STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

モックしないテストも書く話

STORES 予約 でwebアプリケーションエンジニアをやっております。ykpythemindです。

皆さん、Webアプリケーションのテストを書いていますか。 モック(mock)を使っていますか。 今回は自動テスト上で、偽物だけではなく(要所で)本物を使おうよという話を書きます。
想定読者としては主にRuby on Railsを開発しているバックエンドエンジニアを想定しています。

モックとは

今回のモックの定義は以下にしておきます。 *1

実際の外部サービスや内部サービスの代わりをする偽のサービスを作成することです。 ref: https://circleci.com/ja/blog/how-to-test-software-part-i-mocking-stubbing-and-contract-testing/

Rubyでは外部サービスとの接合部で、

  • RSpecallow(xx).to receive(yy).and_return(zz) ... などでモックオブジェクトを使ったり、
  • webmockVCR gem を使うのが一般的そうです。

STORES 予約 では外部の決済代行サービスやメール配信部分の繋ぎこみなどで、VCR gemでレコーディングされたAPIのレスポンス(つまりはモックされた偽物)を用いている部分が多くあります。

利点と課題点

モックの使用について、実際のAPIを叩くわけにはいかない部分でもテストできる点や速度の面での利点があります。一方課題点は、あくまでモックは偽物であって、本物ではない点です。

そもそもなんで自動テストをやるんだっけ、というと、安全にデリバリーしたい。本番反映したときに意図しないことが起きないで欲しい。というのがあるかと思います。全部モックしていると、気づかないうちに嘘をつかれていて、本番でコケます……。

全然気づかなかった例

Ruby 3系にアップデートのタイミングで発生しました。キーワード引数の扱いの破壊的変更に対応できていません。モックを刺しており、本番でしか通らない部分でした。

STORES 予約 での対応例

本物を使う究極のテストとして、手動テストやe2eテストがありますが、STORES 予約ではそこまで重くない範囲の結合テストを用意しています。

ポイントは以下の点です。

  • 外部サービスのsandbox環境を使う
  • RSpecタグ機能が便利
  • 抽象的なレイヤーから呼び出す(直接SDKを呼び出すようなテストにしない)

外部サービスのsandbox環境を使う

本番環境だけでなくsandbox環境を提供してくれているサービスが大半だと思うので、そちらでキーを発行し開発者で共有します。以下は一部の外部サービスの例です。 *2

stripe

https://stripe.com/docs/testing#test-code

twilio

https://jp.twilio.com/docs/iam/test-credentials

こちらのキーを、後ほどCI環境や開発者環境の環境変数に設定しておきます。

RSpecでの例

通常(unit testやrequest test)のrspecのテストからは分離します。

spec
├── models
├── requests
├── integrations <- 実リクエストがあるものを全部この階層に入れる
    ├── stripe_spec.rb
    ├── twilio_spec.rb
    ...
...

specファイルはこんな感じです。

# integration: trueをつけておく (タグ)
RSpec.describe 'Stripe Integration Test', integration: true do
  around { |e| with_webmock_disabled { VCR.turned_off { e.run } } } # このグループ内ではmock系を無効化
  
  # ...
end
module WebmockDisableHelper
  def with_webmock_disabled
    WebMock.disable!
    begin
      yield
    ensure
      WebMock.enable!
    end
  end
end

タグを使ってCI上での実行を分離する

通常のテストとモックしない結合テストを分けておくとCIの実行速度面や管理の面で利点があります。

# GitHub Actions
  - name: Run Integration test
    env:
      STRIPE_SECRET_KEY: ${{ secrets.TEST_STRIPE_SECRET_KEY }}
      # ... sandbox環境を向いたキーをsecretsに登録しておく
    run: |
      bin/rspec --tag @integration

通常のspec実行はこうしておきます。

# integrationタグがついていないテストのみを実行
bin/rspec --tag ~@integration

抽象的なレイヤーから呼び出す

最後のポイントとしては、抽象的なレイヤーから呼び出すことで、直接SDKを呼び出すようなテストにしないということです。 以下では、コントローラ層から抽出してきたPaymentRequestというオブジェクトのpayメソッドを実行しています。(実装的には内部で決済処理のAPIが実行される)

specify 'クレカ決済をして、rails側に正しくデータが記入されること' do
  event_scheme_page = create(:resource, :event_scheme, prepayment_price: 1000)
  reservation = create(:reservation, :accepted, resource: event_scheme_page)

  # Stripe上で使えるダミーコード。有効なカードとして扱われる (https://stripe.com/docs/testing#use-test-cards)
  # 実際のリクエスト上では card_tokenだけが送信されてくる。(カード情報が予約サーバに送信されない)
  card_token = Stripe::Token.create(
    { card: { number: '4242424242424242', exp_month: 1, exp_year: 2023, cvc: '314' } }
  )

  # 実際に予約に対しての決済を実行している部分(コントローラから抽出してきた)
  payment_request = PaymentRequest.new({ method: 'credit_card', ctok: card_token }, merchant)
  payment_request.pay(reservation: reservation, current_user: end_user)

  reservation.reload

  # 記録されたデータの確認
  expect(reservation.paid_with_credit_card?).to be true
  expect(reservation.payment.amount).to eq 1000 # 予約ページに設定した金額
  
  # stripe上のデータを確認
  charge_on_stripe = Stripe::Charge.retrieve(reservation.payment.external_id)
  expect(charge_on_stripe.amount).to eq 1000 # こちらも設定した金額でチャージされてるかどうか
end

まとめ

本物を使ったテストがあると安心感があります。一方、このようなモックしないテストはあくまで必要最低限に留めましょう。

*3

たとえば今回のクレカ決済のテスト部分では、決済手数料の計算等の部分はテストするべきではありません(unit testとして書けるはず) 私は最小限の正常系だけ通しておくことが多いです。

効果的にあえてモックしないテストを使って安心安全なデリバリーを行っていきましょう。

本記事は社内勉強会でのLTに一部加筆修正を加えたものです。 ヘイ株式会社では絶賛採用活動中です。ご興味を持たれた方はぜひお願いします。

*1: モック/スタブ/ダブル 等それぞれ意味が違いますが、厳密な使い分けをしていません

*2: 言うまでもありませんが、必ず漏れても問題ないsandbox用のキーにしましょう...

*3:TDD is dead. Long live testing. (DHH)

I don't think that's healthy. Test-first units leads to an overly complex web of intermediary objects and indirection in order to avoid doing anything that's "slow". Like hitting the database.

I rarely unit test in the traditional sense of the word, where all dependencies are mocked out, and thousands of tests can close in seconds. It just hasn't been a useful way of dealing with the testing of Rails applications.

とあるようにRails自体が積極的にDIやmockを使うような思想ではなさそうです。