はじめに
エンジニアの皆様、テストコードはちゃんと書けておりますでしょうか?(挨拶)
どんな開発言語や開発手法を導入していたとしても、アプリケーションの機能実装とテストは表裏一体であると言えます。場合によっては機能の作り込みよりも時間をかけるべきケースが多いくらい重要である(・・・と信じたい)反面、デッドラインが近づくにつれて真っ先に工数が削られやすく軽視されがちな工程でもあります。
時間に追われてテストコードを書いた結果、テストの体をなしていないコードになっていたり後で見返したときに記述が煩雑すぎてメンテ不能になっていたり・・・といった苦い経験は誰しもがあるかと思います。かくいう自分もそんなことは多々ありました。
そんな今までの経験則を基に「自分がテストコードを書くにあたってどんなことを意識しているのか?」をいくつかピックアップして備忘録も兼ねて紹介したいと思います。
一応注意なのですが、「あくまで自分はこうやってます」という趣旨の記事です。偉大な先人によるテストコードの書き方などに関する記事・コラムも多数ありそれらの内容に相反する事項もあるかもしれませんが、そこは生暖かい目でご指摘・コメントいただけると幸いです。
また本記事ではRails
アプリケーションに対してRspec
のコードを書いていくことを前提とします。
サンプルアプリケーション
テスト対象となるRails
アプリケーションの大まかなアーキテクチャと処理の流れをシーケンス図に示します。わかりにくいかもしれませんが、基本的なMVCアーキテクチャに下記レイヤーを取り入れたものだと思っていただければ。
- Usecase層:単一のビジネスロジックを記述する
- 要するにInteractorオブジェクト
- 参考:Railsのデザインパターン: Interactorオブジェクト
- Service層:アプリケーション外部とのやりとりのためのロジックを記述する
- 主に外部APIクライアント用のクラス
各アーキテクチャ層の責務を整理するとこんなところでしょうか。
- Controller層
- HTTPリクエストからUsecase層に渡すためのパラメータオブジェクトを生成、Usecase層に渡す
- Usecase層から返されたオブジェクトを必要に応じてHTTPレスポンス用に整形する
- 整形したオブジェクトをHTTPレスポンスとして返す
- Usecase層
- 単一のビジネスロジックを実現するためにModel層やService層の各種処理を呼び出す
- Model層
- DBからデータを読み込む、もしくはデータを保存する
- データの整合性を検証する
- Service層
- アプリケーション外のリソースにアクセスする
- 主に外部APIリクエスト用クライアントが該当
- アプリケーション外のリソースにアクセスする
テストコードを書く上で気をつけている5つのこと
ここから本題です。
各アーキテクチャ層の責務に応じたテストの観点をまとめておく
前述の通りそれぞれのアーキテクチャ層はそれぞれ責務を持っており、その責務を果たしているかどうかを検証する必要があります。そのために必要となるテストの観点をそれぞれの層について思いつく限りであらかじめ挙げておきます。
採用しているフレームワークやアーキテクチャ構成によってこの辺りは異なってくるかと思いますが、サンプルアプリケーションに対してテストコードを書く場合、自分はこんな感じでまとめています。
- Controller層
- 認証・認可が適切なHTTPリクエストのみを受け付けているか
- パラメータの形式が想定通りなHTTPリクエストのみ受け付けているか
- HTTPリクエストのパラメータに応じて適切なUsecaseを呼び出しているか
- Usecase層に適切な形式のパラメータを渡しているか
- 正常系として適切なステータスコード(2xx)、レスポンスを返しているか
- 異常系として適切なステータスコード(4xx、5xx)、レスポンスを返しているか
- Usecase層
- 入力パラメータに対して適切な形で出力を返しているか
- パラメータに応じて適切にリソースが生成/削除/更新されているか
- 想定外の入力パラメータを渡された場合、適切に例外処理しているか
- Model層
- DBからデータを正しく読み込めているか
- DBにデータを正しく書き込めているか
- モデルのリレーションやバリデーションがそのモデルの用途に沿う形で適切に設定されているか
- モデルのattributeに応じた処理が適切にできているか
- Service層
- 適切な形のパラメータで外部APIにリクエストを投げられているか
- 外部APIからのレスポンスに応じた出力を適切に返しているか
- 想定外の入力パラメータを渡された場合、適切に例外処理しているか
もちろんこれら全てを実際に検証する必要はありません。重要なのはこれらの観点から外れる物事をそのアーキテクチャ層ではテストする必要はないことです。それぞれのアーキテクチャ層で検証できているのであればその上位のアーキテクチャ層で改めて検証する必要はないはずですし、こうしないとController層をテストするためにアプリケーション全体の挙動を考慮したテストコードを書くことになってしまいます。
テスト対象となるメソッドのロジックを把握する
実際にテストコードを書く際にはそのクラスで定義されているpublicなメソッドごとに検証しますが、書き始める前にそのメソッドのコードから「何をするためのメソッドでどんな結果が得られるのか」を最低限把握します。
具体的には下記を確認しておきます。
- 引数の型と取りうる値
- 戻り値の型
- 引数によって処理が変わるか
- 別クラスのメソッドを呼び出しているか
- メソッドの実行により何らかの副作用を起こしていないか
- 引数に対する破壊的操作
- DBに対するレコードの追加/削除/更新
- 異常系に対する例外処理があるか
- 何かしらの例外オブジェクトを返す
- 異常終了を表す戻り値を返す
自分で書いたコードであるならば上記項目は当然把握しているはずなので省略しても良いですが、他人が書いたコードに対して新しくテストコードを書くなら徹底的に確認します。
なぜテストコードが書かれていないかはこの際考えないこととする
テストのスコープを明確にする
テスト対象に対して「検証すること」、「検証しないこと」の線引きを明確にします。前述のテスト観点、ロジックがはっきりとしているのであれば、「検証すること」のアタリはおおよそつけられるかと思います。
※この時点でアタリをつけるのが難しい、もしくは検証すべきことの量が明らかに多い場合はテスト対象となるコードの実装が良くないケースが多いです。 処理の一部をモジュールに切り出すなどのリファクタリングを検討してください。
レガシーコードがその状態なら色々あきらめてまず軽くテストコード書いてからリファクタリングしましょう
またロジックの内容を踏まえて「検証すること」としたものに対して下記のように優先度をそれぞれつけていきます。これによりテストに割ける工数に応じて優先度の低いものは切り捨てて記述することができます。
- 優先度:高(絶対検証すべき)
- 正常系処理に対する検証事項
- 例:戻り値が適切な形になっているか、DBアクセスが適切にできているか
- 優先度:中(可能であれば検証しておきたい)
- 想定される異常系処理に対する検証事項
- 例:想定通りの不正な入力に対して例外処理ができているか
- 優先度:低(特段必要はないけど検証してもいい)
- 発生する可能性が低い、もしくはまず起こり得ないような異常系処理に対する検証事項
- 例:想定外の入力に対して例外をraiseしているか
AAA(Arrange-Act-Assert)パターンを意識する
可読性を上げるためにAAA(Arrange-Act-Assert)パターンに沿ってテストコードを記述します。
- Arrange(準備)
- テストを実施するために必要となる前提条件や必要なデータを準備する
-
Rspec
ではlet
、let!
、before
節に記述
- Act(実行)
- テスト対象の振る舞いを実行する
-
Rspec
ではsubject
節に記述
- Assert(確認)
- 期待された結果であるかを確認する
-
Rspec
ではit
節に記述
これにより後から見返した際に「何を対象としたテストなのか?」「どのような入力を想定しているのか?」「どのような振る舞いを期待しているのか?」が理解しやすくなります。
RSpec.describe HogeHoge do
describe '#foo' do
# Act
subject { hoge_hoge.foo }
# Arrange
let(:hoge_hoge) { HogeHoge.new }
before do
# これもArrange
hoge_hoge.bar = 1
end
# Assert
context '●●の場合' do
it 'trueを返すこと' do
is_expected.to eq true
end
end
end
end
モックは必要最小限に、でも有効に使用する
テストにおいて「本物のフリをする偽オブジェクト」であるモックは非常に強力で有用です。特にテストコードを書く上で大いに悩ませる下記2つのような問題を解消できることはとてもありがたいです。
- 下位アーキテクチャ層の全ての挙動を考慮してテストコードを記述する必要がなくなる
- 外部APIを実際に叩かずに検証することができる
- ∵「外部APIを叩くフリをするオブジェクト」を用意すればよいため
しかしだからといって闇雲にモックを使うと、テストになっていないコードを量産してしまう危険性が高くなります。一例を挙げるとこんなケースです。
モック対象のメソッドの改修によりレスポンス内容が変わってしまう or モック化していた大元のクラスそのものが使われなくなる
→そんなことを知るよしもないモックが本物のフリで今まで通りの挙動をしてしまう
→テストは通ってるのにいざリリースしたら 「「なぜか」」 仕様通りに動かない!!!!
この手の事象は実際にリリースしてからようやく気づく場合も多く、原因特定までにかなりの時間を要してしまう場合もしばしばです。。。
とはいえ、悪いのはモック自体ではなくその使い方です。使わずともテストコードが煩雑化しないようであればそのままモックを使わないようにします。自分なりにモック化する/しないケースを分類するとこんなところです。
- モック化するケース
- 処理そのものが複雑かつ煩雑となりうる場合
- 例:Controller層からUsecase層の処理を呼び出す
- アプリケーション外とのやりとりを含む場合
- 例:外部APIを叩くような処理を呼び出す
- 処理そのものが複雑かつ煩雑となりうる場合
- モック化しないケース
- 対象となる処理の内容がシンプルかつ自明である場合
- 例:
ActiveRecord::Base.create
メソッドなど
- 例:
- 冪等性が保障されているような処理である場合
- 例:特定の値や文字列を必ず返すようなメソッド
- 対象となる処理の内容がシンプルかつ自明である場合
最後に
テストコードを書く上で個人的に意識していることを5つピックアップして紹介しました。「これぐらいみんなやってるでしょ?」と思っている方もいらっしゃるとは思いますが、いざテストコードを書こうとしたときに意識できていないものも多々あるかと思います。私自身いざ明文化してみると「思ったより書くこと多いな・・・」と気づかされ、テストコードの書き方について改めて見直す良い機会となりました。
「テストコード書け、ってレビューで指摘されたけどよくわからんし時間もないから適当にassert書いとけばいいだろ」みたいことを思っている方はこれを機にテストコードを書くことについて今一度検討してみるのはいかがでしょうか?
最後の最後に宣伝ですが弊社CBcloudではバックエンドエンジニアを募集しています!!
この記事を読んで弊社に興味を持ってもらえた方、また物流業界にjoinしたいと考えている方はご応募いただけるとエンジニア一同大変うれしく思います