unittestのあるあるとその対策をゆるく書きたい~~
はじめに
QAエンジニアとしてテストコードをレビューしたり、テストコードを書くことがあるので、ユニットテストに関するあるあるを書いたいと思います。
対策は具体的ではなくふわっとしているものが多いです。「あるある」という気持ちで見ていただけると嬉しいです。
この記事では、RSpecを用いた例を中心に解説します。
あるある1: context深くなりがち
内容
contextはテスト条件を表すことが多いです。複雑な仕様になればなるほどcontextが深くなりやすくなりがちです。
サンプルコード
サンプル: プロダクションコード
class MovieTicketPrice
BASE_PRICES = {
child: 500, # 12歳以下
teenager: 1000, # 13歳〜18歳
adult: 1500, # 19歳〜64歳
senior: 1200 # 65歳以上
}.freeze
LADIES_DAY_WEEKDAY = :wednesday
def calculate_fee(customers, entry_hour, entry_date)
total_fee = customers.reduce(0) do |sum, customer|
age = customer[:age]
gender = customer[:gender]
base_price = case age
when 0..12 then BASE_PRICES[:child]
when 13..18 then BASE_PRICES[:teenager]
when 19..64 then BASE_PRICES[:adult]
else BASE_PRICES[:senior]
end
# レディースデー割引
base_price *= 0.9 if apply_ladies_day?(entry_date, gender)
# 17時以降割引
base_price *= 0.8 if entry_hour >= 17
sum + base_price.to_i
end
# 団体割引
total_fee *= 0.8 if customers.size >= 7
total_fee.to_i
end
private
def weekday_to_wday(weekday)
{ sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 }[weekday]
end
def apply_ladies_day?(entry_date, gender)
entry_date.wday == weekday_to_wday(LADIES_DAY_WEEKDAY) && gender == :female
end
end
サンプル: contextが深い例
require_relative 'movie_ticket_price'
require "date"
RSpec.describe MovieTicketPrice do
describe '#calculate_fee' do
subject do
movie_ticket_price = MovieTicketPrice.new
movie_ticket_price.calculate_fee(customers, entry_hour, entry_date)
end
let(:wednesday) { Date.new(2024, 12, 11) }
let(:tuesday) { Date.new(2024, 12, 10) }
let(:customers) { customers_group_discount_not_applied }
let(:customers_group_discount_not_applied) { [
{ age: 12, gender: :female },
{ age: 13, gender: :male },
{ age: 18, gender: :female },
{ age: 19, gender: :male },
{ age: 64, gender: :female },
{ age: 65, gender: :male },
] }
let(:customers_group_discount_applied) {
customers_group_discount_not_applied.push({ age: 50, gender: :female })
}
context 'when entry time is before 16:00' do
let(:entry_hour) { 16 }
context 'When entry_date is wednesday' do
let(:entry_date) { wednesday }
context 'When group discount is applied' do
let(:customers) { customers_group_discount_applied }
it { is_expected.to eq(6200) }
end
context 'When group discount is not applied' do
it { is_expected.to eq(6400) }
end
end
context 'When entry_date is not wednesday' do
let(:entry_date) { tuesday }
context 'When group discount is applied' do
let(:customers) { customers_group_discount_applied }
it { is_expected.to eq(6560) }
end
context 'When group discount is not applied' do
it { is_expected.to eq(6700) }
end
end
end
context 'when entry time is after 17:00' do
let(:entry_hour) { 18 }
context 'when it is wednesday' do
let(:entry_date) { wednesday }
context 'When group discount is applied' do
let(:customers) { customers_group_discount_applied }
it { is_expected.to eq(4960) }
end
context 'When group discount is not applied' do
it { is_expected.to eq(5120) }
end
end
context 'when it is not wednesday' do
let(:entry_date) { tuesday }
context 'When group discount is applied' do
let(:customers) { customers_group_discount_applied }
it { is_expected.to eq(5248) }
end
context 'When group discount is not applied' do
it { is_expected.to eq(5360) }
end
end
end
end
end
対策
RuboCopのRSpec/NestedGroupsで最大階層をきめるたり、深い階層を作りすぎないようにするために、RSpec::Parameterizedを使用するといいかもしれません。****
contextを浅くした例
require_relative 'movie_ticket_price'
require "date"
require 'rspec-parameterized'
RSpec.describe MovieTicketPrice do
using RSpec::Parameterized::TableSyntax
describe '#calculate_fee' do
subject do
movie_ticket_price = MovieTicketPrice.new
movie_ticket_price.calculate_fee(customers, entry_hour, entry_date)
end
let(:wednesday) { Date.new(2024, 12, 11) }
let(:tuesday) { Date.new(2024, 12, 10) }
let(:customers_group_discount_not_applied) { [
{ age: 12, gender: :female },
{ age: 13, gender: :male },
{ age: 18, gender: :female },
{ age: 19, gender: :male },
{ age: 64, gender: :female },
{ age: 65, gender: :male },
] }
let(:customers_group_discount_applied) {
customers_group_discount_not_applied.push({ age: 50, gender: :female })
}
where(:customers, :entry_hour, :entry_date, :expected_fee, :test_case_name) do
ref(:customers_group_discount_applied) | 16 | ref(:wednesday) | 6200 | 'When entry time is before 16:00 and entry_date is wednesday and group discount is applied'
ref(:customers_group_discount_not_applied) | 16 | ref(:wednesday) | 6400 | 'When entry time is before 16:00 and entry_date is wednesday and group discount is not applied'
ref(:customers_group_discount_applied) | 16 | ref(:tuesday) | 6560 | 'When entry time is before 16:00 and entry_date is not wednesday and group discount is applied'
ref(:customers_group_discount_not_applied) | 16 | ref(:tuesday) | 6700 | 'When entry time is before 16:00 and entry_date is not wednesday and group discount is not applied'
ref(:customers_group_discount_applied) | 17 | ref(:wednesday) | 4960 | 'When entry time is after 17:00 and entry_date is wednesday and group discount is applied'
ref(:customers_group_discount_not_applied) | 17 | ref(:wednesday) | 5120 | 'When entry time is after 17:00 and entry_date is wednesday and group discount is not applied'
ref(:customers_group_discount_applied) | 17 | ref(:tuesday) | 5248 | 'When entry time is after 17:00 and entry_date is not wednesday and group discount is applied'
ref(:customers_group_discount_not_applied) | 17 | ref(:tuesday) | 5360 | 'When entry time is after 17:00 and entry_date is not wednesday and group discount is not applied'
end
with_them do
it { is_expected.to eq(expected_fee) }
end
end
end
あるある2: テストコードの品質基準がカバレッジだけになりがち
詳細
カバレッジの数値が高くても、実際にテストコードの品質が良いかどうかはわかりません。
しかし、カバレッジは簡単に定量的に計測できるため、「カバレッジが高いかどうか?」だけでテストコードの品質を議論してしまうことがよくあります。
『単体テストの考え方/使い方』p24-25では、次のように述べられています。
●網羅率 (coverage) はテスト・スイートの質が悪いことを評価できるが、良いことを評価できない。つまり、網羅率が低ければ、計測対象のテスト・スイートは十分なテストを行っていないことを示すことになるが、その逆に、網羅率が高かったとしても、テスト・スイートの質が良いと自動 的に評価されるわけではない。
●分岐網羅率(branch coverage)はテスト・スイートの網羅性についてコード網羅率(code coverage)より正確な結果を返すが、テスト・スイートの質が高いことを評価するものにはならない。こうなる理由は、分岐網羅率はテスト・ケースが何も確認していない場合であっても高い網羅率を出せるからであり、さらに、使われているライブラリの中身が計測対象から外れているからである。
またp21には以下の様に述べられています。
たとえば、病院に入院している患者のことを想像してください。そして、その患者の体温を計測した際、もし、計測した結果の体温が高ければ、その患者が発熱していることが分かります。これは適切な体温の観察です。しかしながら、このとき、病院が患者の体温を特定の数値にすることを目標にしていると、間違った治療が行われることになります。たとえば、患者の横にクーラーを設置して、皮膚上の温度が目的の温度になるまで冷たい風を浴びせ続けることで、目標の体温にするようなことです。しかも、もし、特定の体温にすることが目標であるのならば、この方法は迅速で効果的な治療として見ることができます。しかしながら、この方法はもちろん間違っており、患者を治療していることにはなりません。
このことから、カバレッジは「足りない部分」を発見するための指標としては有用です。しかし、カバレッジの数値だけにこだわりすぎると、開発全体に悪影響を及ぼすこともあります。
つまり、カバレッジの数値そのものを目的化してしまうと、本来の目標である「テストの質を向上させること」から外れてしまうのです。
また、『単体テストの考え方/使い方 p20』ではさらにこう述べています。
テスト・スイートの質がいかに優れているかを自動的に評価できる方法はない。
はて、どうした者かなという感じですね。
対策
カバレッジ以外にテストコードの品質を定量的に判断することができないので、正直これと言って良い対策のアイデアは現時点で浮かんでいません。
「カバレッジが低ければ高くするように努力していこう、カバレッジが高ければ、高くても問題がある可能性がある」点をつたえること、以下の教科書的な解答を進めていく、とかですかね...
- テストすることが開発サイクルの中に組み込まれている
- コードベースの特に重要な部分のみがテスト対象となっている
- ビジネスロジックを含む部分(ドメインモデル)が最も効果的なテスト
- 第2部で、ドメインモデルをアプリケーションの他の関心ごとから隔離する方法を説明する
- 最小限の保守コストで最大限の価値を生み出すようになっている
- 価値があるテストケースを認識できること
- 価値があるテストケースを作成できること
あるある3: テストコードの設計や上手な書き方は軽視されがち
詳細
プロダクションコードは設計や上手な書き方を熟考すると思います。そのための技術書やWebの記事も多いと思います。
しかし、テストコードの設計はあまり検討されないのではないでしょうか?どちらかというと、テストとして成り立っているかどうかまでの確認で終わりが多いかな~と思っています。
テストコードもコードである以上、品質も大事ではないでしょうか。
対策
良い書き方としてAAA(Arrenge-Act-Assert)は意識してみるといいかもしれません。簡単なサンプルです。
サンプルコード
サンプル: プロダクションコード
class ShoppingCart
def initialize
@items = []
end
def add_item(item, price)
@items << { item: item, price: price }
end
def total_price
@items.sum { |item| item[:price] }
end
def discounted_price(discount_rate)
total_price * (1 - discount_rate)
end
end
サンプル: 悪い例
RSpec.describe ShoppingCart do
subject(:cart) { ShoppingCart.new }
it 'calculates the discounted_price' do
cart.add_item('Apple', 100)
expect(cart.discounted_price(0.5)).to eq(50)
cart.add_item('Banana', 200)
expect(cart.discounted_price(0.5)).to eq(150)
end
end
サンプル: 良い例
RSpec.describe ShoppingCart do
subject(:cart) { ShoppingCart.new }
describe '#discounted_price' do
context 'when items are added to the cart' do
it 'returns the discounted_price of all items' do
# Arrange: テストデータを準備
cart.add_item('Apple', 100)
cart.add_item('Banana', 200)
# Act: 検証対象のメソッドを実行
result = order.discounted_price(0.5)
# Assert: 結果を検証
expect(result).to eq(150)
end
end
end
end
AAA以外は、私自身も良いテストコードの書き方が自分もわかってないです。
JSTQB AL テスト自動化エンジニアのシラバスの24ページには、SOLIDの原則も意識しましょう、といったこと書いてます。
しかし、これを上手く使ってテストコードの設計を検討できるイメージを持てていません。もしだれかイメージがあれば教えてほしいです。
あるある4: ユニットテスト(単体テスト)とインテグレーションテスト(結合テスト)の境界があいまいのまま
詳細
単体テストの定義は1クラス?それとも1つふるまい?
データベースに接続するのは単体テスト?結合テスト?
コントローラーや複数のクラスを管理するようなサービスクラスは単体テスト?結合テスト?
などなど、あいまいになってませんか?
そして、モックなどのテストダブルを使いすぎて偽陰性が発生してたり、
多くのテストテスト観点をコントローラーレベルでテストしようとして処理時間が遅なったりしてませんか?
対策
世界共通で「これだ!」と決まったテストレベルはないと思うので、「俺たちのチームが考えるテストレベルはこうだ!」というのを決めると良いかもしれません。
またはGoogleのテストサイズという考え方を参考にして見るのもいいかもしれません。
あるある5: テストデータの作り方が雑になりがち
詳細
テストデータの変数名に1~5といった数字を使いがちではないですか?例えばuser1、user2、user3といった感じです。
プロダクションコードでは変数名にこのような数字を使うことはほとんどないのに、テストコードでは使われがちです。
さらに、作成されたテストデータも、ほぼ同じ内容で差異がほとんどなかったり、実際のユーザーが作りそうなデータとかけ離れていることも少なくありませんか?
たとえば、退会済みのユーザーや特殊な条件のユーザーを意識せず、全てログイン済みのアクティブなユーザーを前提にしている場合などです。
対策
どういったテストデータを作るのか、あまり考えていないからかもしれないですね。
そのため、変数名でデータの意味を表現することで、テストコードを読む人が直感的にデータの役割を理解できるといいかもしれません。
例:
ログイン済みのユーザー: active_user
退会済みのユーザー: deleted_user
管理者ユーザー: admin_user
また、テストデータを実際に使われそうなデータにすることで、変数名で表現しやすくなるかもしれません。
そうして書いた方がテストコードの期待値だけを見てわかりやすくなるメリットもあると思います。
Discussion