このエントリーはトレタ Advent Calendar 2022の21日目の記事です。
トレタに入社して約半年、一度も出社したことのない在宅勤務の申し子あさひです。
業務では、O/Xと呼ばれる、飲食店で自分のスマホから
料理などの注文ができるサービスのQAを担当しています。
今回は、画面の自動テストを高速に実行出来るComponentのテストについて、
CypressとReactを用いて紹介しようと思います。
対象読者
- Reactを使っている人(Next.jsやViteも含む)
- componentテストが気になって布団に入ってもスマホで調べちゃう人
- seleniumで消耗したが、テスト自動化の夢を諦めきれない人
- componentもTDD/BDDで実装してみたい人
- とにかく自動テストを速く実行したい人
Cypressとは
簡単に説明すると、js/tsでテストを書くためのライブラリで、
画面表示や関数やAPIなど様々なものを対象にテストを行うことが出来ます。
いくつかの有料の機能はありますが、
手元の環境でテストを動かしたりする程度であれば会員登録も支払いも不要です。
Componentテストについて
関数のテストと同様に、画面の表示や動作の確認を
コンポーネント単位で行うテストです。
コードを書きながらブラウザで行っていた動作確認の殆ど自動化することが出来ます。
なお、意図したスタイルが当たっているかなど、
見た目に関するテストについてはあまり得意ではありません。
Componentテストの魅力
速い
なんと言ってもテスト実行速度の速さが一番の魅力です。
本当に速いです。
Seleniumで書いていたテストは何だったのかと思うぐらい速いです。
もちろん、テスト対象やテスト量に依存はしますが、
小さいコンポーネントであれば速く実行できます。
Componentテストを厚くしておけば、手間のかかるE2Eテスト削減して
テストを効率化させることもできます。
簡単に始められる
UIの自動テストは以下の問題によくぶつかると思います。
- 画面要素が多くて、どこから手をつければいいのか・・
- 認証とか前処理とか多くて、テストを書くまでの事前準備のほうが大変・・・
- 適切な属性の設定が無く、DOMの取得が困難・・
下層のコンポーネントから始めれば
- コード量は少なく、機能も明確なのでコードが書きやすい
- 機能が少ないので、数十行程度のテスト量で済む
- 画面要素が少ないので、テスト用の属性を追加しやすい
テストを書く際は、アトミックデザインで言うところのATOMSから始めると良いです。
上流に向かうにつれ、コンポーネント自体の複雑さは増していきますが、
データ準備が容易
この辺は、Jest等で関数のテストを行うときと同じです。
様々なデータパターンでの表示確認は、
テストコード内で定義したデータを渡すことで実現出来ます。
そのため、テストのためにDBへのデータ登録をしたり、
テスト後に不要なデータを削除したりするような
面倒な事前/事後処理を書く機会を減らすことが出来ます。
export default ({title}) => {
if(!title)return <p id="title">タイトルの取得に失敗しました</p>
return <h1 id="title">{title}</h1>
}
import Element from './component.jsx'
it('titleが無い場合', function (){
// Elementには何も渡さない
cy.mount(<Element/>)
//titleのエラー表示確認
cy.get('#title').should('have.text','タイトルの取得に失敗しました')
})
it('titleが有る場合', function (){
// Elementのtitleに値を渡す
cy.mount(<Element title="任意のタイトル"/>)
//titleの正常表示確認
cy.get('#title').should('have.text','任意のタイトル')
})
DBの参照が必要な場合でも、
ある程度ならcypress側でレスポンスを改ざんすることも出来ます。
import { useState, useEffect } from 'react'
export default () => {
const [title, setTitle] = useState()
useEffect(() => {
const init = async () => {
const response = await fetch('/API/itemData')
const json = await response.json()
setTitle(json.title)
}
init()
}, [])
if (!title) return <p id="title">タイトルの取得に失敗しました</p>
return <h1 id="title">{title}</h1>
}
import Element from './component.jsx'
it('titleが有る場合', function () {
// /API/itemDataのレスポンスを改ざんする
cy.intercept('GET', '/API/itemData', {
statusCode: 201,
body: {
// response.json()実行時の戻り値を以下に改ざんする
title: '任意のタイトル',
},
}).as('request')
cy.mount(<Element />)
//titleの正常表示確認
cy.get('#title').should('have.text', '任意のタイトル')
})
セットアップ
こちらを参照してください。
特に複雑な手順は無く、
npmコマンドでCypressをインストールしたあと、
画面の指示に従って簡単な初期設定を行うだけでセットアップが完了します。
ちなみに、React用のCypressは、Next.jsやViteにも対応しています。
テストを書いてみる
今回は先程の例に使用したコンポーネントを使用します。
export default ({title}) => {
if(!title)return <p id="title">タイトルの取得に失敗しました</p>
return <h1 id="title">{title}</h1>
}
見てのとおりではありますが、このコンポーネントは以下の仕様を持っています。
- titleがない場合は、取得失敗エラーを表示する
- titleがある場合は、titleの値をh1タグで表示する
では早速、仕様通りに動作することを確認するテストを書いていきます。
テストを書いていく
大まかな流れとしては、
cy.mount
でテスト対象のコンポーネントを呼び出し、
cy.get
でテスト対象のDOMを取得し、
チェーンでアサーション(should
)を繋げて期待値を確認しています。
import Element from './component.jsx'
it('titleが有る場合', function (){
cy.mount(<Element title="任意のタイトル"/>)
cy.get('#title').should('have.text','任意のタイトル')
})
コンポーネントをmountする
cy.mount
でコンポーネントをテスト可能な状態にします。
cy.mount(<Element title="任意のタイトル"/>)
propsが有る場合は、
reactのコードを書くときと同様に属性を指定して値を渡します。
要素を取得する
cy.get
でSelectorを指定して、要素を取得します。
cy.get('#title')
今回の例ではidを指定していますが、CSS用の属性は変更が入りやすい箇所のため、
ボタン名や、テスト専用属性などの変更されにくい要素を使うことが推奨されています。
テスト専用属性を使う場合は、以下の様に置き換えられます。
//DOM
<h1 data-cy="title">{title}</h1>
//test
cy.get('[data-cy="title"]')
期待値を確認する
cy.get
で取得したDOMに対しては、should
を繋げて期待値を確認します。
cypressでは、cyコマンドの戻り値を定数や変数に定義することが出来ません。
そのため、上記の例の様にDOMに関する操作はcy.get
を起点に、
findやclickといった関数をメソッドチェーンで繋げて処理を書いていくこととなります。
cy.get('#title').should('have.text', '任意のタイトル')
shouldでinnerTextを確認したい場合は、
第一引数にhave.text
を指定し、第二引数に期待値を指定します。
第一引数に指定できるアサーションは複数あり、
テスト対象やテスト内容に応じて使い分ける必要があります。
テストを実行する
書いたテストをCypress上で実行するとこんな感じになります。
ちなみに、Cypressはウォッチモードで起動するため、
すでに起動していた場合はテストを保存したタイミングでテストが実行されます。
期待値についても、「任意のタイトル」となっていることを確認出来ています。
要素を取得してみる
テストを書く大まかな流れを説明しましたので、
次は要素の取得について詳しく説明していきます。
要素を取得する関数
get
基本的に、要素を取得はget関数を使います。
getは、selectorを指定することで意図した要素を取得することが出来ますが、
何度も使うような要素はaliasに定義して使い回すことも出来ます。
// getで要素を取得したあと、as関数を繋げます
cy.get('[data-cy="submit"]')
// get('@submitボタン')でこの要素を取得出来るようになります
.as('submitボタン')
// aliasを使ってボタンを取得し、取得したボタンが非活性であることを確認
cy.get('@submitボタン').should('be.disabled')
// form内の名字テキストボックスに値を入力
cy.get('form').contains('名字').type('田中')
// aliasを使ってボタンを取得し、状態が変わっていることを確認
cy.get('@submitボタン').should('be.enabled')
また、getで取得した要素に対して、selectorやテキストなどで子要素を取得できるため、
全要素にdata-*属性を設定しなくともある程度変更に強いテストを書くことが出来ます。
子要素の取得についてはいくつかの手法がありますが、
よく使いそうなものをピックアップして紹介します。
子要素を取得する関数
get(NG例)
こちらはNG例です。
getは常にcy.root要素から検索を開始するため、
以下の様にgetにgetを繋げて子要素を取得することは出来ません。
cy.get('form').get('button')
この様に記載をしたい場合は、find関数を使用します。
find
find関数は、selectorを指定して子要素を取得出来ます。
cy.get('form').find('button')
eq
eq関数はn番目の要素を指定することが出来、
mapで要素を生成するような一覧機能を持つコンポーネントで有用です。
例えば、ユーザー一覧で2番目のユーザー情報を取得するというようなことが出来ます。
cy.get('[data-cy="ユーザー情報"]')
.eq(1)
.find('[data-cy="生年月日"]')
.should('have.text','2000/01/01')
contains
contains関数では指定したテキストを含む要素を取得でき、
メニュー要素の中から、
文言の一致するリンクを探してクリックさせるような操作が可能になります。
cy.get('[data-cy="サイドメニュー"]')
.contains('ログアウト')
.click()
また、containsは、cyから直接実行してcy.rootを起点に要素を取得することもできるため、
要素の少ないコンポーネントの場合は直接containsで要素を取得しにいくのもアリです。
cy.contains('ログアウト').should('be.disabled')
within
within関数は、本来はcy.rootである要素検索の起点を
コールバック内に限って親要素へ変更することが出来ます。
withinはcyから直接実行できるcontainsと非常に相性がよく、
フォームのテストに使用すると飛躍的に可読性を向上させることが出来ます。
export default () => {
return <>
<form cy-data="検索フォーム">
<label>
<span>名前</span>
<input type="text" />
</label>
<input type="submit" value="検索" />
</form>
<form cy-data="ユーザー登録フォーム">
<label>
<span>名前</span>
<input type="text" />
</label>
<label>
<span>メールアドレス</span>
<input type="email" />
</label>
<input type="submit" value="登録" />
</form>
</>
}
このようなフォームがあった場合、以下のようにテストが書けます。
cy.get('[cy-data="ユーザー登録フォーム"]').within(() => {
cy.contains('名前').type('田中')
cy.contains('メールアドレス').type('test@example.com')
cy.contains('登録').click()
})
then
innerTextなど、DOMのパラメーターをどうしても取得したい場合が有ると思います。
その時は、thenを使うことで、DOMのパラメーターにアクセスすることが出来ます。
it('ログインユーザーの検索', function () {
cy.get('[data-cy="ログインユーザー情報"]')
.find('[data-cy="ユーザー名"]')
.then(($el) => {
// 要素の検索結果が1件だとしても$elは配列になるため、[0]を指定する
this.userName = $el[0].innerText
})
cy.get('[data-cy="検索フォーム"]').type(this.userName)
})
要素に対する操作を書いてみる
次は、取得した要素に対する操作について説明します。
cypressでは、クリックや文字入力など様々な操作を行うことが出来ますが、
大体が取得した要素にチェーンして実行するのみとなるため割愛します。
selectFile
selectFile関数は、input[type="file"]の要素に対して、
ファイルを選択する場合に使用します。
cy.get('form').contains('ユーザーアイコン')
.find('input')
.selectFile('cypress/fixtures/image.png')
引数に指定するパスは、ルートフォルダを起点に指定します。
テストの期待値を書いてみる
最後に、取得した要素や、操作を行った後の期待値の確認方法について説明します。
should
get等で取得した要素の状態を確認する場合の多くは、
should関数を使います。
should関数では、要素が指定の条件を満たすかを確認する場合と、
要素のプロパティが指定値と一致するかを確認することが主となります。
こちらも数が多いので、使用頻度の高そうなものに絞って説明します。
be.visible / not.be.visible
表示確認で多用することとなる、見えているか?のアサーションです。
html上には存在するが、表示されていない要素(display:noneなど)を
確認する場合は、以下の様に記載します。
cy.get('[data-cy="ローディング"]').should('not.be.visible')
画面上に表示されていることを確認する場合は、notを外します。
cy.get('[data-cy="ローディング"]').should('be.visible')
be.exist / not.be.exist
こちらも、表示確認で多用することとなる、存在するか?アサーションです。
画面上に表示されているかは問いません。
html上に存在しない要素を確認する場合は、以下の様に記載します。
cy.get('[data-cy="ローディング"]').should('not.be.exist')
画面上に表示されているかは問わず、html上の存在確認をする場合はnotを外します。
cy.get('[data-cy="ローディング"]').should('be.exist')
have.text
文言確認で多用するアサーションです。
要素のinnerTextが期待通りかを確認します。
cy.get('[data-cy="ユーザー名"]').should('have.text', '田中 太郎')
have.length
動的に要素を生成するコンポーネントで使用するアサーションです。
指定条件に一致する要素の数が期待通りかを確認します。
例えば、引数の値によって表示する要素数が変わるコンポーネントの場合、
以下の様に記載します。
const userData = [
{name:'田中', isValid: true},
{name:'鈴木', isValid: false},
]
cy.mount(<Element userData={userData}/>)
// 鈴木は無効なので、田中のデータだけが出力されることを確認したい
cy.get('[data-cy="ユーザー情報"]').should('have.length', 1)
Cypressでテストコードを書くときの注意点
最後に、テストを書く上で何度もハマってしまことがあったためいくつか紹介します。
Cypressには特有のクセが有るので、
変に先入観があると最初のうちは結構苦戦します。
cyコマンドの戻り値にconst/letは使えない
ここにもある通り、cyに続くコマンドの結果を
constなどに定義して使うことは出来ません。
そのため、メソッドチェーンで関数を繋げてテストを実行しています。
acync/awaitは使わない
ここにもある通り、cypressはasync/await を使えるように設計されていません。
そのため、thenを駆使してメソッドチェーンを連ねる他ありません。
アロー関数を使うとthisでaliasを参照できない
as関数を使ったり、this.*に値を定義することで、
異なるスコープから値を参照することが出来ます
しかし、アロー関数で書かれているitやbeforeEach内では、
this.*を使用することが出来ません。(@によるaliasは可能)
そのため、itやbeforeEachなどでは通常の構文で関数を宣言することが望ましいです。
beforeEach(function () {
cy.wrap({name: '田中'}).as('testData')
})
it('thisの参照がエラーになる', () => {
// thisの参照時にエラーとなる
cy.log(this.testData)
})
it('thisが参照出来る', function () {
// アロー関数ではないため、thisの参照が可
cy.log(this.testData)
})
@のaliasはgetとwaitにしか使えない
ここに書いてあるとおり、
@による参照はget関数とwait関数でしか使えません。
それ以外の関数で使用した場合は、ただの文字列として扱われるため
@があるからアロー関数使っても大丈夫!!とはなりませんでした・・。
beforeは、1つ目のitにしか機能しない
mochaやjestといったフレームワークに慣れている私にとって
とても不思議な挙動でした。
テストを書いているとき、it毎にmountを書くのが面倒だったため、
beforeでmountするようにしたところ、急に2つ目のテストが通らなくなりました
describe('通らないテスト',function(){
before(()=>{
cy.mount(<Element />)
})
it('要素の表示確認',function(){
// 問題なくテストがパスする
cy.get('input').should('be.visible')
})
it('要素の入力確認',function(){
// be.visibleが確認出来ているはずなのに、要素が存在しないとエラーが出る
cy.get('input').type('テスト')
})
})
ここにある通り、cypressではit毎に状態をリセットする仕様でした。
そのため、beforeでのmountは2つ目のit実行時にリセットされており、
mountされていない虚無を参照したためにテストがエラーとなっていたわけです。
今回のようなケースで想定通り動くコードを書く場合は、
beforeEachを使います。
describe('通るテスト',function(){
beforeEach(()=>{
cy.mount(<Element />)
})
it('要素の表示確認',function(){
// 問題なくテストがパスする
cy.get('input').should('be.visible')
})
it('要素の入力確認',function(){
// こっちも問題なくテストがパスする
cy.get('input').type('テスト')
})
})
トレタでは一緒に働く自動化エンジニア募集しています!!
トレタでは、テスト自動化エンジニア/SETの方を募集しています。
この記事を読んで興味が湧いた方はぜひご連絡ください。
他の業種も募集してます!