Let's Start Cypress ～テスト自動化スタートダッシュ～

## 前置き
お疲れ様です。NSSの川島と申します。

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

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

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

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

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

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

▼イメージ

![01.E2Eテストのイメージ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/610d991a-f094-4cb3-9b22-b4764e6ab882.png)

### （個人的に思う）E2Eテスト実施時の問題点
E2Eテストはアプリ全体の動作テストですので、極めて重要です。
ですが上述の通り、実施にコストがかかります。具体的には・・・

- 単純に打鍵操作が手間
- 目検が中心のため、確認漏れが多い
- エビデンスの取得 / 整理が手間
  
上記のような課題を解消するために、E2Eテストを自動化するツールが公開されています。
`Cypress`はその中の1つで、上記の課題を解決することが可能です。

- 単純に打鍵操作が手間 ➡ **打鍵操作自体をコード化することで自動化可能**
- 目検が中心のため、確認漏れが多い ➡ **アサート機能により自動化可能**（[画像比較機能](https://docs.cypress.io/app/tooling/visual-testing/)というのもあります）
- エビデンスの取得 / 整理が手間 ➡ **キャプチャ機能により自動化可能**

### Cypress について

https://www.cypress.io/

- 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`で作成します。
```bash
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:

```

#### アプリケーション起動
```bash
  cd vite-project
  npm install
  npm run dev
```

## Cypressのインストール、起動
※具体的な実装例だけ見たい方はこちらへどうぞ ➡ [テスト例の紹介](#テスト例の紹介)
#### インストール
以下のコマンドでインストール可能です。今回インストールしたバージョンは`14.3.0`です。
```bash
npm install cypress
```

#### package.json を編集
```diff
{
  "scripts": {
+    "cy:open": "cypress open" 
  }
}
```

#### Cypress の稼働確認
```bash
npm run cy:open
```
以下のようなウィンドウが立ち上がれば起動完了です！

**E2E testing** を選択します。

![02.welcome_to_cypress.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/74f122a3-af94-4b83-838a-eabe73ba726e.png)

**Continue** をクリック。

![03.configuration_files.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/05ee2d42-de27-4062-a8a9-6e23e02d157b.png)

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

![04.choose_a_browser.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/56627130-5706-4c30-a749-928e36c717b7.png)

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

![05.create_your_first_spec.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/200bf87b-5873-446a-a039-c01ca4200a05.png)

**Create Spec**を押下。

![06.create_spec.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/ac586848-03e7-4c4c-8caf-958d478be7b0.png)

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

![07.okey_run_the_spec.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/ac520ca0-643b-444c-8930-4af2cabfdbde.png)

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

![08.run_result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/71b8f44c-5362-433d-8530-91e9813a89a9.png)

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

```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/8b0bd150-08e7-44f6-976b-80e94f2566d8.png)

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

![08-02.edit_test_result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/9ef39020-1e48-4031-88d7-9594432e1cb2.png)

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

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



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

### 例1.ボタン押下とアサート
#### テスト内容
ボタンを押下すると数字が増える、という挙動をテストします。
  
![09.example1-test-content.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/ba01e3fd-87d8-4fc6-ae40-ae1d56fc1c22.png)

#### 画面実装(App.jsx)
```js
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)
```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/0154a4ce-a229-4b6a-aa04-2a7827c2d955.png)

#### 画面実装(App.jsx)
```js
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)
```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](https://jsonplaceholder.typicode.com/)を使用しています。

![11.example3-test-content.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/3b74e226-29f2-4a0c-a35a-846a226e7aaa.png)

#### 画面実装(App.jsx)
```js
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)
```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/911bb712-b693-4c91-b3d8-c8fdfc82fdfe.png)

#### 画面実装(App.jsx)
```js
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)
```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公式](https://docs.cypress.io/app/core-concepts/best-practices#Selecting-Elements) では、これが推奨されているようです。


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

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

![13.example5-test-content.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/0deb4a54-6a0e-4421-b9e3-8acc238e106a.png)

#### 画面実装(App.jsx、App.css)
```js
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
```

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

#### テストコード(cypress/e2e/capture-test.cy.js)
```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回動いてしまいます
  - そのため、スクロール操作 ➡ キャプチャ取得 ➡ スクロール操作...という処理が実現できないので、注意です

```js
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/331b7e1d-0179-49d6-82bf-c9b5ddf29e7b.png)

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

![15.ボタン1回押下後.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/c96d969d-2033-458c-ba1e-00822d37b7f3.png)

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

![16.スクロール中.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3799996/f4bc3999-449c-4724-9ed6-2179fd2e5a25.png)


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

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

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

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

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

### 参考（Cypress公式ドキュメント）

https://docs.cypress.io/app/get-started/why-cypress

https://github.com/cypress-io/cypress