5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

前置き

お疲れ様です。NSSの川島と申します。

主に業務系Webアプリケーションの開発に携わっています。

日々の開発の中で個人的に難しいと感じていることの1つに E2E テストの自動化 があります。
E2E自動化系の技術は色々ありますが、今回は比較的情報が充実しているCypressを試してみます。

そもそもE2Eテストって?

アプリの画面などを実際に操作して、期待通りにアプリケーションが動いているかを確認するテストですね。
コンポーネント単位の単体テスト等と比較して、テスト範囲が広く、コストがかかる傾向があります。

単体テストや統合テスト(結合・連結テストとも)との住み分けとしては

  • 単体テスト:関数やコンポーネント単位でテスト
  • 統合テスト:関数やコンポーネント間の連携をテスト(DB、API、外部サービスの結合・連結テスト等)
  • E2E テスト:アプリケーション全体の動作テスト

といった具合になると思います。

▼イメージ

01.E2Eテストのイメージ.png

(個人的に思う)E2Eテスト実施時の問題点

E2Eテストはアプリ全体の動作テストですので、極めて重要です。
ですが上述の通り、実施にコストがかかります。具体的には・・・

  • 単純に打鍵操作が手間
  • 目検が中心のため、確認漏れが多い
  • エビデンスの取得 / 整理が手間

上記のような課題を解消するために、E2Eテストを自動化するツールが公開されています。
Cypressはその中の1つで、上記の課題を解決することが可能です。

  • 単純に打鍵操作が手間 ➡ 打鍵操作自体をコード化することで自動化可能
  • 目検が中心のため、確認漏れが多い ➡ アサート機能により自動化可能画像比較機能というのもあります)
  • エビデンスの取得 / 整理が手間 ➡ キャプチャ機能により自動化可能

Cypress について

  • E2Eテストを自動化するためのJavaScriptベースのテストフレームワークです。
  • E2Eテストだけでなく、統合テストや単体テストにも対応しています。

Selenium と比較

E2Eテスト用のツールとして有名な、Seleniumとの比較です。
Cypressはより直感的かつシンプルに導入~実装が可能な印象です。

項目 Cypress Selenium
テストコードの実行環境 ブラウザ内(アプリと同じスレッド) 外部プロセス
アプリケーションの画面操作 標準搭載 WebDriver経由での操作
イベントの検知 即座に検知可能 遅延あり(ポーリングや待機設定が必要)
画面描画等の自動待機 標準搭載 手動で設定が必要
アサート 標準搭載 外部ライブラリ等が必要
デバッグ機能 GUIあり ログ中心

Cypressの導入

前置きはこのくらいにして、早速使ってみます!

サンプルアプリの作成

この辺は本題ではないのでサクッと済ませます。

執筆時点の動作確認環境

ツール / ライブラリ バージョン
node 22.14.0
vite 6.2.0
React 19.0.0
Cypress 14.3.0

reactプロジェクト作成

viteで作成します。

npm create vite@latest

Need to install the following packages:
  create-vite@6.3.1
Ok to proceed? (y) y
|
o  Project name:
|  vite-project
|
o  Select a framework:
|  React
|
o  Select a variant:
|  JavaScript + SWC
|
o  Scaffolding project in XXXXXXXX #作成されたプロジェクトのパス
|
—  Done. Now run:

アプリケーション起動

  cd vite-project
  npm install
  npm run dev

Cypressのインストール、起動

※具体的な実装例だけ見たい方はこちらへどうぞ ➡ テスト例の紹介

インストール

以下のコマンドでインストール可能です。今回インストールしたバージョンは14.3.0です。

npm install cypress

package.json を編集

{
  "scripts": {
+    "cy:open": "cypress open" 
  }
}

Cypress の稼働確認

npm run cy:open

以下のようなウィンドウが立ち上がれば起動完了です!

E2E testing を選択します。

02.welcome_to_cypress.png

Continue をクリック。

03.configuration_files.png

ブラウザを選択します。今回は Edge を選択しました。
Start E2E Testing in Edge をクリック。

04.choose_a_browser.png

ブラウザが立ち上がりCypress画面が開くのでCreate new specをクリック。

05.create_your_first_spec.png

Create Specを押下。

06.create_spec.png

EdgeCypress公式にアクセスするだけのテストが自動生成されます。
Okey, run the specを押下。

07.okey_run_the_spec.png

テストが実行されて、正常終了すると思います。

08.run_result.png

テストコードの編集

テストコードの編集方法を紹介しておきます。
${プロジェクトのルート}/cypress/e2e/spec.cy.jsを開いて編集します。

describe('template spec', () => {
  it('passes', () => {
    cy.visit('https://example.cypress.io')
  })

  // テストOK
  it('OK', () => {
    expect(true).to.equal(true)
  })

  // テストNG
  it('NG', () => {
    expect(true).to.equal(false)
  })
})

基本構文はMochaというフレームワークを使って記述します。

  • describe:テストの大項目の説明を記述(例:〇〇ボタンの機能)
  • context:テスト条件の説明を記述(例:××という条件の場合)
  • it:具体的なテスト内容を記述(例:〇〇ボタン押下時に△△という挙動をすること)

本記事で実行するテストはシンプルなので、特にこだわってこの辺りを記述していませんが、
丁寧に記述しておくとテスト結果が見やすくなり、保守性が向上すると思います。

それでは、Cypressの画面で Specs から spec.cy.js をクリックして実行してみます。

08-01.edit_test_execution.png

実行結果は以下のようになると思います。
Cypress公式へのアクセスと、//テストOKのところまではグリーンになり、
//テストNGでエラーになります。

08-02.edit_test_result.png

テストコードの追加

${プロジェクトのルート}/cypress/e2e/${任意の名前}.cy.jsを作成し、任意のテストコードを書いてください。

上記と同様 Cypressの画面で Specsから実行可能です。
Specs に表示されない場合は F5 キーでリロードするか、Cypress を再起動すると反映されると思います。

テスト例の紹介

少し遊んでみたので、簡単なテスト例をいくつか紹介します。

例1.ボタン押下とアサート

テスト内容

ボタンを押下すると数字が増える、という挙動をテストします。

09.example1-test-content.png

画面実装(App.jsx)

import { useState } from 'react'
import './App.css'

function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
      </div>
    </>
  )
}

export default App

テストコード(cypress/e2e/counter-test.cy.js)

describe('Counter Test', () => {
    it('Count up', () => {
        // ローカルサーバにアクセス
        cy.visit('http://localhost:5173')

        // "count is" という文字を持っている <button> を取得.
        const countButton = cy.get(".card > button").contains("count is")

        // <button> の中の文字列をアサート. カウントの初期値は0.
        countButton.should("contain", "count is 0")

        // <button> をクリック
        countButton.click()

        // <button> の中の文字列をアサート. カウントが1になる.
        countButton.should("contain", "count is 1")

        // <button> をクリック
        countButton.click()

        // <button> の中の文字列をアサート. カウントが2になる.
        countButton.should("contain", "count is 2")
    })
})

ポイント / メモ

  • Cypressのテストコード(cy.xx)はアプリケーションと同じスレッドで動くので
    awaitなど待機処理を意識せずに直感的に書けます。
  • アサートは標準搭載されているshould()を利用できます。

例2.モーダルのテスト

テスト内容

Modal ボタンを押下するとモーダルが開き、テキスト入力をするまでの挙動をテストします。

10.example2-test-content.png

画面実装(App.jsx)

import { useState } from 'react'
import './App.css'
import Modal from 'react-modal';

Modal.setAppElement('#root');

function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <div className="card">
        <button onClick={() => setIsOpen(true)}>
          Modal
        </button>
        <Modal
          isOpen={isOpen}
          onRequestClose={() => setIsOpen(false)}
          contentLabel="Example Modal"
        >
          <input
          type="text"
          placeholder="入力欄"
        />
          <button onClick={() => setIsOpen(false)}>Close</button>
        </Modal>
      </div>
    </>
  )
}

export default App

テストコード(cypress/e2e/modal-test.cy.js)

describe('Modal Test', () => {
  it('Modal', () => {
      // ローカルサーバにアクセス
      cy.visit('http://localhost:5173')

      // "Modal" という文字を持っている <button> を取得
      const modalButton = cy.get(".card > button").contains("Modal")

      // クリック操作
      modalButton.click()

      // 入力欄に文字を入力
      cy.get('.ReactModal__Content input[type="text"]').type('文字入力テスト');

      // アサート
      cy.get('.ReactModal__Content input[type="text"]').should('have.value', '文字入力テスト');
  })
})

ポイント / メモ

  • DOMに対する操作が可能なので、モーダル操作(入力操作含む)も可能です。
  • モーダルの取得がやや複雑ですが、後述するdata-testidを使用すればシンプルに取得できます。

例3.fetchによる外部通信を含むテスト

テスト内容

ボタンを押下するとfetchでデータを取得し、結果をボタンに表示する挙動のテストです。
今回はJSON PlaceHolderを使用しています。

11.example3-test-content.png

画面実装(App.jsx)

import { useState } from 'react'
import './App.css'

function App() {
  const [todo, setTodo] = useState("");
  const onClickSearch = () => {
    fetch('https://jsonplaceholder.typicode.com/todos/1')
    .then(response => response.json())
    .then(json => setTodo(json.title));
  }


  return (
    <>
      <div className="card">
        <button onClick={onClickSearch}>
          Search: {todo}
        </button>
      </div>
    </>
  )
}

export default App

テストコード(cypress/e2e/fetch-test.cy.js)

describe('Fetch Test', () => {
    it('Fetch', () => {
        // ローカルサーバにアクセス
        cy.visit('http://localhost:5173')

        // "Search" を含む <button>
        const searchButton = cy.get(".card > button").contains("Search")

        // クリック操作
        searchButton.click()

        // アサート
        searchButton.should('contain', 'delectus aut autem')
    })
})

ポイント / メモ

  • 繰り返しになりますが、Cypressの処理はアプリケーションと同じスレッドで処理されます
  • よって、fetch()などを使う場合でも、レスポンスを待機するためのawaitはテストコードに書かなくてOKです

例4.data-testid による要素の取得

テスト内容

data-testid を使用して任意のボタンを取得するテストです。
今回はButton1の方を取得してみます。

12.example4-test-content.png

画面実装(App.jsx)

import './App.css'

function App() {

  return (
    <>
      <div className="card">
        <button data-testid="button1">
          Button1
        </button>
        <button data-testid="button2">
          Button2
        </button>
      </div>
    </>
  )
}

export default App

テストコード(cypress/e2e/data-testid-test.cy.js)

describe('data-testid Test', () => {
  it('data-testid', () => {
      // ローカルサーバにアクセス
      cy.visit('http://localhost:5173')

      // data-testid="button1" でボタンを取得する
      const button1 = cy.get('[data-testid="button1"]')

      // アサート
      button1.should('contain', 'Button1')
  })
})

ポイント / メモ

  • data-testidを画面実装時に付与しておくことで、cy.get()が楽になります。
  • セレクタや表示文言等を意識しなくなるので、画面実装の変更にもある程度強くなります。
  • Cypress公式 では、これが推奨されているようです。

例5.キャプチャの取得

テスト内容

キャプチャ取得機能を試すテストです。
それだけだと面白くないので、画面スクロール等と組み合わせてテストシナリオを書いてみました。

テストする画面のイメージは以下です。例1.で使用したボタンと、スクロールバー付きのリストを使用します。

13.example5-test-content.png

画面実装(App.jsx、App.css)

import { useState } from 'react'
import './App.css'

function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <ul class="scrollable-list">
          {Array.from({ length: 50 }, (_, i) => i).map(i => <li>Item {i}</li>)}
        </ul>
      </div>
    </>
  )
}


export default App
.scrollable-list {
  max-height: 150px;
  overflow-y: auto;
  border: 1px solid #ccc;
  padding: 8px;
  width: 100%;
}

テストコード(cypress/e2e/capture-test.cy.js)

describe('Capture Test', () => {
    it('Capture', () => {
        // ローカルサーバにアクセス
        cy.visit('http://localhost:5173')

        // "count is" という文字を持っている <button> を取得.
        const countButton = cy.get(".card > button").contains("count is")

        // <button> の中の文字列をアサート. カウントの初期値は0.
        countButton.should("contain", "count is 0")

        // キャプチャ取得
        cy.screenshot('Capture Test/No1',  { overwrite: true, capture: 'viewport' });

        // <button> をクリック
        countButton.click()

        // <button> の中の文字列をアサート. カウントが1になる.
        countButton.should("contain", "count is 1")

        // キャプチャ取得
        cy.screenshot('Capture Test/No2',  { overwrite: true, capture: 'viewport' });

        // <button> をクリック
        countButton.click()

        // <button> の中の文字列をアサート. カウントが2になる.
        countButton.should("contain", "count is 2")

        // キャプチャ取得
        cy.screenshot('Capture Test/No3',  { overwrite: true, capture: 'viewport' });

        // リストをスクロールしながらキャプチャ
        cy.get(".scrollable-list").then(element => {
            const scrollDistance = 150;
            const el = element[0];
            const scrollHeight = el.scrollHeight;
            const scrollSteps = scrollHeight / scrollDistance + 1;

            Cypress._.times(scrollSteps, (i) => {
              cy.wrap(null).then(() => {
                el.scrollTop = i * scrollDistance;
              });
              cy.wait(1000);
              cy.screenshot(`Capture Test/step-${i}`,  { overwrite: true, capture: 'viewport' });
            });
        })
    })
})

ポイント / メモ

  • cy.screenshot()でキャプチャが取得可能です
    • overwrite: trueがないと実行の度にファイルが増えてしまうので注意です
    • capture: 'viewport' は画面サイズが大きい場合に、メモリ不足にならないように入れています。
      これにより、画面に映っている範囲のみキャプチャするようになります
  • スクロールしながらキャプチャするところは少し工夫が必要で
    cy.XX以外の操作をcy.wrap()でラッピングしてあげる必要があります。
    理由とポイントは以下の通りです
    • cy.XXの処理は即時実行ではなくCypressのコマンドキューに追加してから非同期で実行されます
    • cy.XXの処理とそれ以外の処理を同期的に実行するために、cy.wrap()が必要です
    • また、ループ処理もforではなくCypress._.times()で表現する必要があります
  • NGの実装例も以下に記載しておきます。(若干ハマったやつ)
    • 下記の実装だとcy.wait()cy.screenshot()が処理される前にループが5回動いてしまいます
    • そのため、スクロール操作 ➡ キャプチャ取得 ➡ スクロール操作...という処理が実現できないので、注意です
cy.get(".scrollable-list").then(element => {
    const scrollDistance = 150;
    const el = element[0];
    const scrollHeight = el.scrollHeight;
    const scrollSteps = scrollHeight / scrollDistance + 1;

   /** NG例(forの中に cy.wait() や cy.screenshot() を書いてしまっている) */
   for (let i = 1; i <= 5; i++) {
      el.scrollTop = i * scrollDistance
      cy.wait(1000)
      cy.screenshot(`Capture Test/scroll-step-${i}`, { overwrite: true, capture: 'viewport' })
    }
})

実行結果

実行すると以下のように自動的にキャプチャが取得されます。

14.キャプチャ全量.png

▼ボタンを1回押下した後のキャプチャ

15.ボタン1回押下後.png

▼スクロール中のキャプチャ

16.スクロール中.png

最後に

Cypressの導入から簡単なテスト実装例をご紹介しました。

公式ドキュメントがかなり充実しているので、導入のハードルは比較的低いと感じました。

使ってみて思いましたが、画面操作の自動化を簡単に実装可能というだけでも十分価値があるかなと思います。

業務での導入の第一歩として、データ投入操作を自動化するや、各画面のキャプチャを自動で取得させるなど、
定型的な作業から自動化していくのもありかもしれませんね。
(実際、過去に参画していた現場ではそんな使い方もしてました)

ご覧いただきありがとうございました!

参考(Cypress公式ドキュメント)

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?