私的テスト考え方まとめ
あり得るすべてのテストケースをテストするべき
あり得るすべてのテストケースをテストすることは不可能である
したがって、意味を損なわない範囲でテストケースを絞り、それをすべてチェックする必要がある
同値クラス・境界値分析
また、全通り探索のテストを手書きするのはツラいし読むのも難しいので、適切にテストコードを圧縮する必要がある
パラメタライズドテスト
あり得るすべてのテストケースをテストするべき
以下のような関数を考える
code:ruby
def hohho(hoge, fuga)
if hoge || fuga
return 'success'
else
return 'failure'
end
end
この関数の条件分岐に関しては、hoge が true / false のときと fuga が true / false のときがあるので、2 通り x 2 通り の 4通りのテストケースがある
そのため、テストは以下の4通りあればよい
code:ruby
expect(hohho(true, true)).to eq 'success'
expect(hohho(true, false)).to eq 'success'
expect(hohho(false, true)).to eq 'success'
expect(hohho(false, false)).to eq 'failure'
このように、あり得るすべての入力をテストするのが原則である
上記ですべてのテストケースを網羅できているというのは嘘だが、その話題は次で扱う
あり得るすべてのテストケースをテストすることは不可能である
以下のような関数を考える
code:ruby
def hohho(hoge, fuga)
if hoge = 100 || fuga = 200
return 'success'
else
return 'failure'
end
end
この関数のテストケースについて、真面目に考えてみると...
そもそも hoge や fuga の型は決まってないので、それぞれ任意の値が渡され得る
そのため、テストケースは無限個考えられる
型を int に限定したとしても、Ruby の int は大きさに上限がないので、やはり無限個のテストケースが考えられる
これだとテストが出来ないので、うまい具合にテストケースを絞る必要がある
全通り探索を妥協する方法:同値分割
以下の関数を考える(ひとつ上と同じ)
code:ruby
def hohho(hoge, fuga)
if hoge = 100 || fuga = 200
return 'success'
else
return 'failure'
end
end
このコードをよく見ると、hoge に関する条件分岐は「100 であるかそうでないか」、fuga に関する条件分岐は「200 であるかそうでないか」という意味しか持たない
つまり、hoge が nil であろうが "100" であろうが 200 であろうが違いがない
そのため、これらのうちどれかについて1通りだけテストをすれば、これらのカテゴリーの値についてのテストは完了したとみなしても良い
つまり、hoge については、その値が 「100 のケース」と「100 でないケース」をテストすれば良い
例えば hoge = 100 と 101、hoge = 100 と nil、など
fuga についても同じことが言えるので、2 x 2 = 計4通りのテストをすれば良い
もう一つ例を出す
code:ruby
def hohho(hoge, fuga)
if hoge < 100 || (200 < fuga && fuga < 300)
return 'success'
else
return 'failure'
end
end
このコードについての「分類」は、以下のようになる
hoge: 100未満 / 100以上
fuga: 200未満 / 200以上かつ300未満 / 300以上
したがって、hoge 2通り x fuga 3通り の計6通り をテストすれば良い
この「分類」を同値クラスという
たとえば、hoge は 100未満 と 100以上 という2つの同値クラスを持つし、fuga は 200未満 と 200以上かつ300未満 と 300以上 という3つの同値クラス を持つ
...
テストを書く時、多くのプログラマは同値分割を無意識のうちに行っているはず
しかし、「同値分割を行った上で全てのテストケースをチェックする」というところまでやっているプログラマはそれほど多くないはず
「同値分割」をするのは「全探索」をするためなので、全探索しましょう
全通りのテストを書くなんて労力が見合わねえ or そんなの無理と思ったあなたは次の節を読んでください
テストケースを気軽に増やす方法:パラメタライズドテスト
code:ruby
def hohho(hoge, fuga)
if hoge < 100 || (200 < fuga && fuga < 300)
return 'success'
else
return 'failure'
end
end
この関数を全探索するには、2 x 3 = 6通りのテストケースが必要であると書いた
code:ruby
expect(hohho(50, 100)).to eq 'success'
expect(hohho(50, 250)).to eq 'success'
expect(hohho(50, 350)).to eq 'success'
expect(hohho(150, 100)).to eq 'failed'
expect(hohho(150, 250)).to eq 'success'
expect(hohho(150, 350)).to eq 'failed'
6通りもテストケースを書くのはダルいと思ったときはパラメタライズドテストをおこなう
例えばこういう感じ
code:ruby
for fuga in 100, 250, 350 # 説明のための嘘 ruby なので注意
expect(hohho(50, fuga)).to eq 'success'
end
expect(hohho(150, 100)).to eq 'failed'
expect(hohho(150, 250)).to eq 'success'
expect(hohho(150, 350)).to eq 'failed'
上3つのテストケースは、fuga の値に関わらず返り値が一緒なので、for 文でくくりだすことができる
動作が変わらないのであっても、fuga を 3パターンテストすることは必要である。なぜなら、「動作が変わらないこと」のテストは必要だからだ。
この例では、fuga が3つしかないことと、hohho は事前条件の整備が不要な関数であることから、あまりありがたみを感じられないかもしれない
fuga が10個あったり、hohho が hoge, fuga, piyo の3つの引数を取るようなケースを考えるとありがたみが分かる
パラメタライズドテストのポイントは、自然な境界でテストケースを分割することにあると思う
上の例で、最後の3つのテストケースを for 文にくくりだしていないのは意図的である
例えば以下のようにするとすべてを for 文の中に押し込めるが、その後には破滅が待っている
code:ruby
for hoge in 50, 150 # 説明のための嘘 ruby なので注意
for fuga in 100, 250, 350
if hoge == 150 && (fuga == 100 || fuga == 350)
expect(hohho(hoge, fuga)).to eq 'failed'
else
expect(hohho(hoge, fuga)).to eq 'success'
end
end
このテストは、 hoge == 150 && (fuga == 100 || fuga == 350) という条件分岐が正しいことがテストされていないのでバグっている可能性があり、テストとして破綻している
もちろんベタ書きしているときだってバグってた可能性はあったわけだが、こちらのほうがよりバグる可能性が高い
なお、上記の例は、テストが落ちたときにどのパラメータでダメだったのかわからない、という欠点を抱えている
少なくとも rspec では、テストが落ちたときの変数名が dump されたりはしない
実用的には以下のように、context を生成するようにするのが良いだろう
code:ruby
for fuga in 100, 250, 350 # 説明のための嘘 ruby なので注意
context "when hoge = 50, fuga = #{fuga}" do
it "returns 'success'" do
# scope の関係上、ruby では本来ここで fuga は読めないのだが、let をうまく使えば読めるようになる。
# 具体的な方法は読者の演習問題とする。
expect(hohho(50, fuga)).to eq 'success'
end
end
end
このようにすることで、テストが落ちたときに "when hoge = 50, fuga = 100" が落ちた、ということがすぐわかる
see: rspec によるパラメタライズドテスト
テストケースを減らす方法を2つ
①条件分岐を減らす
条件分岐を一つ増やすと最低でもテストケースが2倍に増えるのでやめたほうがいい
仕様を考え直したり、関数の切り方を変えたりすることで、条件分岐は意外に簡単に減らせる
まずはこれを試みるべき
②単体テストと統合テストを区別する
あり得るすべてのパターンを愚直にテストしていくと、どこかで限界が来る
code:ruby
def hohho(hoge, fuga)
if hoge < 100 || (200 < fuga || fuga < 300)
return 'success'
else
return 'failure'
end
end
def piyoo(hoge, fuga, monyo)
if hohho(hoge, fuga) == 'success' && monyo
return 'hohho succeeded'
else
return 'hohho failed'
end
end
こういうコードがあった時、愚直にやると piyoo については、計8通りのテストケースをチェックする必要がある
hohho の振る舞いを全通り探索するための hoge / fuga の組み合わせ 4通り
monyo の値(true/false) 2通り
4 x 2 = 8通り
指数的にテストケースが増えるので、どこかで不可能になる
これを解決する鍵が「単体テスト」と「統合テスト」
単体テストでは、あり得るすべての条件分岐をチェックする
統合テストでは、利用するコンポーネント(クラス・関数など)が仕様どおりに動く(バグがない)ことを前提に、必要な部分だけをテストする
具体例
hohho は単体テストを行い、4通りをチェックする
piyoo は統合テストを行い、以下の計4通りをテストする
hohho が success を返すパターン と failed を返すパターン の 2通り
適当に hoge = 50 と hoge = 150 (fuga はなんでもいい) の2通りでよい
monyo が true の場合と false の場合の 2通り
統合テストのテストケースが8通りから4通りに削減されている
代わりに単体テストを4通りテストしているが、hohho を使う他の関数については hohho の単体テストをしなくて良いので、スケーラビリティがある
このように、コンポーネントの階層が深くなっても、テストケースの数が組合せ爆発を起こさない
テストの精度を上げる方法:境界値分析
一般に境界値はバグの温床なので、ここを重点的にテストするとバグの発見確率が高まる
code:ruby
def hohho(hoge, fuga)
if hoge < 100 || (200 < fuga || fuga < 300)
return 'success'
else
return 'failure'
end
この関数は、hoge について2つの同値クラス、fuga について3つの同値クラスに分割できるのだった
境界値とは、同値クラス間の「境界」を指す
例えば hoge なら 100 が境界だし、fuga なら 200 と 300 が境界
超ざっくりいうと、境界値分析とは、各境界値について3通りのテストケースを作ることである
境界値そのもの
境界値の「左」の値
境界値の「右」の値
例えば hoge と fuga については以下のテストケースをチェックする。3 x 6 = 18 通りである。
hoge → 99, 100, 101
fuga → 199, 200, 201, 299, 300, 301
テストケースが超増えるので、パラメタライズドテストをうまく使ってコードを圧縮しないとすぐに死ねる
advanced な手法
個人的には、同値分割・境界値分析・パラメタライズドテストが出来てれば、だいたいいい感じのテストが書けると思っている
もっと詳しく知りたいという人は、この本がオススメなので買って読んでください
特に境界値分析のあたりはかなり雑に解釈して書いてるので、本にあたったほうが良いと思います
https://dawn.hateblo.jp/entry/2021/02/28/192026