Cypressの導入
おはようございます、モチベーションクラウドの開発に参画している@sinpaoutです。
TL; DR
フロントエンドの機能動作を担保するためCypressを導入しインテグレーションテスト機構を構築した。
Cypressの導入、使い方、工夫、運用方法について記述する。
![Cypress] (https://cdn.deliciousbrains.com/content/uploads/2018/09/28135025/db-End2EndTestingCypress-1540x748.jpg)
経緯
新機能を開発していると、ある日「手を止めて負債を解消しようぜ」という天命が下って希望の日差しが見えた。
みんなで負債リストを書き出し、優先順位づけしてリファクタリングを行う準備をした。
そこでリファクタリングの動作確認に自動テストの導入が必須であることに気づきCypressを使ったインテグレーションを導入することに至った。
インテグレーションテストとは
ユニットテストを増やすと勝手に品質が上がるという話をよく聞くが
書くことが多く短時間で品質を担保するためには不向き。
しかし、E2E全機能を全ブラウザーでテストもできないので
Chromeのみで基本動線を担保するレベルのものにすることになった。
環境
Rails、Vue+Vuex、SPA。
一部coffee+erbも残っているが今回はSPAだけを対象にする。
Cypress
先ず動かして感覚をつかめる
実際のテスト
シナリオ1
- データが存在する場合ユーザー一覧が表示されること
- データなしのメッセージが表示されないこと
cy.get('.users tbody tr').should('to.have.length.gt', 0)
cy.get('.empty-msg').should('not.exist')
基本的にDOMの特定はcy.getを使いjqueryのセレクターがサポートされている。
アサーションはshould関数をしよう。
シナリオ2
- 一覧のデータをクリックすることで詳細ページへ遷移できること
- 表示されたページが詳細ページであること
- パンくずの一つ前をクリックで元の一覧画面に戻ること
import url from 'url'
cy.get('.users tbody tr').eq(0).click()
cy.url().should(pageUrl => {
const parsedUrl = url.parse(pageUrl)
// urlが正しいこと
expect(parsedUrl.pathname.match(/\/user\/[0-9]+/)).to.ok
})
※ SPAのURLの検証はパターンのみ検証するため正規表現を使う
Page Object Pattern
各テストでDOMを直接参照しているとDOMの変更があった場合の修正範囲が広がる。
別の画面の機能を呼び出したい時にページのDOM構成を調べる必要がある。
上記の問題を解決するためPage Object Patternを導入する。
先ず共通的なPage Objectを追加。
- ページを意識しない共通的な機能を実装
import url from 'url'
const PAGE_URLS = {
companies: '/companies/?',
users: '/companies/[0-9]+/users/?',
users: '/companies/[0-9]+/users/[0-9]+/?'
}
export default class PageObject {
reload () {
cy.reload()
}
breadcrumb () {
return cy.get('.breadcrumbs')
}
breadcrumbs () {
return cy.get('.breadcrumbs-list > li')
}
// 現在のページのURLをチェック
isPageOf (pageName) {
cy.url().should(pageUrl => {
if (!PAGE_URLS[pageName]) {
throw new Error(`Page url regex not found for pageName: ${pageName}`)
}
const pathReg = new RegExp(PAGE_URLS[pageName])
const parsedUrl = url.parse(pageUrl)
expect(parsedUrl.pathname.match(pathReg)).to.ok
})
}
}
各ページのPage Objectが共通を継承し、該当ページの機能を実装。
import CommonPageObject from '../../common/PageObject'
const URL = '/users'
export default class UsersPageObject extends CommonPageObject {
visit () {
cy.visit(URL)
}
// 検索エリアを開く
expandSearchArea () {
cy.get('.users .fieldset').then($elements => {
if ($elements.length <= 2) {
cy.get('.users .expand-button').click()
}
})
}
// 検索ボタンの押下
search () {
cy.get('.users .actions button').click()
}
getUserRows () {
return cy.get('.user-list tbody tr')
}
emptyMessage () {
return cy.get('.empty-data')
}
}
シナリオ3
- リクエストの中身を検証する
共通PageObjectに関数を追加。
// PageObject.js
spyApiModule () {
cy.window().its('axios').then((module) => {
cy.spy(module, 'get')
cy.spy(module, 'post')
cy.spy(module, 'put')
cy.spy(module, 'delete')
})
}
getSpiedApiModule () {
return cy.window().its('axios')
}
※ windowオブジェクトからaxiosを取得しているので
axiosがグローバル変数としてセットされている必要がある。
if (process.env.NODE_ENV === 'test') {
window.axios = axios
}
使い方
import UsersPageObject from './UsersPageObject'
const userPage = new UsersPageObject()
// 予めスパイさせる
userPage.spyApiModule()
userPage.search()
userPage.getSpiedApiModule().then((module) => {
cy.wrap(module.get).should('be.called')
const firstArgs = module.get.firstCall.args[0]
const parsedQuery = queryString.parse(url.parse(firstArgs).query)
expect(parsedQuery).eqls({
page: '1',
limit: '25',
sort: 'email',
direction: 'asc'
})
})
※ SpyモジュールはSinonが使われている。
運用
テストの記述は2段階に分けて行う。
- 先ずテストケースだけ(describeとit)のみを記述しレビューを通す
- それからテストの中身(DOM操作やアサーション)を記述する
テストケースとテストの中身を分けることによってレビューコストを削減ができる。
また、作業分担できるので担当者の得意な部分を依頼ができ、作業効率化が図れる。
Config
cypress.json
{
"baseUrl": "http://localhost:8081/",
"ignoreTestFiles": [
"!/**/*.spec.js"
],
"viewportWidth": 1300,
"viewportHeight": 800,
"video": false
}
integrationフォルダー配下は全てテストファイル扱いになる。
PageObejctのファイルを対象外にしたいので *.spec.js
のみが対象となるようにする。
CI連携
モックサーバーが立ち上がってからCypressが走る必要あるのでCI上では工夫が必要。
先ず公式のGuideを参照し、npm scriptを工夫する。
モック
モックサーバーは基本的にDockerなどを使わずローカルでwebpack-dev-serverで起動する。
APIのモックはJSON静的なJSONファイルで提供している。
APIモックの詳細はこちらの記事 を参照。
シェルスクリプトを用意して環境変数のセットとwebpack-dev-serverの起動を行っている。
./bin/local-front-dev
運用コマンド
# モックサーバーを起動
yarn serve:local
# Cypressのtest runnerを起動(モックサーバーを立上げておく)
yarn cy:open
# ヘッドレスでテストを実行(モックサーバーを立上げておく)
yarn cy:run
# ローカルでテストを記述
yarn e2e:local
# CI上でテストを実行
yarn e2e:ci
タスクの詳細
Cypressの起動&テストタスク
"cy:open": "cypress open",
"cy:run": "cypress run",
モックサーバー起動・終了タスク
"serve:local": "./bin/local-front-dev",
"serve:local:kill": "kill $(lsof -i :8081 | grep node | awk '{print $2}')",
モックが立ち上がるのをwaitするタスク
"serve:local:wait": "wait-on http-get://localhost:8081/assets/packs/index.js",
ローカル用タスク
"e2e:local": "run-p serve:local e2e:test",
"e2e:test": "run-s serve:local:wait cy:open",
CIで用タスク(終了後プロセスを殺す)
"e2e:ci": "run-p serve:local e2e:ci:test",
"e2e:ci:test": "run-s serve:local:wait cy:run serve:local:kill",
wait-on モジュールのスターも忘れずに。
問題
ヘッドレスの場合 Electron
ブラウザーしか選択肢がない。
テストケースによってヘッドレスで固まってしまうことがあるので
CIの安定した運用はまだ先になりそう。
Chromeのヘッドレスもサポートするそうで期待したい。
https://github.com/cypress-io/cypress/issues/832
感想
E2Eに特化しているサービスだけあってデバッグ機能など豊かで使いやすい。
OSSで始められて将来テストが遅くなったらお金払って並列実行などで運用してもらうことも可能。
Test runner:
Test runner のUI上でselectorやデータ型の相違などの問題が解決するので
になれることをおすすめ。
おまけ
主に使われるコマンド一覧。
visit:
指定のURLへ遷移
cy.visit('http://localhost:8081/')
url:
現在のURLを取得
cy.url().should('include', '/operations/companies/1/users')
get:
DOMの取得
cy.get('.breadcrumbs-menulist > li').eq(0).click()
contains:
指定の文字列が含まれている要素を取得
cy.contains('メンバー設定').click()
eq:
複数要素取れた場合指定インデックスで取得
cy.get('.users-actions button').eq(1).should('be.visible')
find:
小要素から検索
cy.get('.users-conditions .users-field')
.find('input[type=text]')
.should('have.value', '田中')
children:
小要素一覧を取得
cy.get('.users-conditions .users-field')
.eq(fieldIndex)
.find('select.form-select-target')
.should('be.visible')
.children()
.should('have.length', options.length)
click:
クリックイベントを発火
cy.get('.breadcrumbs .breadcrumbs-menulist').eq(0).find('a').click()
select:
セレクトボックスを指定した値で選択
cy.get('.users-conditions .users-field')
.find('select')
.select('3')
each:
小要素一覧を回す
wrap:
取得した要素をラップしてCypressコマンド使えるようにする
cy.get('.users-conditions .users-field')
.find('select.form-select-target')
.children()
.each(($el, index) => {
cy.wrap($el)
.should('have.value', options[index].value)
.and('have.text', options[index].text)
})
アサーション
should:
基本的なアサーションの作成機能である。
文字列として指定できるものはchainersと予備chaiはsinonの関数が使える。
https://docs.cypress.io/api/commands/should.html#Syntax
// テキストフィールドが表示されていること
cy.get('.users-conditions .users-field')
.should('be.visible')
// クラスが指定されていないこと
cy.get('.list-pager .list-pager-next')
.should('not.have.class', 'is-disabled')
// href属性が存在すること
cy.get('.users-conditions .users-link')
.should('have.attr', 'href')
// 指定数のDOMの数がヒットすること
cy.get('.users-conditions .users-item')
.should('have.length', 5)
// 指定した値が選択されていること
cy.get('.users-conditions .users-item')
.should('have.value', '3')
// 指定した文字列が入力されていること
cy.get('.users-conditions .users-item')
.should('have.text', 'user01@mail.com')
and:
shouldと合わせてAND条件を指定可能にする
cy.get('.users-conditions .users-link')
.should('have.attr', 'href')
.and('contain', '/operations/companies/1/magellan_operation_menu')
expect:
ChaiのBDDアサーション
https://docs.cypress.io/guides/references/assertions.html#Chai
// trim後の文字列の比較
expect($btn.text().trim()).eq('検索')
// checkboxが中間(未選択)状態であること
expect($el[0].indeterminate).eq(true)
リクエストの中身
server:
router関数が使用可能にするためにサーバーを起動
route:
ネットワークリクエストの振る舞いを管理
as:
ルートのリクエスト情報のエリアス、DOMでも使用可能
it('Getting users api status should be 200', () => {
cy.server()
// companies/1/users で始まるリクエストを監視
cy.route('GET', '/companies/1/users*').as('getUsers')
cy.get('.users-conditions .users-form-actions button').click()
cy.url().should('include', '?page=1&sort=email&direction=asc')
// getUsersエリアスのxhrオブジェクトからstatusを取得
cy.wait('@getUsers').its('status').should('eq', 200)
cy.visit('/operations/companies/1/users')
})
ApiモジュールのSpy
it('リクエストパラメーターにpage、sort、directionのみが含まれること', () => {
userPage.spyApiModule()
userPage.search()
userPage.getSpiedApiModule().then((module) => {
cy.wrap(module.get).should('be.called')
const firstArgs = module.get.firstCall.args[0]
const parsedQuery = queryString.parse(url.parse(firstArgs).query)
expect(parsedQuery).eqls({
page: '1',
limit: '25',
sort: 'email',
direction: 'asc'
})
})
})
小技
例:windowオブジェクトからaxiosを取得してaxios.getをスパイする
cy
.window()
.its('axios')
.as('axios')
.then((axios) => {
cy.spy(axios, 'get')
})
...
cy.get('@axios').then((axios) => {
expect(axios.get).to.be.calledOnce
})