株式会社LITALICOでWebエンジニア(Rails)を担当しています、@litalico-takamasa-mizukamiです。
この記事は『LITALICO Advent Calendar 2017』15日目の記事です。
はじめに
今年私はテストコードをたくさん書きました。実務の中でどうしてテストを書くのか?何をテストすべきなのか?テストなんて書いてないで、さっさとリリースして価値を一刻もはやくエンドユーザーに届けたい!などなど少なからずモヤモヤがありました。そんな私が実務から得られたテストについての必要性と考え方の変化について書こうと思います。
※ 途中紹介するサンプルコードはRubyなのでRuby前提の話になってしまうこともあるかと思いますがご了承ください
なんのためにテストコードを書くのか?
テストコードは主にコスト削減の為に書くべきだと思っています。サービス新規立ち上げは時はもちろんの事、サービスの運営においても仕様変更、設計の見直しなど容易に起こりえます。時にはバグを含んだアプリケーションコードをリリースしてしまう事があります。そんな時テストコードがあることによって多くのコスト削減につながります。さらにテストを書くことによって得られる利点をたくさん実務で感じられました。
バグ存在を早期に発見できる
アプリケーションコードのバグを見つけられることは大きな利点だと思います。バグはできるだけ早く見つけたほうが良いし、初期段階でのバグ修正のコストは低く、放置されればされるほど辛くなります。テストが無いためにバグ修正に時間がかかる事もしばしばありました。
アプリケーションが正しく動作する事が保証され自信を持ってリリースできる
実務の中で私は課金まわりの実装を担当する事になり、正直とてもビビっていました。自分の書いたコードで会社に大きな損害を与えてしまったらどうしようとか、請求業務が正しく行われるかどうかとても不安でした。そんな時テストコードはとても強い味方になりました。自分の書いたアプリケーションコードがテストによって正しく動作する事が保証され多少なりとも不安感を取り除くことにができました。
レビュワーのレビュー時間を短縮し、結果的にエンドユーザーに早く価値を届けられる
テストコードがない状態でレビューを受けるとある条件の時、アプリケーションコードが正しく動くかどうかの指摘をたくさん受けました。しかしテストがある事によって指摘事項が少なくなり、結果的に早くリリースできるようになりました。レビュワーに対して安心感を与えられる点においてもテストコードのメリットだなと感じられました。
後の仕様書となりうる
人間誰しも時間が立てば忘れてしまいます。該当のアプリケーションコードを書いた当人ですら、仕様はどうだったか?忘れてしまいます。なかなか、別途仕様書を作ったり、ドキュメントを作ったり、整理したりする時間は少ないです。そんな時、テストは役に立つと思います。該当のアプリケーションコードを書いた当人だけでなく、一緒に開発しているメンバーやこれから新しくJOINしてくるメンバーための仕様書として役割を少しでも与えられ、忘れてしまった仕様や設計を思い出すきっかけを与えてくれると思います。
メンテンスしやすくなる
仕様の変更は予期しない時に突然にやってきます。そんな時、テストコードのお陰でアプリケーションコードを安全に早く変更できると思います。もしテストコードがなければ、何をどう変更すればよいのか判断に迷い、新たなバグを生み出したり、仕様変更に時間がかかったりすると思います。
最低限何をテストするべきか
オブジェクト指向のアプリケーションは複数のオブジェクト間で相互メッセージのやり取りによって動作するため、メッセージに着目してテストを行う事が必要であると思います。
したがって、オブジェクトに入ってくるメッセージ(受信メッセージ)とオブジェクトから出ていくメッセージ(送信メッセージ)に着目してテストを行う事がいいと思います。
例えば図の場合Hogeに対してはメッセージの戻り値についてのテストを行います。具体的にはメッセージが戻す結果が期待する値を等しくなる事をテストします。
送信メッセージは、他のオブジェクトからすれば受信メッセージになるので、オブジェクトが受信、送信するメッセージに着目してテストを行うことが基本だと思います。
受信メッセージのテスト
以下のサンプルコードはDeliveryPerson
(配達人)とRecipe
(料理)のモデルです。配達人は料理を注文した人に料理を届ける事を想定したアプリケーションコードになります。
class DeliveryPerson
attr_reader :name, :delivery_minutes, :recipe
def initialize(name, delivery_minutes, recipe)
@name = name
@delivery_minutes = delivery_minutes
@recipe = recipe
end
def arrival_time
delivery_minutes + recipe.total_time
end
end
class Recipe
attr_reader :dish_name, :prep_time, :cook_time
def initialize(dish_name, prep_time, cook_time)
@dish_name = dish_name
@prep_time = prep_time
@cook_time = cook_time
end
def total_time
prep_time + cook_time
end
end
DeliveryPerson
は1つの受信メッセージ、arrival_time
に応答し、Recipe
も同じく1つの受信メッセージに応答します。
前述した通り受信メッセージにおけるテストはメッセージが戻す結果が期待する値と等しくなることをテストすると良いので、以下のコードはRecipe
のtotal_time
メソッドのテストコードでtotal_time
の戻り値が8
であることをテストしています。
require 'rails_helper'
RSpec.describe Recipe, type: :model do
describe 'total_time' do
it 'prep_timeとcook_timeの合計値返す事' do
recipe = described_class.new('carry', 5, 3)
expect(recipe.total_time).to eq 8
end
end
end
同様にDeliveryPerson
のテストコードではarrival_time
のテストします。
require 'rails_helper'
RSpec.describe DeliveryPerson, type: :model do
describe 'arrival_time' do
it 'delivery_minutesとRecipeのtotal_timeの合計値を返す事' do
delivery_person = described_class.new('jon', 5, Recipe.new('carry', 5, 3))
expect(delivery_person.arrival_time).to eq 13
end
end
end
疎結合なテストを意識する
前述したDeliveryPerson
のテストコードはとても壊れやすいテストコードでよいテストとは言えないです。良くない点が2点あります。一つ目はDeliveryPerson
でDeliveryPerson
に注入されたオブジェクトがtotal_time
にメッセージを送れる事を期待している点です。2つ目にRecipe
がtotal_time
を実装している事が想定されている点です。Recipe
のtotal_time
のメソッド名が仮に変更され、DeliveryPerson
のarrival_time
が古いままの場合DeliveryPerson
、Recipe
の両方のテストが失敗します。
テストの責任領域を考える
DeliveryPerson
はRecipe
のオブジェクトを注入されているが、まったく別のオブジェクトが注入される場合も考えられます。そのたびにテストが失敗するのは辛いです。そこでDeliveryPerson
のテストではRecipe
の実装内容に左右されない形でテストコードを書くべきだと思います。
テストダブルを使ってテストの責任範囲を狭める
テストダブルを使ってテストを書き直すと以下のようになります。こうすることにより。テストコードではRecipe
との依存関係を取り去る事ができ、テストの責任範囲を分担する事ができます。またどんなオブジェクトが注入されてもDeliveryPerson
のテストは失敗する事はなくなりDeliveryPerson
のテストは安定します。
require 'rails_helper'
RSpec.describe DeliveryPerson, type: :model do
describe 'arrival_time' do
it 'delivery_minutesとRecipeのtotal_timeの合計値を返す事' do
total_time_double = double("TotalTimeDouble", :total_time => 8)
delivery_person = described_class.new('jon', 5, total_time_double)
expect(delivery_person.arrival_time).to eq 13
end
end
end
テストダブルを使ったテストでは問題があると感じる人いるかもしれません。それはアプリケーションが壊れているのにもかかわらずテストが成功してしまうのではないかという指摘です。しかしRecipe
のアプリケーションコードの問題はRecipe
のテストで発見するべきだと思います。DeliveryPerson
のテストが失敗する必要はありません。したがってRecipe
のテストを修正する事で指摘事項を解消できると考えています。Recipe
でtotal_time
が正しく実装されている事を保証すればいいと思います。
require 'rails_helper'
RSpec.describe Recipe, type: :model do
before { @recipe = described_class.new('carry', 5, 3) }
describe 'total_timeが実装されていること' do
it do
expect(@recipe).to respond_to(:total_time)
end
end
describe 'total_time' do
it 'prep_time + cook_timeの値を返す事' do
expect(@recipe.total_time).to eq 8
end
end
end
こうする事でRecipe
、DeliveryPerson
のテストが互いに影響を及ぼし合わないようなテストが書けると思います。
最後に
テストを書くことによって、様々なメリットがある事が実務を通して経験する事ができました。テストコードを書くより、一刻も早くリリースしたいという当初の気持ちは今は変わりました。当初の私はテストコードを書くことが苦手でテストコードを書くことに時間がかかりすぎていました。何をテストするのか?どうやってテストするのか?分からず、テストコードを書くことが嫌いでした。そんな自分を変えたのは、近くにいた先輩方のおかげです。たくさんのテストコードを書かせてもらい、テストの大切さを学ぶ貴重な経験をさせていただきました。テストコードを書くことが苦手なら、とにかくたくさんのテストコードを書けばいい。一刻も早くエンドユーザーに価値を届けたければテストコードを書くスピードを上げればいいという考えに変化していきました。これからも、テストコードと上手に付き合っていきたいと思います。
明日の『LITALICO Advent Calendar 2017』は@jazzyslideさんの「22歳下の友達とクリエイティブユニットを立ち上げた話」についての記事です。