Help us understand the problem. What is going on with this article?

Cypressを使ったインテグレーションテストの導入

More than 1 year has passed since last update.

Cypressの導入

おはようございます、モチベーションクラウドの開発に参画している@sinpaoutです。

TL; DR

フロントエンドの機能動作を担保するためCypressを導入しインテグレーションテスト機構を構築した。
Cypressの導入、使い方、工夫、運用方法について記述する。

Cypress

経緯

新機能を開発していると、ある日「手を止めて負債を解消しようぜ」という天命が下って希望の日差しが見えた。

みんなで負債リストを書き出し、優先順位づけしてリファクタリングを行う準備をした。
そこでリファクタリングの動作確認に自動テストの導入が必須であることに気づきCypressを使ったインテグレーションを導入することに至った。

インテグレーションテストとは

ユニットテストを増やすと勝手に品質が上がるという話をよく聞くが
書くことが多く短時間で品質を担保するためには不向き。
しかし、E2E全機能を全ブラウザーでテストもできないので
Chromeのみで基本動線を担保するレベルのものにすることになった。

Integration test

環境

Rails、Vue+Vuex、SPA。
一部coffee+erbも残っているが今回はSPAだけを対象にする。

Cypress

先ず動かして感覚をつかめる

https://docs.cypress.io/guides/getting-started/writing-your-first-test.html#Add-a-test-file

実際のテスト

シナリオ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段階に分けて行う。
1. 先ずテストケースだけ(describeとit)のみを記述しレビューを通す
2. それからテストの中身(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
Test runner のUI上でselectorやデータ型の相違などの問題が解決するので
になれることをおすすめ。

https://docs.cypress.io/guides/core-concepts/test-runner.html#Overview

おまけ

主に使われるコマンド一覧。

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をスパイする

Spyの機能一覧

cy
  .window()
  .its('axios')
  .as('axios')
  .then((axios) => {
    cy.spy(axios, 'get')
  })

...

cy.get('@axios').then((axios) => {
  expect(axios.get).to.be.calledOnce
})
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした