前置き
お疲れ様です。NSSの川島と申します。
主に業務系Webアプリケーションの開発に携わっています。
日々の開発の中で個人的に難しいと感じていることの1つに E2E テストの自動化 があります。
E2E自動化系の技術は色々ありますが、今回は比較的情報が充実しているCypress
を試してみます。
そもそもE2Eテストって?
アプリの画面などを実際に操作して、期待通りにアプリケーションが動いているかを確認するテストですね。
コンポーネント単位の単体テスト等と比較して、テスト範囲が広く、コストがかかる傾向があります。
単体テストや統合テスト(結合・連結テストとも)との住み分けとしては
- 単体テスト:関数やコンポーネント単位でテスト
- 統合テスト:関数やコンポーネント間の連携をテスト(DB、API、外部サービスの結合・連結テスト等)
- E2E テスト:アプリケーション全体の動作テスト
といった具合になると思います。
▼イメージ
(個人的に思う)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:
[email protected]
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
です。
package.json を編集
{
"scripts": {
+ "cy:open": "cypress open"
}
}
Cypress の稼働確認
以下のようなウィンドウが立ち上がれば起動完了です!
E2E testing を選択します。
Continue をクリック。
ブラウザを選択します。今回は Edge を選択しました。
Start E2E Testing in Edge をクリック。
ブラウザが立ち上がりCypress
画面が開くのでCreate new specをクリック。
Create Specを押下。
EdgeでCypress
公式にアクセスするだけのテストが自動生成されます。
Okey, run the specを押下。
テストが実行されて、正常終了すると思います。
テストコードの編集
テストコードの編集方法を紹介しておきます。${プロジェクトのルート}/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
をクリックして実行してみます。
実行結果は以下のようになると思います。Cypress
公式へのアクセスと、//テストOK
のところまではグリーンになり、//テストNG
でエラーになります。
テストコードの追加
${プロジェクトのルート}/cypress/e2e/${任意の名前}.cy.js
を作成し、任意のテストコードを書いてください。
上記と同様 Cypress
の画面で Specsから実行可能です。
Specs に表示されない場合は F5 キーでリロードするか、Cypress
を再起動すると反映されると思います。
テスト例の紹介
少し遊んでみたので、簡単なテスト例をいくつか紹介します。
例1.ボタン押下とアサート
テスト内容
ボタンを押下すると数字が増える、という挙動をテストします。
画面実装(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" という文字を持っている
const countButton = cy.get(".card > button").contains("count is")
//
countButton.should("contain", "count is 0")
//
countButton.click()
//
countButton.should("contain", "count is 1")
//
countButton.click()
//
countButton.should("contain", "count is 2")
})
})
ポイント / メモ
-
Cypress
のテストコード(cy.xx
)はアプリケーションと同じスレッドで動くのでawait
など待機処理を意識せずに直感的に書けます。 - アサートは標準搭載されている
should()
を利用できます。
例2.モーダルのテスト
テスト内容
Modal ボタンを押下するとモーダルが開き、テキスト入力をするまでの挙動をテストします。
画面実装(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" という文字を持っている
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を使用しています。
画面実装(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" を含む
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
の方を取得してみます。
画面実装(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.で使用したボタンと、スクロールバー付きのリストを使用します。
画面実装(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" という文字を持っている
const countButton = cy.get(".card > button").contains("count is")
//
countButton.should("contain", "count is 0")
// キャプチャ取得
cy.screenshot('Capture Test/No1', { overwrite: true, capture: 'viewport' });
//
countButton.click()
//
countButton.should("contain", "count is 1")
// キャプチャ取得
cy.screenshot('Capture Test/No2', { overwrite: true, capture: 'viewport' });
//
countButton.click()
//
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' })
}
})
実行結果
実行すると以下のように自動的にキャプチャが取得されます。
▼ボタンを1回押下した後のキャプチャ
▼スクロール中のキャプチャ
最後に
Cypress
の導入から簡単なテスト実装例をご紹介しました。
公式ドキュメントがかなり充実しているので、導入のハードルは比較的低いと感じました。
使ってみて思いましたが、画面操作の自動化を簡単に実装可能というだけでも十分価値があるかなと思います。
業務での導入の第一歩として、データ投入操作を自動化するや、各画面のキャプチャを自動で取得させるなど、
定型的な作業から自動化していくのもありかもしれませんね。
(実際、過去に参画していた現場ではそんな使い方もしてました)
ご覧いただきありがとうございました!
参考(Cypress公式ドキュメント)
Views: 2