この記事について
単体テストにおいてテスト対象のクラスとテストクラスの関係は基本的に1対1にすることが多いと思いますが、その対応関係とトレードオフについての個人的な考察です。(考察というよりもメモに近いかもしれません)
簡単な計算機の例
足し算、引き算、掛け算を行うことができる計算機を例に2つのパターンの実装を考えてみます。
言語はPHPを想定しています。
2つの実装パターン
ひとつのパターンは、1つのクラスの中にプライベートメソッドとしてそれぞれの処理を持つ例です。
ひとつのパターンは、Strategyパターンを適用した例です。
この2つは実装の方法は違えど、Calculator
に期待される振る舞いは同じです。
ちなみにOperation
は列挙型です。
enum Operation: string{
case Plus = '+';
case Minus = '-';
case Times = '*';
}
実装パターンに対するユニットテスト
まずは1つのクラスで実装したパターンに対するテストです。Calculator
クラスに1対1で対応するCalculatorTest
クラスにテストを書いています。
class CalculatorTest extends TestCase
{
private Calculator $sut;
protected function setUp(): void
{
$this->sut = new Calculator();
}
public function testPlus(): void
{
$this->assertSame(3, $this->sut->calculation(Operation::Plus, 1, 2));
$this->assertSame(7, $this->sut->calculation(Operation::Plus, 3, 4));
}
public function testMinus(): void
{
$this->assertSame(1, $this->sut->calculation(Operation::Minus, 3, 2));
$this->assertSame(-3, $this->sut->calculation(Operation::Minus, 7, 10));
}
public function testTimes(): void
{
$this->assertSame(6, $this->sut->calculation(Operation::Times, 2, 3));
$this->assertSame(21, $this->sut->calculation(Operation::Times, 3, 7));
}
}
こちらのパターンのテストについて特に異論は無いと思います。(細かいテストケースの妥当性についてはご容赦ください)
次にStrategyパターンを適用したケースについて見てみましょう。こちらも1対1にする慣例に従って書いています。
class CalculatorTest extends TestCase
{
private Calculator $sut;
protected function setUp(): void
{
$this->sut = new Calculator();
}
public function testCalculation(): void
{
$this->assertSame(3, $this->sut->calculation(Operation::Plus, 1, 2));
$this->assertSame(1, $this->sut->calculation(Operation::Minus, 3, 2));
$this->assertSame(6, $this->sut->calculation(Operation::Times, 2, 3));
}
}
それぞれのStrategyクラスにもテストを書きます。例としてPlusStrategy
クラスのテストを記載します。
class PlusStrategyTest extends TestCase
{
public function testCalculation(): void
{
$sut = new PlusStrategy();
$this->assertSame(3, $sut->calculation(1, 2));
$this->assertSame(7, $sut->calculation(3, 4));
}
}
1対1に従ってテストを書くとこの様になりました。CalculatorTest
クラスでは3つ演算ができるかテストし、3つのStrategyクラスではそれぞれの演算についての詳細をテストしています。
このように実装によってテストが変わってしまっていますが、ここで期待される振る舞いは同じという点に注目します。
1つのクラスで実装したパターンからStrategyパターンを適用したとすれば、振る舞いは変わらないのでリファクタリングです。リファクタリングであればテストはそのままで、リファクタリングの後も引き続きテストが通ることを確認したいはずです。例えばStrategyパターンを適用してから、また元に戻したり、あるいは別のパターンを適用してリファクタリングしたとすれば、テストが壊れてしまうことになります。
これはprivateメソッドをテストしているのと同じような状態であり、実装の詳細に踏み込んだテストであると考えます。そして、それによって「リファクタリングへの耐性1」が低下した状態になっています。
トレードオフを整理する
さて、先程の例では「1対1の対応関係にこだわるとリファクタリングへの耐性が低下し、テストが壊れやすくなる」という点が出てきました。
では先程のようにStrategyパターンのそれぞれのクラスに対してテストを書くのは避けるべきでしょうか?これもそうとは言えないかもしれません。先程のコードはとてもシンプルな例ですが、より複雑になったケースではどうでしょうか?振る舞いを提供するクラスのテストが膨大になるかもしれませんし、詳細に対してテストを書いていきたいかもしれません。(PHP言語はテストの構造化が難しい・・・)
このあたりは引き続き考察していきたいと思います。
まとめ
はっきりとした結論には至りませんでしたが、少なくとも、慣例に従い1対1でテストを書く前にリファクタリングへの耐性(テストの壊れやすさ)について考える必要がありそうです。
参考
『Clean Craftsmanship 規律、基準、倫理』 p.154 テスト設計
『単体テストの考え方/使い方』 p.96 良い単体テストを構成する4本の柱
-
『単体テストの考え方/使い方』 ↩