Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Gunosyの広告管理画面を支えるE2Eテスト

広告技術部のサンドバーグと星です。 普段の業務は、主に広告の管理システムの開発をしています。管理画面はRuby on Railsで作られており、今回は煩雑になりがちなE2Eのテストをきれいに書けたので、それについて話します。

背景

Gunosyの広告システムは4年以上前にリリースされ、これまで多くの機能が追加されてきました。 配信システムは一度リプレースされましたが、私達が運用している管理画面に関してはリプレース などはされず、現在も拡張され続けています。長く運用されているシステムのため 開発するメンバーの入れ替わりもあり、もちろん思想やコードスタイルも変わってきたため、 バグが発生しやすい環境になってしまっています。

ただ、外部のお客様も使う機能も含まれるため、バグが無いことを担保する必要があり、 テストがより重要になってきます。

また、複雑なデータ構造と画面操作があるため、単体テストでバグが無いことを担保するのは難しい。 そこで、E2Eのテストを充実させ、期待する入力と出力が正しいことを保証しています。

E2EのテストはRspecとCapybaraを使っています。

Gunosyの複雑なテスト

context 'when logged in as operator', js: true do
    it 'editable approval_status, provisional_approval_status, refusal_reason & comment' do
      expect(page.all('tbody#records > tr > td > a.editable')[0].text).to eq 'waiting_for_approval'
      page.all('tbody#records > tr > td > a.editable')[0].trigger('click')
      expect(page.find('div.editable-input select').find(:xpath, 'option[1]').text).to eq 'waiting_for_approval'
      expect(page.find('div.editable-input select').find(:xpath, 'option[2]').text).to eq 'accepted'
      expect(page.find('div.editable-input select').find(:xpath, 'option[3]').text).to eq 'refused'
      page.find('div.editable-input select').select 'accepted'
      page.find('div.editable-buttons button.editable-submit').click

      expect(page.all('tbody#records > tr > td > a.editable')[1].text).to eq '未対応'
      page.all('tbody#records > tr > td > a.editable')[1].trigger('click')
      expect(page.find('div.editable-input select').find(:xpath, 'option[1]').text).to eq 'OK'
      expect(page.find('div.editable-input select').find(:xpath, 'option[2]').text).to eq '不明'
      expect(page.find('div.editable-input select').find(:xpath, 'option[3]').text).to eq 'NG'
      expect(page.find('div.editable-input select').find(:xpath, 'option[4]').text).to eq '未対応'
      page.find('div.editable-input select').select 'OK'
      page.find('div.editable-buttons button.editable-submit').click

      page.all('tbody#records > tr > td > a.editable')[3].trigger('click')
      page.find('div.editable-input textarea').set 'テストコメント'
      page.find('div.editable-buttons button.editable-submit').click

      expect(page.all('a.creative_editable').first.text).to eq '承認済み'
      expect(page.all('a.creative_editable')[1].text).to eq 'OK'
      expect(page.all('a.creative_editable')[2].text).to eq 'その他'
      expect(page.all('a.creative_editable')[3].text).to eq 'テストコメント'
    end
  end

上記のようにE2Eのテストをしようとすると、

  1. Elementを検索・操作する際に全体ページから一意の要素を逐一検索する必要があります
  2. ElementはHTMLの階層構造で表現されているため、何なのかがわかりづらいです
  3. 同じ要素を繰り返して使う際に独自クラスなどを定義しない限り、都度ページ要素を検索する必要があります
  4. DOMに変更があった場合、各Elementの検索を変更する必要が出て来るかもしれません

また、要素アクセスのためのコードは冗長で見通しが非常に悪いです。

こういったE2Eテストの問題を新規プロジェクト開始のタイミングできれいに保てるような仕組みを 導入してみました。

SitePrism *1

SitePrismとは...

A Page Object Model DSL for Capybara

Capybaraのテストで利用するページを Page Object として利用することができます。

Page Object とは、一つのHTMLページを一つのオブジェクトとしてとらえるデザインパターンです。

以下は README.me から参照した、クラスの定義とその使用例です。

class Home < SitePrism::Page
  set_url "http://www.google.com"

  element :search_field, "input[name='q']"
end

@home = Home.new
@home.load

@home.search_field #=> クラス内で定義されたセレクタを使ってCapybaraの要素を取得できます
@home.search_field.set 'hoge' #=> セレクタはCapybaraのElementを返すため、Capybaraのメソッドを利用できます
@home.search_field.text #=> 'hoge'

Gunosyでの使用例

HTMLの構造が複雑であるため、 SitePrismのセクション機能を使って、ひとかたまりの要素を抽象化しました。

ページクラス

class Index < SitePrism::Page
  class SearchForm < SitePrism::Section
    element :search_button, 'td:nth-child(2) button'
    element :user_name, '.search-detail input#user_name'
  end

  class TableRow < SitePrism::Section
    element :user_id, 'td div div:nth-child(2) .text-small'
    element :user_name, 'td div div:nth-child(2) .text-default'
  end

  set_url '/users'

  element :title, '.page-title'

  section :search_form, SearchForm, '.search-bar'
  sections :table_rows, TableRow, '.table tbody tr'

  def table_rows_of(index)
    table_rows[index]
  end
end

このユーザ一覧ページの例では、ユーザを表示するテーブルと、ユーザを検索するフォームに分けることができます。 これらの機能をsectionとして定義することで、参照しやすい単位に抽象化することができます。

E2Eテスト

let(:index) { Pages::User::Index.new }

before { index.load }

context 'when displaying tables' do
  it 'should display correct sum values' do
    expect(index.title).to have_content(I18n.t('term.user_index'))
    expect(index.table_rows_of(0).user_name.text).to have_content(user.name)
    expect(index.table_rows_of(0).user_id.text).to have_content("ID #{user.id}")
  end

  context 'when user id is specified', js: true do
    before do
      index.search_form.user_name.set user.name
      index.search_form.search_button.click
    end

    it 'should display 1 row' do
      expect(index).to have_table_rows count: 1
      expect(index.table_rows_of(0).user_name.text).to have_content(user.name)
    end
  end
end

ページクラスのインスタンスを使って、CapybaraのElementを取り出せるので、そのまま Capybaraのメソッドが利用できます。 これによって、複雑な構造でも要素を扱いやすくなります。

最後に

新規のプロジェクトから導入したSitePrismでより良いテストコードを書くことができました。 今回は新規のプロジェクトでの取り組みでしたが、既存のシステムでもSitePrism を使い、より質の高いテストコードにしていきたいと思います。

私の所属する広告技術部ではRails管理画面に限らず広く一緒にサーバーサイド開発してくれる仲間を募集中です。ご興味あるエンジニアの方は下記から応募してみてください!

hrmos.co

*1:masterブランチではテストが落ちているので、こちらのPRで運用していますhttps://github.com/natritmeyer/site_prism/pull/186