Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

LambdaTestでスモークテストをはじめました

こんにちは、Studyplus for School事業部エンジニアの島田です。

もうすぐ2020年も終わりになりますね。

はじめに

皆さんはスモークテストをしていますか?

スモークテストとは元々「電子機器での発煙がないかをテストしていたこと」を起源とし、そこから転じて「ソースコードの開発・追加・修正を終えたソフトウェアが動作する状態にあるかを確認するテストのこと」となったようです。

Studyplus For School(以下FS)の開発チームでは、こちらの記事でも少しふれているStatic vs Unit vs Integration vs E2E Testing for Frontend Apps にあるEnd to End(E2E)をスモークテストとして位置付けています。

www.itmedia.co.jp

testingjavascript.com

tech.studyplus.co.jp

なぜ導入したか

これまでE2Eテストの導入を検討した事はあったのですが、

  • どこまでをテストすべきか
  • 運用負荷が高そう。UIの変更に追随することが大変ではないか
  • UI変更に対応できなくなるとテストが落ちても気にしなくなり、テストが狼少年になってしまうのではないか

といった懸念がありましたが、上記の記事にあるIntegration testsの線引きを決め、E2Eテストではサービスの重要な機能に絞った最低限のテストにする事を決めました。

また、こちらの記事で少し触れているログイン関連の改修ではリリース後に一部機能の不具合が発覚しました。そのためログインなどのクリティカルな機能ではE2Eによる退行テストが必要だと強く感じるようになりました。

tech.studyplus.co.jp

ツール選定

E2Eテストを検討するにあたっては、まずサービス、テストフレームワークの選定をしました。

様々な候補が出たのですが、それぞれを調査・検討するには数が多過ぎるので、実績や知名度や特性から以下に絞りました。

SaaSとOSS(課金へのアップグレードもある)のそれぞれ2つをリストアップし、4つのサービス、フレームワークの調査・比較をする事にしました。

autify.com www.lambdatest.com playwright.dev www.cypress.io

それぞれについて以下の内容を中心に調査をしました。

  • 選定にあたって
    • 初期コスト(導入、学習等)
    • 運用コスト(課金有無、自前で構成する場合)
    • テストの実装方法

各メンバーで分担して調査した結果を元にチームで検討した概略が以下になります。

  • Autify
    • メリット
      • コードを書かなくて良い。エンジニア以外でも出来る
      • テスト実行までの最初の設定が手軽そう
      • 日本語ドキュメント、サポートがある
    • デメリット
      • 課金が比較的高い
      • テストコードという資産が残らない(他への乗り換えが難しくなる)
  • LambdaTest
    • メリット
      • ローカルで簡単に様々なブラウザでの実行が確認できる
      • ダッシュボードで様々な結果(動画、リクエスト内容)が確認出来る(年間契約: $99/月)
      • 既存の言語・テストフレームワークで書ける
      • 自動テスト以外の機能も充実している
      • Integrationが多い
    • デメリット
      • Seleniumの学習コスト
      • 海外での導入実績はそれなりにあるが、サービスとしての信頼性は未知数
  • Playwright
    • メリット
      • 無料
    • デメリット
      • 学習コスト高そう。実装に慣れるのが大変そうな印象
      • 実行環境の準備・運用
      • 実行結果(キャプチャ)の保存をしたい場合に、自分たちで考える必要がある
  • Cypress
    • メリット
      • ダッシュボードで様々な結果(動画、リクエスト内容)が確認出来る(年間契約: $99/月)
    • デメリット
      • 学習コスト。Playwrightと比べれば低そうではある

これらの内容から、

  • 導入コスト:Playwright > Cypress > LambdaTest > Autify
  • 運用コスト:Autify > LambdaTest > Cypress > Playwright
  • 金額: Autify > LambdaTest , Cypress > Playwright

と判断し、LambdaTest か Cypress が争点となりました。スモークテストのみをするのであれば両者とも大きな差異はないと判断しました。そのため同じ金額を課金するのであれば+α(クロスブラウザチェックなど)が出来るLambdaTestを採用しました。

※ 上記はあくまでFSチーム内での簡易的に調査した内容なので、もしかしたら認識が違っている箇所もあるかと思いますので、もし修正点があればご指摘いただければと思います。

LambdaTestでE2Eテスト(自動テスト)

LambdaTestでE2Eテストをするには、SeleniumのリモートWebDriver経由でLambdaTestのSelenium Automation Gridを利用して様々なブラウザのテストを実行する事ができます。

そのため様々な言語・フレームワークで実現をすることができます。こちらに詳しく記載されています。

ドキュメント以外にもこちらに実装のサンプルがあります。

FSチームでは学習コスト等を考えて、RSpecでテストを実装することにしました。

www.lambdatest.com

github.com

構成とテストの実行タイミング

FSのインフラ構成とデプロイについてはこちらの記事に詳しい説明があります。アプリケーションはSPAでサーバー(API)とクライアント(Web)で構成され、デプロイにはJenkinsを利用しています。

サーバーの環境には、開発・ステージング・本番の3つがあります。

E2Eテストの実行タイミング、フローの概要は、

  1. 各リポジトリ(サーバー or クライアント)にてmasterへマージ
  2. Jenkinsで開発環境にデプロイ
  3. JenkinsからCircleCI経由(APIでpipelineを実行)でE2Eテストを実行(SeleniumのリモートWebDriverでLambdaTestに接続)
  4. 問題なければ、ステージング、本番へリリース

という感じになります。

f:id:yo-shimada:20201214101413j:plain
E2Eテスト概要図

E2Eテストについてはアプリケーションとは別のリポジトリで管理し、通常のbuild(単体テスト、Lint)とは分けてデプロイ後に実行する事としました。そうした理由としては、

  • アプリケーションがサーバー(API)とクライアント(Web)でGithubのリポジトリが分かれて管理しているため、それぞれのデプロイに対してE2Eテストを実行したい
  • E2Eテストは、実行環境(サーバー)にデプロイしてからでないと確認が出来ない
  • スモークテストの位置付けとしては、(本番にリリースされなければ)masterのbranchマージ後に問題が発覚すれば良い

といった事が上げられます。

circleci.com

tech.studyplus.co.jp

E2Eテストコード(RSpec)の実装例

RSpecで実装概略です。

リポジトリ構成

$ tree -L 2 -a
.
├── .circleci
│   └── config.yml
├── .envrc
├── Gemfile
├── Gemfile.lock
├── spec
│   ├── login_spec.rb
│   └── spec_helper.rb
└── vendor
    └── bundle

Gemfile

source 'https://rubygems.org'

git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }

gem 'retriable'
gem 'rspec'
gem 'rubocop', require: false
gem 'selenium-webdriver'

spec/spec_helper.rb

  • @driver.execute_script "lambda-status=#{lambda_status}" はLamdaTestからSlackで通知をするために、LamdaTestにRSpecの成功可否を知らせるために設定しています。
  • @driver = Retriable.retriable(on: Net::ReadTimeout) do はLamdaTestにてタイムアウトする事が稀にあるので、その際にリトライをするようにしています。

Slack通知成功
Slackエラー通知

require 'selenium-webdriver'
require 'retriable'
require 'net/protocol'
require 'net/http'

RSpec.configure do |config|
  config.expect_with :rspec do |expectations|
    expectations.include_chain_clauses_in_custom_matcher_descriptions = true
  end

  config.mock_with :rspec do |mocks|
    mocks.verify_partial_doubles = true
  end

  config.shared_context_metadata_behavior = :apply_to_host_groups

  # Selenium
  config.after(:example) do |example|
    lambda_status = example.exception ? 'failed' : 'passed'
    @driver.execute_script "lambda-status=#{lambda_status}"
  end

  config.around(:example) do |example|
    caps = {
      browserName: ENV['LT_BROWSER'],
      version: ENV.fetch('LT_BROWSER_VERSION') { 'latest' },
      platform: ENV['LT_OPERATING_SYSTEM'],
      name: example.metadata[:description] || example.metadata[:location] || 'RSpec Sample Test',
      build: 'RSpec Selenium Boron',
      network: true,
      visual: true,
      video: true,
      console: true,
      tags: [example.metadata[:file_path].split('/').last&.split('.')&.first]
    }

    @driver = Retriable.retriable(on: Net::ReadTimeout) do
      client = Selenium::WebDriver::Remote::Http::Default.new
      client.read_timeout = 120 # seconds
      Selenium::WebDriver.for(
        :remote,
        http_client: client,
        url: "https://#{ENV['LT_USERNAME']}:#{ENV['LT_APPKEY']}@hub.lambdatest.com/wd/hub",
        desired_capabilities: caps
      )
    end
    begin
      example.run
    ensure
      @driver.quit
    end
  end
end

spec/login_spec.rb

クリティカルなテストケースのみ(今回はログイン)として、なるべくUIの状態に依存しないシンプルな実装(最低限のxpath)を心がけました。

RSpec.describe 'login' do
  describe 'ログイン・ログアウト' do
    let(:email) { 'test@example.com' }
    let(:password) { 'sample' }

    context '未ログインの場合' do
      before do
        @driver.manage.window.maximize
        @driver.get("#{ENV['WEB_URL']}/login")

        @driver.find_element(:xpath, "//button[contains(text(), 'ログイン')]").click

        email_element = @driver.find_element(:name, 'operator[email]')
        email_element.send_keys(email)
        password_element = @driver.find_element(:name, 'operator[password]')
        password_element.send_keys(password)
      end

      subject { @driver.find_element(:xpath, "//button[contains(text(), 'ログイン')]").click }

      context '正しいメールアドレス、パスワード' do
        it 'ログイン出来る' do
          subject
          expect(@driver.current_url).to eq "#{ENV['WEB_URL']}/?login=success"
        end
      end

     ...

    end
  end
end

.circleci/config.yml

実行結果についてはSlackで通知されます。

f:id:yo-shimada:20201203232355p:plain
CircleCIのSlack通知

version: 2.1
orbs:
  slack: circleci/slack@4.1.1
executors:
  default:
    working_directory: ~/test-e2e
    docker:
      - image: cimg/ruby:2.6-browsers
        environment:
          LT_OPERATING_SYSTEM: win10
          LT_BROWSER: chrome
          WEB_URL: https://example.com

commands:
  install_dependencies:
    steps:
      - run:
          name: gem install bundler v1.17.2
          command: |
            gem install bundler:1.17.2
      - run:
          name: bundle install
          command: |
            bundle install -j4 --path vendor/bundle
  notify_failed:
    steps:
      - slack/notify:
          event: fail
          mentions: '@engineer'
          template: basic_fail_1
  notify_success:
    steps:
      - slack/notify:
          event: pass
          custom: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "E2E Test Successful! :tada:",
                    "emoji": true
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Project*:$CIRCLE_PROJECT_REPONAME"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*When*:$(date +'%m/%d/%Y %T')"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Tag*:$CIRCLE_TAG"
                    }
                  ],
                  "accessory": {
                    "type": "image",
                    "image_url": "https://assets.brandfolder.com/otz5mn-bw4j2w-6jzqo8/original/circle-logo-badge-black.png",
                    "alt_text": "CircleCI logo"
                  }
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": {
                        "type": "plain_text",
                        "text": "View Job"
                      },
                      "url": "${CIRCLE_BUILD_URL}"
                    }
                  ]
                }
              ]
            }
jobs:
  build:
    executor: default
    steps:
      - checkout
      - restore_cache:
          keys:
            - v2-bundler-{{ arch }}-{{ checksum "Gemfile.lock" }}
            - v2-bundler-{{ arch }}-
      - install_dependencies
      - save_cache:
          key: v2-bundler-{{ arch }}-{{ checksum "Gemfile.lock" }}
          paths:
            - vendor/bundle
      - persist_to_workspace:
          root: ~/test-e2e
          paths:
            - ./*
      - notify_failed
  rspec:
    executor: default
    steps:
      - attach_workspace:
          at: ~/test-e2e
      - restore_cache:
          keys:
            - v2-bundler-{{ arch }}-{{ checksum "Gemfile.lock" }}
            - v2-bundler-{{ arch }}-
      - install_dependencies
      - run:
          name: run test
          command: |
            bundle exec rspec
          when: always
      - notify_failed
  notify_success:
    executor: default
    steps:
      - notify_success

workflows:
  build:
    jobs:
      - build
      - rspec:
          requires:
            - build
      - rubocop:
          requires:
            - build
      - notify_success:
          requires:
            - rspec

JenkinsからCircleCIのジョブを実行する際のAPI呼び出し例

curl --request POST \
-u '${CIRCLECI_TOKEN}:' \
--header 'content-type: application/json' \
--data '{"branch": "main"}' \
--url 'https://circleci.com/api/v2/project/${vcs-slug}/${org-name}/${repo-name}/pipeline'

LambdaTestのダッシュボード

Seleniumによる自動テストの経過・結果はLambdaTestのダッシュボードで確認することができます。Seleniumのvideo オプション等を有効にしておくと、テストの動画やリクエスト内容などブラウザでの実行を確認することが出来ます。

f:id:yo-shimada:20201203232900j:plain
Lambdatest Automation

さいごに

LambdaTestを導入するまでにかかったコストは低く、実行結果が簡単に分かりやすく確認出来る事は大きなメリットだと感じています。

また自動テスト以外にも、様々なブラウザで検証できるクロスブラウザテスト等の機能が充実しており自動テスト以外でも導入の恩恵は大きいと感じています。

ただ、自動テストで時々タイムアウトになるなど不安定な時がありました。あまりに常態化するとテストが落ちても気にしなくなりテストをしている意味がなくなるので、そこが心配ではありますが現時点ではほぼ発生しないので気になってはいません。

今後はE2Eによるクリティカルな機能のテストケースの実装を追加し、より保守性を向上させると共に手動で実施するテストを減らし生産性を高めていきたいと考えています。