RACCOON TECH BLOG

株式会社ラクーンホールディングス
技術戦略部より、
tipsやノウハウなど
技術的な話題を発信いたします。

一緒にラクーンのサービスをつくりませんか?採用情報はこちら

e2eのテスティングフレームワークCypressを導入しました

こんにちはURIHO開発をしている今川です。
今回はe2eのテスティングフレームワークであるCypressを導入したので、その導入についてのお話です。

なぜCypressを導入したのか

URIHOのお客様に使用していただいている画面はSPAで構築してあります。そのためSPAに対応したe2eのツールが必要になりました。今まではseleniumを使用していたのですが各個人の環境の設定が手間、SPAで使用するとなるとDOMが出現するまで待つという処理を入れるなど本来の処理とは別のコードが必要になりそうだったので別のフレームワークを探していました。そこでe2eテストがしやすいcypressを導入してみました。またその他のツールに関してはseleniumをラップしたものが多かったりしたので、そういったものは候補から除外しました。

Cypressとは?

e2eのテスティングフレームワークです。今までの開発だとテスト用のフレームワーク(Mocha、Jasmineなど)や テストランナーをどれにするかなど選ばねばならず、またセットアップなどが大変でした。それをcypressはAll In Oneという形で包括しており npm installするだけですぐにテストコードが書けます。たいていAll In One系のツールは動かなくて大変(RailsとかRailsとかRailsとか)なのですがcypressはすべて内包しているのでセットアップが非常に楽です。

インストール

下記コマンドを実行します。npmの環境がない方はnpmを先にインストールしてください。

npm install cypress --save-dev

実行

cypressを実行します。すると下記のようなcypressの画面が開き、実行したディレクトリでcypressディレクトリが作成されます。npxはnode_modulesのコマンドを実行できるコマンドです。直接ファイルを指定しなくて済むので楽です。

npx cypress open

─cypress
├─fixtures(テストデータを入れるディレクトリ)
├─integration(テストコードを入れるディレクトリ)
│ └─examples(cypressが初期テストデータとして用意している)
├─plugins(cypressの挙動を追加するプラグインを入れるディレクトリ)
└─support(cypressにカスタムコマンドを追加するディレクトリ)

つかってみる

基本的に下記のフローに従って使います。

1 対象ページにアクセス

cy.visit

2 色々操作

基本はDOM要素を取得し、取得したDOM要素に対して操作する流れになります。

メソッド名 内容
cy.get DOM要素を取得する
cy.contains DOM要素内のテキストを取得する
cy.get('input').type('URIHO') DOM要素に対して入力する
cy.get('.btn').click() DOM要素を押下する

3 アサーション

アサーションには2種類あり。cypressのコマンドに含まれているアサーション(should)と外部の独立したアサーション(expect)です。

メソッド名 内容
cy.get('.result').should('be.visible') cypressのコマンドに含まれているアサーション
expect("uriho").to.have.length(3) 外部の独立したアサーション

4 ソースコード例

会社名、代表者名、都道府県、取引状況を入力し登録するという簡単な入力フォームでテストします。テストコードでは登録ボタン押下後に各入力フォームがクリアになり、メッセージに「登録ありがとうございました」が表示されることを確認します。テスト対象のhtmlとJavascriptはvueとbootstrapで簡単に作ってあります。

■ html

<!DOCTYPE html>
<html>
 <head>
  <meta charset="utf-8">
  <title>Cypress Test</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-BmbxuPwQa2lc/FVzBcNJ7UAyJxM6wuqIj61tLrc4wSX0szH/Ev+nYRRuWlolflfl" crossorigin="anonymous">
 <body>
  <div id="app" class="container">
    <h1>Cypress Test</h1>
    <div class="alert alert-primary" role="alert" v-if="message" id="message">
      {{message}}
    </div>
    <div class="mb-3 row">
      <label for="name" class="col-sm-2 col-form-label">会社名</label>
      <div class="col-sm-10">
        <input type="text" class="form-control" id="name" placeholder="ラクーンホールディングス">
      </div>
    </div>
    <div class="mb-3 row">
      <label for="representative_name" class="col-sm-2 col-form-label">代表者名</label>
      <div class="col-sm-10">
        <input type="text" class="form-control" id="representative_name" placeholder="小方功" v-model="representative_name">
      </div>
    </div>
    <fieldset class="row mb-3">
      <legend class="col-form-label col-sm-2 pt-0">都道府県</legend>
      <div class="col-sm-10">
        <input class="form-control" list="prefectures" id="prefecture" placeholder="東京都" v-model="prefecture">
        <datalist id="prefectures">
          <option value="北海道">
          <option value="青森県">
          <option value="岩手県">
          <option value="宮城県">
          <option value="秋田県">
          <option value="山形県">
          <option value="福島県">
          <option value="茨城県">
          <option value="栃木県">
          <option value="群馬県">
          <option value="埼玉県">
          <option value="千葉県">
          <option value="東京都">
          <option value="神奈川県">
          <option value="新潟県">
          <option value="富山県">
          <option value="石川県">
          <option value="福井県">
          <option value="山梨県">
          <option value="長野県">
          <option value="岐阜県">
          <option value="静岡県">
          <option value="愛知県">
          <option value="三重県">
          <option value="滋賀県">
          <option value="京都府">
          <option value="大阪府">
          <option value="兵庫県">
          <option value="奈良県">
          <option value="和歌山県">
          <option value="鳥取県">
          <option value="島根県">
          <option value="岡山県">
          <option value="広島県">
          <option value="山口県">
          <option value="徳島県">
          <option value="香川県">
          <option value="愛媛県">
          <option value="高知県">
          <option value="福岡県">
          <option value="佐賀県">
          <option value="長崎県">
          <option value="熊本県">
          <option value="大分県">
          <option value="宮崎県">
          <option value="鹿児島県">
          <option value="沖縄県">
        </datalist>
      </div>
    </fieldset>
    <fieldset class="row mb-3">
      <legend class="col-form-label col-sm-2 pt-0">取引状況</legend>
      <div class="col-sm-10">
        <div class="form-check">
          <input class="form-check-input" type="radio" name="new_deal" id="new_deal" value="0" checked v-model="deal_type">
          <label class="form-check-label" for="new_deal">
            新規取引先
          </label>
        </div>
        <div class="form-check">
          <input class="form-check-input" type="radio" name="existing_deal" id="existing_deal" value="1" v-model="deal_type">
          <label class="form-check-label" for="existing_deal">
            既存取引先
          </label>
        </div>
      </div>
    </fieldset>
    <div class="mb-3 row">
      <div class="col-sm-10">
        <button type="submit" class="btn btn-primary" id="regist_button" v-on:click="regist()">登録</button>
      </div>
    </div>
  </div>
 </body>
 <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
 <script src="./cypress_test.js"></script>
 <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/js/bootstrap.bundle.min.js" integrity="sha384-b5kHyXgcpbZJO/tY9Ul7kGkf1S0CWuKcCD38l8YkeH8z8QjE0GmW1gYU5S9FOnJ0" crossorigin="anonymous"></script> </head>
 </html>

■ js

var app = new Vue({
  el: '#app',
  data: {
    name: '',
    representative_name: '',
    prefecture: '',
    deal_type: '',
    message: ''
  },
  methods: {
    regist: function () {
      this.name = ""
      this.representative_name = ""
      this.prefecture = ""
      this.deal_type = ""
      this.message = "登録ありがとうございました"
    }
  }
})

■ テストコード

htmlファイルとjsファイルを適当なWebサーバーに置いて下記コードをcypress/integrationディレクトリに置いてテストしてください。cy.visitの引数のURLは適宜各々の環境に置き換えてください。

describe('Cypress Test', () => {

  beforeEach(() => {
    cy.visit("http://localhost:3000/cypress_test.html")
  })

  context("登録テスト", () => {
    it("登録が成功し、入力フォームが初期化されていること", () => {
      cy.get("#name").type("株式会社ラクーンフィナンシャル")
      cy.get("#representative_name").type("秋山祐二")
      cy.get("#prefecture").type("東京都")
      cy.get("#existing_deal").check("1")
      cy.get("#regist_button").click()
      cy.get("#message").should('contain', "登録ありがとうございました")
      cy.get("#name").should('contain', "")
      cy.get("#representative_name").should('contain', "")
      cy.get("#existing_deal").should('contain', "")
    })
  })
})

■ 結果

独自コマンドをつくる

cypress/support/commands.jsに下記のAPIに従って独自コマンドを追加していきます。独自コマンドはよく使うログイン・ログアウト処理、セッションストレージの削除などを一つにまとめるのに使用します。

Cypress.Commands.add(name, callbackFn)

cypress/support/commands.jsに下記のコードを入力し、cypress/integration/test.jsにcommands.jsに定義したloginコマンドを呼び出し、cypressを実行します。

■ cypress/support/commands.js

Cypress.Commands.add("login", (ID="hoge", PASSWORD="hogehoge") => {
  console.log(ID)
  console.log(PASSWORD)
})

■ cypress/integration/test.js

describe('カスタムコマンドテスト', () => {
  context("ログインテスト", () => {
    it("loginコマンドを呼んでログイン処理がされること", () => {
      cy.login("cypress", "cypress_password")
    })
  })
})

■ 実行結果

下記のようにcommands.jsに定義したloginコマンドが呼ばれコンソールにcypress, cypress_passwordが出力されます。

cypress
cypress_password

環境ごとに使い分ける

開発環境と本番環境でアクセス先等を分けたいといったときに環境変数で分けることが多いと思います。cypressの場合はcypress.json、cypress.env.jsonで定義することができますが、ymlのようにdevelopment/urlといったネストした形には対応していません。そのため--envを使うことで環境ごとに使い分けることができるので個人的にはこちらがお勧めです。

# 実行時
npx cypress open --env HOST=*****
# 引数に渡した環境変数を取得する
Cypress.env('HOST')

cypressをヘッドレスで使う

cypressは開発時は内蔵ブラウザが立ち上がりテストが実行しやすいですが、実際に実行するのはJenkinsやCircleCIなどで定期実行すると思います。そのためヘッドレスで使うためのコマンドが必要です。やり方は簡単でcypress runコマンドを実行することでヘッドレスで実行できます。全部実行する場合はそのままcypress run、一部テストを実行したい場合はcypress run -s cypress/integration/test_spec.jsのようにファイルを指定します。

# 全テストを実行
npx cypress run --env HOST=****
# 指定したテストを実行
npx cypress run -s cypress/integration/test_spec.js

実行結果の録画

cypressはGUIで実行(cypress open)した場合、実行結果は録画されませんがヘッドレスで実行(cypress run)した場合は録画が開始されcypress/videosディレクトリにmp4形式で出力されます。録画が不要な場合はcypress/cypress.jsonに下記の設定をすれば出力されなくなります。

{
"video": false
}

実際に導入・運用した結果

URIHOの以前のe2eテストコードの書き方はスモークテストのようにログイン~登録~結果確認を一連の流れを行い問題なく動作するかを確認するものでした。このやり方だと画面に修正が入り、テストコードを修正する際にすべての流れを実行しなければ確認できず非常に煩雑でした。そこで機能単位でテストコードを書くように変更しました。機能単位だと機能から機能への遷移の確認はしないのか?という疑問もあると思いますが、それは機能ごとに遷移するボタンやリンクをクリックした結果、期待したURLなのかどうかをテストコードに追加することで対応しています。具体的には下記のようにしました。コード自体は適当なので雰囲気だけ伝わればいいかなと思います。

■ 以前の書き方

describe('Ce2eテスト Test', () => {
  it("一連の流れが確認できること", () => {
    // ログイン
    cy.visit("http://localhost:3000/top")
    cy.get("#email").type("test@uriho.jp")
    cy.get("#password").type("hogehoge")
    cy.get("#login_button").click()
    // 入力
    cy.get("#member_link").click()
    cy.get("#name").type("株式会社ラクーンフィナンシャル")
    cy.get("#representative").type("秋山祐二")
    cy.get("#confirm_button").click()
    // 以下確認したい画面
    // ****
  })
})

■ cypress導入後

ログイン処理は「独自コマンドをつくる」で紹介した通り共通コマンドとして実装しています。確認することは機能単位で確認するようにテストコードを書きます。下記の例でいえば会員登録~完了までをひとつの機能として扱いテストコードを実装していきます。テスト対象ページからの重要な同線を確認したい場合のテストは

describe('会員登録入力~完了まで', () => {

  inputURL = "http://localhost:3000/member"

  beforeEach(() => {
    cy.login()
    cy.visit(inputURL)
  })

  context("登録テスト成功", () => {
    it("登録が成功していること", () => {
      // 入力
      cy.get("#name").type("株式会社ラクーンフィナンシャル")
      cy.get("#representative").type("秋山祐二")
      cy.get("#confirm_button").click()
      // 確認
      cy.get("#name").should("have.value", "株式会社ラクーンフィナンシャル")
      cy.get("#representative").should("have.value", "秋山祐二")
    })

    it("問い合わせ画面へ遷移すること", () => {
      // 問い合わせ画面への遷移の確認
      cy.get("#inquiry_link").click()
      cy.url().should('include', "/inquiry")
    })
  })
})

プログラムが適切に動くことを確認するローレベルなテストコードであれば、そこまで不安定なテストになることはないのですがe2eという特性上アプリケーションが適切に動いていることを確認するのが目的なのでより上位の階層でテストコードを実行します。するとネットワーク、アクセス先のサーバの状態、DBとの通信などでエラーが発生しテストが不安定になることがあります。なのでリトライ処理を設定し2回連続でエラーになった場合はSlackに通知するようにしています。リトライ処理や例外処理は必要最低限で実装しています。

URIHOのCIツールはJenkinsを使っています。今後Jenkinsを使わなくなる可能性もあるのでDockerfileで実行環境を用意してジョブを実行するように運用しています。

Jenkinsでジョブを実行しておりエラーが発生したらSlackにe2eテストが失敗したことを投稿するようにしています。はじめはcypressの機能でvideoを添付しようかと考えていたのですが、リトライ処理で書いたとおり2連続で失敗したら調査する必要があり、エラーが発生したらjenkinsのログを確認するかローカル環境で実行して確認するので意味がないと考えメッセージだけの投稿にしました。

開発環境でいえばnpm installするだけなのでストレスフリーで導入できます。CI環境で実行する場合は対象のCI環境にあった導入が必要ですがDockerfileで実行環境を作っておけばポータブルに動かせるので導入コストは高くないです。

e2eテストに学習コストをかけ過ぎたくなかったのでWikiにcypressの導入~開発方針~リリースまで掲載しチームで保守できるようにしています。学生インターンの方にテストコードを修正していただきましたが1人日もかからず完了しリリースまでできたので学習コストは低めに抑えられています。

基本的に主要な機能が正常に動いているか確認するのが目的なので、e2e用に専用のアカウントを用意して確認できるようにしています。

まとめ

いかがだったでしょうか。冒頭でも書きましたが、All In Oneの何でもできる系ツールは設定だけで嫌になることが多いと思いますがcypressはnpm installするだけですぐにテストが実行できて便利だったと思います。またテストのやり方もページの取得、DOM要素の操作、アサーションのコードも直感的に理解でき学習コストも低めだと思いますので比較的導入しやすいかと思います。

一緒にラクーンのサービスを作りませんか? 採用情報を詳しく見る

関連記事

運営会社:株式会社ラクーンホールディングス(c)2000 RACCOON HOLDINGS, Inc