こんにちは、プロダクトエンジニアリング部の鈴木です。
私達のチームでは、リファクタDaysの取り組みとして、APIサーバのテストコード(RSpec)のリファクタリングを行いました。
このリファクタリングにより、テストコードの記述量が大幅に削減され、数年間利用してきたAPIコントローラのテストコードを作業時間にして2週間程度で移行できました。
この記事では、どのようにしてチームでRSpecを改善したのか全体像をお伝えします。
APIサーバが抱えるテスト実装の課題
私達のチームが管理しているサービスでは、バックエンド(APIサーバ)が、RubyのSinatraフレームワークによるBackends For Frontend(BFF)構成となっています。
コントローラのテストは、RSpecで記述しており、APIサーバへの問い合わせ結果を検証しています。
APIサーバでは外部APIへの問い合わせが多く行われており、テストコードでは外部APIには問い合わせないよう、ダミーデータを返すためのモック・スタブ化が多く行われています。
そのため、図のようにサービスの機能追加・改修の度に、モック・スタブ化を行っていました。
これにより、テストコードの実装工数・コード量・管理の負担が増加し、サービス実装の足枷となりつつありました。
また、テストコード全体の構成にも明確なルールはなく、伝統的に書かれている構成を参考に実装している状態には、チームとして課題感を抱えていました。
主な改善内容
まず、初めに刷新後のAPIコントローラのテスト作成方法を図示します。
刷新後のテストコードの作成は、ツールからテストコードの雛形を生成し、リクエストパラメータやテストコードを追記して作成します。
また、モック・スタブ化や期待値の作成は初回時に自動的に行われるため、実装者は生成された内容が正しいか確認するだけで良いようになっています。
この仕組みに変更するためどのような対応をしたのか、各章で詳しく解説いたします。
ディレクトリ構成を整備・統一する
テスト関連のディレクトリ構成は、実装コードと同じディレクトリ構成になるように変更することで、実装との対応関係を明確にしました。
先に、大まかな実装(src/配下)とテスト関連(spec/配下)のディレクトリ構成のイメージを記します。
ここでは、例としてsrc/app/resource/mansion.rb
という、APIのコントローラを対象としたテストのディレクトリ構成を記載しました。
. ├── spec #テスト関連 │ ├── app #テストコード │ │ └── resource │ │ └── mansion │ │ ├── helper.rb #テスト準備コード │ │ └── mansion_spec.rb #テストコード │ ├── fixture #期待値ディレクトリ │ │ └── app │ │ └── resource │ │ └── mansion │ │ └── result.json #mansion_spec.rbで利用する期待値 │ ├── helper #共通テスト準備コード │ ├── tools #テスト作成自動化用のツール群 │ └── vcr_cassettes #モック/スタブ情報(VCRカセット) └── src #実装 └── app └── resource └── mansion.rb
spec/app配下にあるテストコードと、fixture/配下のテストコードの期待値ファイルは、実装側のsrc/以降のファイル名までをディレクトリ構造として配置するようにしています。
単純な話のようですが、今まではルールが明確でないなど、独自の共通化により異なるディレクトリ構造が生まれているなど全体像が把握しにくい状態でした。
全体のディレクトリ構成を整備・統一を初めに行うことで、新規実装者でも全体像を把握しやすく、後述の自動化も行いやすくなります。
テストの雛形を自動生成する
テストコードを書くために共通で必要なファイルやコードは、自動生成することで実装者が楽をできるように仕組み化しています。
テストコード雛形作成のツールでは、テスト対象のAPIのエンドポイント(/mansion get)を指定して実行すると、適切なディレクトリにspecの雛形ファイルが配置されるようになっています。
雛形作成コマンドの実行例
ruby spec/tools/make_templates.rb /mansion get
コマンドによって作成されたテストコードの雛形イメージ
#./spec/app/resource/mansion/mansion_spec.rb # frozen_string_literal: true require_relative 'helper' describe Resource::Mansion do let(:params) { {} } # 必要に応じてリクエストパラメータの定義 # リクエストパラメータのバリデーションテストコード describe '#valid?' do it_behaves_like 'valid?', %i[] end # getリクエストのテストコード describe '#get', :vcr do let(:endpoint) { '/mansion' } let(:result_file) { 'mansion.json' } include_context 'expected_result' end end
その他も、テストコードの作成に注力できるよう、ファイルの読み込みなどを極力自動化しています。
モック・スタブ化をVCRで自動化する
外部APIのモック・スタブ化には、VCRを利用することで、モック・スタブ化を自動化しました。
今まで、外部APIに問い合わせる箇所は、テスト実行時には問い合わせないようモックを作成し、ダミーデータを返すスタブを記述していました。
モック・スタブ化にはRSpec標準のRSpec Mocksを用いてきました。
しかし、この方法は下記のような問題が発生しており、実装の負担になっていました。
- 外部APIのリクエストパターン分だけ、モック・スタブ化用のコード(
allow().to receive().and_return()
)を記述する必要がある - モックを共通化するために、テストコードに
shared_context()/include_context()
が頻出する - 外部APIへの問い合わせ結果の代わりとなる、スタブデータを作成する必要がある
- スタブデータが実際の問い合わせ結果と異なり、テストの問い合わせ結果と実際のAPIの結果が乖離する
これらの問題の解決策として、VCRを用いました。
VCRは、Net::HTTPやFaradayといった様々なHTTPライブラリを、外部APIへの問い合わせが発生したタイミングで自動的にWebMock等にモックします。
更に、問い合わせ結果をファイル(vcr_cassettes/配下)に初回のみ自動的に書き出ます。これをVCRではカセットと呼び、スタブデータとして自動的に利用します。
1度VCRの設定をしてしまえば、テストコードへの記述は、外部APIに問い合わせるコードが含まれるテストコードのcontext/it
ブロックに:vcr
を記述するだけで機能します。
describe '#get', :vcr do # このブロック中の外部API問い合わせを自動でモック・スタブ化 end
この仕組みにより、下記のメリットが得られました。
- モックコード・スタブデータを記述する必要がなくなる
- モック・スタブの記述がテストケースから無くなり、テストコードが読みやすくなる
- モック記述漏れにより、外部APIに問い合わせてしまうことがなくなる
- スタブデータが、実際の問い合わせ結果と同じなため、決定論理的なテストを行える
一方、ファイルを自動生成する都合で、describe/context/itにマルチバイト(日本語)を使いにくくなることが課題としてあがりました。
デフォルト設定のVCRは、describe/context/it
の命名で、スタブデータのディレクトリとファイルを作成します。
それにより、命名に日本語が入っているとディレクトリ・ファイル名の日本語がエンコードされ、CLIやGit操作うまくできなくなりました。
解決方法は、下記が考えられます。
- CLIやGitを、マルチバイトでも問題ないように設定する
- VCRの設定でスタブデータのディレクトリを明示的に指定する
describe/context/it
を半角英数で記述しする
私達は、汎用性のためdescribe/context/it
を半角英数で記述しすることを選択しました。
それ以外の殆どの課題は、VCR側の設定をカスタマイズすることで吸収できました。VRCは汎用性の高いライブラリだと思います。
テストコードの期待値も自動で作る
テストコードの期待値も、初回テスト実行時にAPIのリクエスト結果から自動的に作成されるようにしています。
テストコードの期待値はinclude_context 'expected_result'
の内部で下記のように、初回テスト実行時のみ作成と検証をしています。
- fixtureディレクトリ配下に、テストコード用の期待値が存在するか確認する
- 存在しない場合、テストでのAPI問い合わせ結果を、期待値としてfixtureディレクトリ配下に書き込む
- 存在する場合、期待値ファイルを読み込み、作成は行わない
- テストを実行し、期待値とテスト実行結果を検証する
気をつける点として、初回テスト実行時はテスト結果と期待値が同じになるため、APIへの問い合わせ結果はどうであれテストを通過してしまう点です。
これについては、異常な期待値が生成されないよう、APIへの問い合わせ結果に問題があるときは警告を表示するよう対策をしています。
また、期待値が正しい値になっていることは、実装時とレビュー時に確認するようフロー化しました。
テストコードから実装の振る舞い以外を追い出す
最後にテストコードの見通しを良くするために行った対応として、テストコードにはテストコードのパターンごとに、実装の振る舞いに影響する受け取る値(引数)と返す値(期待値)のみを定義するようにしました。
これにより、テストコードから誰が見ても実装の挙動がわかりやすくなるようにしています。
具体的には、実装の振る舞いには関係の無い、テストを実行するのに必要なコード(準備コード)をhelperファイルに切り出しました。
helperファイルに移されるコードの例としてはbefore
やafter
ブロックで行うような、日時の固定や外部API以外(AWSリソース等)モック・スタブ化などの処理です。
準備コードを切り出すためのhelperファイルは2種類の配置方法を用意しました。
- 各テストコードでしか必要のないhelperのコード:各specファイルと同列のhelper.rbに配置。
- 日時の固定など、複数のテストコードでも利用するような共通のhelperコード:spec/helper/配下から共通で呼び出されるように配置。
日時固定の共通helper
# ./spec/helper/contexts/time.rb # VCRカセットの生成時間で日時を固定する共通準備コード shared_context 'freeze_time' do before do travel_to(VCR.current_cassette&.originally_recorded_at || Time.now) end end
共通helper呼び出し用
# ./spec/helper/spec_helper.rb require './spec/helper/contexts/time' # 共通で読み込む準備コードを定義 def basic include_context 'freeze_time' end
個別helper(S3のモック・スタブ化)
# mansion/helper.rb require './spec/helper/spec_helper' # mansion/mansion_spec.rbの実行に必要な、S3バケットのモック・スタブ化 shared_context 'stub_aws_s3' do let(:s3_resources_params) { { region: 'ap-northeast-1' } } let(:s3_resources) { instance_double('s3_resources') } let(:s3_client) { instance_double('s3_client') } before do allow(Aws::S3::Resource).to receive(:new).with(s3_resources_params).and_return(s3_resources) allow(s3_resources).to receive(:client).and_return(s3_client) end end
テストケースのコード
# mansion/mansion_spec.rb require_relative 'helper' describe Resource::Mansion do basic # 共通準備コードの読み込み describe '#get', :vcr do include_context 'stub_aws_s3' # 個別で必要な準備コードの読み込み let(:params) { { mansion_id: 123 } } let(:endpoint) { '/mansion' } let(:result_file) { 'result.json' } include_context 'expected_result' end end
これにより、テストコードからは実装の振る舞いがわかりやすくなり、コードの見通しを良くしています。
これらの対応の結果として、大幅に自動化とテストコードの棲み分けがされたため、実装者が考慮しなければならない点が下記に集約されました。
- テストコードごとのリクエストパラメータの記述
- 外部API以外のモック・スタブ化
- 期待値が想定される値になっているかの確認
テストコードも、8万行程度あったものが約4千行にまとめられ、20分の1となり大幅に改善できました。
チームでの改善の進め方
改善に向けては、リファクタDaysの時間の時間を使ってチームで進めてきました。
リファクタDaysとは、部署全体で進めている、技術的負債の解消とそれによる価値創造の加速を目的とした取り組みです。
我々のチームでは、月に1日業務とは別にリファクタDays時間を確保し、チームで管理しているサービスの技術的負債の解消をしております。
今回のRSpecリファクタリングは、下記のような流れで進めました。
- サービスの課題をブレインストーミング形式で洗い出す
- テストコードの課題点をブレインストーミング形式で洗い出す
- テストコードの改善方針を定める
- チーム全員でリファクタリングを行いながら、改善の障壁を洗い出す
- チームで障壁について解決案を議論・解消
- 4から5を繰り返す
初めから、RSpecの改修を目的にしていたわけではなく、チームでサービス全体からボトルネックを洗い出すところから始めました。
RSpecの改善が決まってからは負債解消をするにはどうするのがよいか、リファクタリングを実施しながら改善の検証を繰り返しました。
その結果として、チーム全員が納得できる改善結果にできました。
まとめ
テストコードは、実装の保守・運用に必要なものですが、テストコードの記述に時間がかかると、実装工数増加に繋がり価値創造の妨げになりかねません。
今回は、テストコード作成の自動化と適切なファイルの棲み分けを行うことで、 次の価値創造の高速化に繋げました。
LIFULLでは、なかなか手を付け辛い技術的負債の解消も、チームのKPIとして掲げることで、精力的に取り組んでおります。
気になった方は求人情報も御覧ください。