はじめに
この記事はLIFULL Advent Calendar 2019の12/25分の投稿です。
メリークリスマス!
クリスマスにAdventCalenderを書くようになって早3年目です。
私はクリスマスと無縁のため、毎年せこせこ記事を書いています。リア充爆発しろ。
さて、弊社ではAPIの開発の際テストコードを書くのですが、毎回若手が悩み、相談される箇所があるため、今回はそれについてまとめようと思います。
もしかしたら弊社の開発フローや文化に依存しているかもしれませんので、違和感・誤りなどあればコメント頂ければ幸いです。
二行で
- テストコードを大まかに分類すると、ユニットテスト / 機能テスト / E2Eの3つに分かれる
- 上記、呼び方・表現は言語やフレームワークによって異なる
- それぞれ目的に合わせて、モック・スタブを利用する
- 「モック・スタブ」の用語についてはこちらの記事 - スタブとモックの違い
がわかりやすく参考になります。
- 「モック・スタブ」の用語についてはこちらの記事 - スタブとモックの違い
これだけです。
当たり前やん、という方も多いと思いますが、この記事はテストコード初学者向けとなりますのでご容赦ください。
私自身、初めてテストコード(RSpec)を書き始めたときにかなり迷った部分でもありますし、なかなか腹落ちできる記事にも出会えていないので、きっと誰かの役に立つはず。
テストコードの分類と注意事項まとめ
記事の中でそれぞれ説明しますが、このテーブルに書いてあることがすべてです。
分類 | 目的 | 注意事項 |
---|---|---|
ユニットテスト | 該当のメソッドの**ロジック(責務)**が正しいことを担保する | 自身以外のメソッドに依存しない。すべてをモック・スタブする。 |
機能テスト | 該当のクラス(のパブリックメソッド)の振る舞いが正しいことを担保する | 自クラスのメソッドはモック・スタブしないこと。他のクラスはモック・スタブする。 |
E2Eテスト | 該当のAPIまたはURLの振る舞いが正しいことを担保する | (可能な限り)なにもモック・スタブしない |
それぞれサンプルコード(RSpec)を交えながら、具体的な注意事項に触れていきます。
ユニットテスト
目的は「該当のメソッドの**ロジック(責務)**が正しいことを担保する」です。
これを果たす上で最も重要な注意事項は、自身以外のメソッドに依存しないことです。
サンプルコードを見てみましょう。
class Sample1
def initialize(hoge:, fuga:)
@hoge = hoge
@fuga = fuga
end
def valid?
return false unless hoge_valid?(hoge)
fuga_valid?(fuga)
end
private
def hoge_valid?
!@hoge.nil? && @hoge.is_a?(Hash)
end
# fuga_valid?も同様に、なんらかロジックを持つ
end
この場合、valid?
の責務は
-
hoge, fuga
それぞれがvalidかどうかを確かめること
です。
それぞれがどういうときにvalidかを知る必要はありません。
そのためvalid?
のユニットテストでは、(hoge|fuga)_valid?
をスタブします。
describe Sample1 do
let(:target) { described_class.new(hoge, fuga) }
let(:hoge) { double('hoge') }
let(:fuga) { double('fuga') }
describe '#valid?' do
subject { target.valid?}
let(:is_hoge_valid) { false }
let(:is_fuga_valid) { false }
before do
allow(target).to receive(:hoge_valid?).and_return(is_hoge_valid)
allow(target).to receive(:fuga_valid?).and_return(is_fuga_valid)
end
context 'when hoge is invalid' do
it 'returns false' do
is_expected.to eq false
end
end
context 'when hoge is valid' do
let(:is_hoge_valid) { true }
context 'when fuga is invalid' do
it 'returns false' do
is_expected.to eq false
end
end
context 'when fuga is valid' do
let(:is_fuga_valid) { true }
it 'returns true' do
is_expected.to eq true
end
end
end
end
他のメソッドの実装ではなく、振る舞いに応じて自身の返り値を変更する、というvalid?
の責務のみがテストできています。
こうすることで、例えばhoge_valid?
のロジックが変更になり、nil
のときはtrue
を返すようになったとしても、valid?
の実装もテストもなにも変えなくてよいようになります。
機能テスト
続いて機能テストです。こちらの目的は、
「該当のクラス(のパブリックメソッド)の振る舞いが正しいことを担保する」
でした。
機能テストの注意点は、「自クラスのメソッドはスタブしないこと」です。
先程のSample1
クラスに少し手を加えて説明します。
class Sample2
def initialize(hoge:, fuga:)
@hoge = hoge
@fuga = fuga
end
def status
return 'OK' if valid?
'NG'
end
private
def valid?
return false unless hoge_valid?(hoge)
fuga_valid?(fuga)
end
end
status
というメソッドを追加してみました。
このSample2
を利用する側として知りたいことは、あるhoge, fuga
を入れたときにstatus
が何を返すか、だったりします。
実装上はvalid?
がどういう判定であるか、なのですが、外からはそこは意識したくないですし、そういう内部処理を隠匿してこそそのクラスの振る舞いと言えると思います。
こういった要求をテストコードで実現したものが、機能テストになります。
イメージとしては、golangのTableDrivenTestsが非常に近いです。
describe Sample2 do
let(:target) { described_class.new(hoge, fuga) }
let(:hoge) { nil }
let(:fuga) { nil }
describe '#status' do
subject { target.status }
context 'when hoge and fuga are nil' do
it 'returns NG' do
is_expected.to eq 'NG'
end
end
context 'when hoge and fuga are Hash' do
let(:hoge) { {hoge: 'hoge'} }
let(:fuga) { {fuga: 'fuga'} }
it 'returns OK' do
is_expected.to eq 'OK'
end
end
end
end
付け加えると、場合にもよりますが、他クラスに依存している場合はスタブするようにしましょう。
他クラスへは「振る舞い」にしか依存するべきではありません。
次に行く前に
ここまでユニットテストと機能テストについて書きましたが、どうでしょう?
次のように思いませんでしたか?
「機能テストあればユニットテストいらなくね?」
そのとおりです。
基本的には、機能テストがしっかりと書けていればユニットテストは不要です。
なぜなら、そのクラスの責務はしっかりと担保されているから。
ただ、それなりに複雑なロジックを書き始めた際、
すべての入力パターンを機能テストで担保できますか?
本当に考慮漏れはないでしょうか?
なので、ユニットテストは機能テストを「補う」ためにあるものだと思ってください。
一つ一つのパーツがしっかりと作ってあり、ロジックが担保されている。
だから、一部代表的な機能がテストできていれば、そのクラスは信頼できる。
という立て付けで考えるといいと思います。
E2Eテスト
最後にE2Eテストです。こちらの目的は、
「該当のAPIまたはURLの振る舞いが正しいことを担保する」
でした。
ユニットテスト、機能テストと広げて来たのでもはや自明かと思いますが、
E2Eテストの注意点は、「(可能な限り)なにもスタブしないこと」です。
あなたのサービスをユーザーが使うときの振る舞いをテストするものです。
外部API、DB、ロガー、あらゆるものが期待通りに動くかわかりません。
考えうる限りの入力で、APIやウェブページをテストしてください。
まぁ、実際は無理ですよね。
なので、先程書いたように、最重要(あるいは代表的)な部分をE2Eテストで担保して、それを下位のテスト(機能テスト、ユニットテスト)で補完しましょう。
最後に
当たり前のことですが、テストコードはプロダクトコードの品質を担保・維持するためのものです。
開発体制により、要求品質は異なるでしょうし、その方法も異なるでしょう。
大事なことは、それぞれのテストの目的をきちんと理解し、それに合わせた方法で書くことかと思います。
自分の考慮不足に気づけるので、私はユニットテストを書くのが大好きです。