Ruby on RailsとCypress

f:id:ashunbshun:20210331175837p:plain

https://www.cypress.io

目次

  1. はじめに
  2. Cypress
  3. cypress-on-rails
  4. おわりに

1. はじめに

はじめまして、株式会社スタメンでエンジニアをしています伊藤です。普段はRuby on Railsを使っているサーバー側の人間なのですが、重要な機能を守るためにE2Eテストを書くことになりました。Railsで単体テストを書く際はFactoryBotでテストデータを作り、RSpecで単体テストを行うというお決まりパターンでコードを書いていましたが、今回は Cypresscypress-on-rails 使いE2Eテストを書いてみたのでその内容について紹介できればと思います。

Cypressとはですが、簡単に言ってしまえばE2Eテストを行うことができるOSSです。Cypressは導入がとても楽なので、触り始めたばかりの頃は「なんて便利なものなんだ!」と、なるのですが、ネイティブのJavaScriptにはない独特の仕様が多く一筋縄ではいきません。代表的なものだと、Promiseやasync/awaitは基本的には使えないです。非同期処理をどうするかはCypressを触る上でとても重要なポイントです。公式にドキュメントがしっかりとまとめられており、また多くのエンジニアがissueを立てているので、そのあたりをちゃんと読めば大体の問題は解決できると思います。ただし英語です。

RailsエンジニアがCypressを触るのであればcypress-on-railsの利用を考えてみても良いと思います。cypress-on-railsの利点として、FactoryBot経由でテストデータを作成できることがあげられます。これまでtraitなどで積み上げてきたテストケースの財産を再利用できるので、これまで頑張って単体テストを書いてきた人ほどハッピーになれます。ただし、実際に触ってみると非同期処理や変数の扱いが分かっていないと分からない難しさがあるので、Cypressの仕様に触れてからcypress-on-railsの話に入っていきたいと思います。

2. Cypress

Cypressではテストランナーとダッシュボードが提供されており、テストランナーはGitHubでソースコードが公開されているOSSで、ダッシュボードでは一部機能を無料で利用することができます。SeleniumなどのようにWebDriverを入れたりする必要がないので、環境構築に対するコストが小さいことも魅力的です。Dockerを利用する場合、テスト用のサーバーの実行とCypressが実行できるコンテナが用意できればいいため、CircleCIとの連携も比較的簡単です。

Cypressでは公式HPで多くのBest Practiceが示されており、基本的にはそれに則るコーディングが推奨されています。Cypress自体はnode環境下で実行されるJavaScriptになりますが、実行のされ方が特殊です。書くコードがそのままコード通り同期的に評価されるのではなく、キューに蓄えられてから非同期的に実行されます。どういうことかというと、Cypressが用意しているAPIとネイティブのJavaScriptの書き方を組み合わせると意図しないタイミングで評価されてしまい、思い通りの処理が実現できないということになります。つまり、Cypressのコードを書く際はCypressのガイドで示されている書き方に従いコーディングを行うことになります。その中でいくつか特徴的な仕様について紹介します。

Cypressの仕様で複雑なものとして非同期処理に関する部分があげられます。最近のモダンなJavaScriptの書き方に慣れている人からすれば、非同期といえばasync/await、少なくともPromiseの使用をイメージすると思います。しかし、公式で述べられているようにCypressではES7のasync/awaitはサポートしていません。Promiseは存在しますが、ネイティブのPromiseとは異なりCypress.Promiseで生成されたオブジェクトのみ挙動を保証しています。(内部モジュールとしてはBluebirdを使っているようです。)

Why can’t I use async / await?

If you’re a modern JS programmer you might hear “asynchronous” and think: why can’t I just use async/await instead of learning some proprietary API? Cypress’s APIs are built very differently from what you’re likely used to: but these design patterns are incredibly intentional. We’ll go into more detail later in this guide.

公式の設計デザインとしてasync/awaitは使用しないとされており、ガイドに則った非同期処理のコーディングが求められます。例えば、Cypress公式の見解として非同期処理を行う場合はthen()intercept()といったCommandと呼ばれるAPIの利用や、Chains of Commands に従ったコーディング、Custom Command の利用を推奨しています。

Test Structure

Cypressは MochaChai をベースにしています。そのため、MochaやChaiのTDD/BDDの記法にしたがってコードを書くことになります。テストコード全体の構成としてはMochaをベースにしています。そのため、describe()context()it()specify()などRailsエンジニアであれば馴染みのあるBDDスタイルでコーディングすることが基本となります。Webページ上のDOMを参照する際はjQueryのエンジンを利用しています。そのためセレクターの書き方は古き良きjQueryの書き方に従うことになります。(https://docs.cypress.io/guides/core-concepts/introduction-to-cypress#Cypress-Can-Be-Simple-Sometimes)

describe('Post Resource', () => {
  it('Creating a New Post', () => {
    cy.visit('/posts/new')     // 1.

    cy.get('input.post-title') // 2.
      .type('My First Post')   // 3.

    cy.get('input.post-body')  // 4.
      .type('Hello, world!')   // 5.

    cy.contains('Submit')      // 6.
      .click()                 // 7.

    cy.url()                   // 8.
      .should('include', '/posts/my-first-post')

    cy.get('h1')               // 9.
      .should('contain', 'My First Post')
  })
})

Cypressは実行の前後のhooksについてもMochaにおけるhooksの仕様が受け継がれています。(https://docs.cypress.io/guides/core-concepts/writing-and-organizing-tests#Hooks)

beforeEach(() => {
  // root-level hook
  // runs before every test
})

describe('Hooks', () => {
  before(() => {
    // runs once before all tests in the block
  })

  beforeEach(() => {
    // runs before each test in the block
  })

  afterEach(() => {
    // runs after each test in the block
  })

  after(() => {
    // runs once after all tests in the block
  })
})

beforeでテストデータの準備などを行い、beforeEachでログインやCookie周りなどテスト毎のステート管理を行うことが基本となります。素直に考えればafterafterEachbeforebeforeEachのステートを綺麗にする処理を書きたくなると思いますが、それはアンチパターンのようなので、正直使い所が難しいです。

Chains of Commands

Chains of Commandsとは、基本的にはJavaScriptのメソッドチェーンになります。Cypressでは実行が非同期的に行われます。そのためネイティブのJavaScriptではなくCypressで用意しているAPIを使い実行順序を制御します。 各コマンドはキューに一度蓄えるため、ネストが同じ高さのコマンドは同期的に実行されることになります。しかし、実行結果の内容を受けて処理を変化させたい場合や、DOM要素の属性値を参照したい場合などは各コマンドの実行結果を受け取りたいはずです。実行結果を他のコマンドに確実に渡す方法としてコマンドのチェーンがあります。

Cypressのコマンドは必ず返り値が存在します。前回実行したコマンドの結果のことをCypressではsubjectと呼びます。subjectはDOM要素や数値、文字列、オブジェクトなど様々な型になりますが、この設計はChaiおよびChai-jQueryから組み込まれているそうです。コマンドをチェーンしていくことでこのsubjectが次のコマンドへと渡されていくため、アサーションを実行することができます。チェーンの途中でget()などを挟むことでsubjectを変えることもできるため、全てのコマンドをチェーンさせたコードを書くこともできます。ただし、コマンドによってはsubjectとして何も渡さないものも存在します。コードの可読性を考えて、一連のまとまったテスト内容はチェーンさせて、テストしたい内容が変わるタイミングでわざとチェーンを一度外すといった書き方もできます。

Commands

Cypressの実行が非同期的です。そのためネイティブのJavaScriptではなくCypressで用意しているAPIを使い実行順序を制御します。その中で非同期処理に関連するget()then()as()wait()intercept()の4つを紹介します。これらのコマンドは実際に使う中で何度も悩まされたコマンドです。

get

get()はおそらくCypressでコードを書く中で一番使用頻度が高いコマンドです。使用方法としては大きくわけて2つあり、DOM要素の取得とエイリアスの参照です。DOM要素を参照する方法はjQueryのセレクターの記法に準拠します。また、DOM要素が実行したタイミングですぐに見つからなかった場合でも、自動的にリトライ処理が走り、遅延してレンダリングされた場合でも取得することが可能です。get()での検索自体がアサーションとして働くため。時間をおいても対象が見つからずタイムアウトした場合は、テスト項目の失敗となります。そのため、DOM要素の存在有無を繰り返し判定するような処理を行いたい場合には使えません。エイリアスについてはas()の説明で述べます。

cy.get('button') // button要素
cy.get('div.test') // div要素でクラス名がtest
cy.get('[data-cy=hoge]') // data-cy属性値がhoge

then

then()は直前のコマンドの結果をうけてコールバック関数を実行するコマンドです。then()を使用するタイミングとしては、大きく分けて2つの状況があげられます。1つ目は変数の扱う状況です。公式のガイドラインによると、Cypessコマンドの実行結果を変数に格納する方法はアンチパターンとされています。理由としては、呼び出すタイミングで対象となるオブジェクトが存在している保証がないからです。then()を使って呼び出すことで、コールバックのスコープ内であれば確実にオブジェクトを参照することができます。

cy.wrap('hoge').then(text => {
    const result = text + 'fuga'
    cy.wrap(result).should('eq', 'hogefuga')
})

2つ目はDOM要素を参照する場合です。Cypressの場合DOM要素を参照する場合get()を使います。参照したDOM要素を検証する場合には続けてshould()などを使いますが、変数化して扱いたい場合はthen()を使わなければなりません。get()の返り値としてjQueryセレクターを返しますが、そのまま変数化しても変数化の処理の部分だけが同期的な処理になってしまうので、思い通りの挙動をしない恐れがあります。

const $elem = cy.get('button') // ここは同期的
$btn.click() // ここは非同期的、いつ実行されるか分からない

DOM要素を確実に変数化したい場合はthen()を使い、引数から参照するようにします。

cy.get('button').then($btn =>{
    // $btnはjQueryオブジェクト
    const text = $btn.text()
    // Cypressのコマンドを実行できる形にするには一度wrapで変換する必要がある
    cy.wrap($btn).click()
})

as

thenを使用せずにオブジェクトを別のコマンドに渡す方法として、asコマンドの使用があげられます。then()は非同期的な実行が行われるCypressの中で変数などを扱う際に欠かせないものですが、使用するたびにネストが下がるため、jQueryのコールバック地獄の時のような深いネストが生まれてしまうことがあります。そこで、asコマンドを使用することで、ネストを回避することができます。as()はsubjectに対してエイリアスをはるコマンドです。ここでいうエイリアスとは、直前に実行したコマンドのsubjectを参照するためのkeyとなる文字列のことで、チェーンしていなくてもget()から値を参照することができます。get()は対象が見つかるまで処理が繰り返されるので、評価が終わるまでは次のコマンドに移ることはありません。なので、直前に非同期的な処理の結果をas()で持つようにし、get()を使い処理が完了するのを確実に待つことができます。

cy.get('button').as('btn')
cy.get('@btn').click()

then()のネストを避けて変数化の代わりを行う方法として有用ですが、落とし穴があります。as()ではられたエイリアスはget()で参照されるとライフサイクルを終えてしまうので、再度参照することができなくなります。なので、繰り返し参照する可能性がある結果に対しては不向きです。

wait

非同期的な処理を待つ方法としてas()get()を使用するパターンを述べましたが、通信処理を待つ場合にはget()ではなくwait()を使う方が良いです。get()はそれ自体にアサーションを含むためタイムアウトしてしまった場合はテストの失敗となってしまいます。しかし、通信結果がなかなか返ってこず、タイムアウトした後に再度処理を繰り返したい場合はwait()が適しています。wait()の使い方は、引数に与えられた一定時間を待つという使い方と、エイリアスを待つという使い方の2つがあります。前者の使い方はsetTimeoutなどと同じように馴染みのある使い方ですが、テストの不安定さに繋がるためCypressではアンチパターンとされています。一定時間ではなく結果を待つ方法としてエイリアスを使う方法があり、こちらの使用が推奨されます。

intercept

非同期処理の定番として通信処理があげられます。例えば、APIを投げる処理が走った場合に、レスポンスが返ってくるまで処理を待ちたい状況が考えられます。intercept()はクライアント側から送られるリクエストを監視できるコマンドです。以前はroute()というコマンドが使われることが多かったようですが、Fetch APIへの対応など様々なネットワーク層の仕様に対応したコマンドになっています。intercept()は単体で使うことはなく、基本的にはas()wait()とセットで使います。

cy.intercept('/results').as('@results') // リクエストの内容を記述、asでエイリアスを作る
cy.get('button').click() // リクエストが飛ぶ処理
cy,wait('@results') // エイリアスの内容が得られるまで待つ
cy,get('li').should('have.length'. 10) // 結果をアサーション

Custom Command

Cypressの標準で実装されているコマンドについていくつか紹介しましたが、ユーザーがコマンドの組み合わせで独自で定義するCustom Commandsと呼ばれるものがあります。Custom Commandのベストプラクティスで内容について書かれていますが、ログイン処理や通信処理などよく使われ関数化したい処理をCustom Commandsにするのが良いとされています。JavaScriptのコードなのでもちろんネイティブの関数定義で複数のコマンドをまとめることもできますが、Custom Commandで定義された処理はチェーンすることで非同期的な処理内容でも確実に制御することができるので、なるべく関数ではなくCustom Commandで定義する方が良いです。特にPromiseが必要になるような処理を書きたい場合はCustom CommandsでCypress.Promissを返す必要があります。( https://qiita.com/murata0705/items/100ef8300caeeaa7d409 )

Cypress.Commands.add('hoge', () => {
  // cyコマンドの処理
})

Sharing Context

Cypressで変数を扱う方法としてthen()as()を紹介しましたが、複数のものを何度も参照したいケースでは使い辛いです。そこで、一部のケースにおいてこの問題を解決する方法としてsharing contextというものがあります。Mochaの仕様として、beforeなどのhookではられたエイリアスはthis.*で参照することができます。これを用いることで、beforeで行った処理結果をitで参照することができます。また、複数のデータを渡す場合でもネストを下げることなく繰り返し参照することができます。ただし、beforeからthisのスコープが渡されることが必要となるため、arrow式でitにコールバックを渡した場合には利用することができません。shared contextを利用する場合は必ずfunction式で渡します。渡したいデータが少ない場合はthen()get()で参照し、渡すデータが多い場合はshared contextを利用するなどの使い分けができると思います。shared contextを利用する場合、function式とarrow式が同じファイル内で混在しがちになりますが、shared contextを使う場合だけコールバックをfunction式で書くと言ったルールにすれば、書き方から意図を伝えることができます。

before(() => {
  cy.fixture('users.json').as('users') // jsonファイルの読み込み結果に対してエイリアスをはる
})

// shared contextを使う場合はコールバックをfunction式にする
it('utilize users in some way', function () {
  const user = this.users[0]
  
  cy.get('header').should('contain', user.name)
})

3. cypress-on-rails

CypressはWebブラウザでの挙動を自動的にテストしてくれるツールです。そのため、サーバーサイドで準備するテストデータはCypress外部で用意をしておく必要があります。そこで今回はRails環境下でCypressを使用する際に便利なcypress-on-railsというgemについて紹介します。

cypress-on-railsの最大の特徴は、CypressからのRubyファイルを実行できる点にあります。FactoryBotによるテストデータの作成やtest fixturesの利用が可能です。これによりこれまで培ってきた既存のテストデータの作成が再利用できます。FactoryBotを使う場合であればtraitやtransientを使い、簡潔にコードを記述することも可能です。

インストール

gemをインストールするためにGemfileに次の記述を追加します。

group :test, :development do
  gem 'cypress-on-rails', '~> 1.0'
end

gemのインストールの次は、cypress-on-rails用のボイラープレートが用意されているのでそれも合わせて実行します。

bin/rails g cypress_on_rails:install

実行すると以下のようなディレクトリとファイルが生成されます。

  • config/environments/test.rb
  • config/initializers/cypress_on_rails
  • spec/cypress/integrations/ Cypressのテストファイルを格納する
  • spec/cypress/support/on-rails.js cypress-on-railsに必要なCustom Commandsの定義
  • spec/cypress/app_commands/scenarios/ テストデータなどを作成するシナリオファイルを格納する
  • spec/cypress/cypress_helper.rb コマンドが実行される前に評価されるファイル

自動的に追加されるものではないですが、FactoryBotの利用やデータベースのクリーンアップ、静的なテストデータの読み込み、Cypress外でのNodeプロセスの実行などを行う際には、加えて以下のディレクトリやファイルが必要になります。

  • spec/cypress/fixtures/ Cypress内で読み込むテストデータを格納する
  • spec/cypress/plugins/ Cypress外のNode.jsのイベントを登録する
  • spec/cypress/app_commands/clean.rb データベースのクリーンアップ
  • spec/cypress/app_commands/factory_bot.rb FactoryBotの設定

cypress-on-railsではCypressのコマンドがフックでRubyファイルが実行されます。仕組みとしては、Cypressのcy.requestコマンドを用いてサーバーへリクエストを送り、送られてきたリクエストの内容に従い実行するRubyファイルを見つけ実行し、実行結果をレスポンスとして返すことでファイルの実行と実行結果の取得を行います。app_commandsで定義したRubyファイルはKernel.evalで評価されます。そのため、DRYなコードを実現するためにはFactoryBotのtraitを最大限に使用するなどの工夫が必要になります。

使い方

FactoryBotを使用したデータの作成は以下のような形で記述することができます。

bot = CypressOnRails::SmartFactoryWrapper
params = command_options.symbolize_keys

user = bot.create(:user, name: params[:name], password: 'password')
article = bot.create(:article, :only_text, user: user, title: '素敵なタイトル', value: params[:value])
return {
    id: user.id,
    password: user.id,
}

Cypressのテストコードで実際に使用する場合は次のようにcy.appもしくはcy.appScenarioで実行することができます。

const data = {
    name: 'テスト太郎',
    value: '素敵な文章',
}
// cy.appを使ったパターン
cy.app('scenarios/create_data', data).then(res => {
    cy.login(res)
})
// cy.appScenarioを使ったパターン
cy.appScenario('create_data', data).then(res => {
    cy.login(res)
})

cypress-on-railsではrubyファイルの評価結果をsubjectとして渡すことができるので、shared contextと組み合わせればサーバー側から複数の情報を簡単に参照することができます。また、DBに対する操作も間接的に可能であるため、テスト用に追加でサーバー側にAPIを定義せずに様々なテストケースを再現することができます。追加の設定でDBのクリーンアップも行えるので、テストごとに独立したテストデータを用意することもできます。

cypress-on-rails はテストデータを用意する際にとても便利なのですが、ruby側の処理でエラーが合った場合、Cypress側ではエラーコードが500のレスポンスでタイムアウトしたことしか分かりません。実際のエラー内容を確認するには実行ログを見るしかありません。さらに、FactoryBotのtraitの定義に問題が合った場合はログにも現れないことがあります。デバックの面では使いにくさが残ります。

4. おわりに

Rails環境下におけるE2EテストとしてCypressとcypress-on-railsを用いた方法について紹介しました。Cypressは導入が簡単でCIとのシナジーも高い部分がメリットとしてあげられますが、独特な仕様や非同期処理の扱いづらさがデメリットとしてあげられます。cypress-on-railsを使うことでCypressでもFactoryBotなどのRubyの財産を使ったテストデータの作成ができ、素早く様々なシナリオでのE2Eテストを作ることができます。重要な機能はこうしたE2Eテストなどでこれからもしっかりと守っていきます。

スタメンでは一緒に働くエンジニアを募集しています。興味がある方は、ぜひ採用サイトからご連絡ください!