単体テストは「将来の開発速度を維持する投資」につきます。しかし現場では、テストコードが逆に保守の重荷となり、小さな修正にも数日かかるケースは珍しくありません。多くの現場での根本原因は「テストを複雑」ということが結構あります。
単体テストをシンプルに保ちながら品質と開発効率を両立するために思っていることや気をつけることを書いていきます。
そもそもビジネスロジックは複雑
実際のビジネスロジックは、組織のルール、状態遷移、既存システムとの整合性など、 本質的に複雑な要素を内包しています。そのため、テスト側まで過度に詳細や依存関係を固定化させると、「何を確認したいテストか」が置き去りにされ、テストコードの読み解きおよび修正コストが急増します。
重要なのは、ビジネスの意図を明確に分離し、テストは「入力 → 期待される出力/状態変化」へのシンプルなマッピングに徹することです。過度なモックや偽データの積み重ねは避け、現実のビジネス要件に即した核心的ケースに絞りましょう。テストが複雑になる時ほど、まずは「本当にその詳細まで確認必要があるか?」を自問することをお勧めします。
APIは複雑でない方が良い
テストが複雑になる根本原因は、往々にしてテスト対象のAPI(公共インターフェースやメソッド)自体が複雑になっていることにあります。「テストを書くのが難しいコード」は、そもそも設計の見直しが必要なサインです。
APIをシンプルに保つことで、テストのセットアップが軽くなり、失敗時のデバッグも容易になります。もし現在のAPIをテストしにくいと感じたなら、フレームワークの工夫やテストコード側で辛さを吸収するより先に「責務の分割」や「パラメータ設計の見直し」を実行しましょう。テストはコードを悪くする言い訳になってはいけません。シンプルで呼び出しやすいAPIは、テストに限らず利用者全員にとって資産です。
パラメータテストで網羅的にテストをする
網羅的にテストをする場合は、手動で複数のテストケースを列挙するより「パラメータ化された単体テスト(Parameterized Tests)」を活用すべきです。
冗長なテストコードは、ビジネスロジックと同じく扱いづらいものになります。
現代のテストフレームワークでは、入力値と期待値の組み合わせをデータ駆動で定義でき、DRY原則が自然に守られます。境界値やエラーケースを体系的にテストできる上、新しい要件や追加仕様が生まれてもデータフローだけでカバレッジが拡張可能です。複雑なロジックを単純なテストケースの集合でカバーするには、パラメータテストが最も効果的かつ保守性の高い武器になります。
不要になったテストコードは削除する
「テストは書けば終わり」ではありません。リファクタリングや機能変更、仕様の削除により、かつて有効だったテストが「死んだテスト」と化すことは日常的に起こり得ます。
残った不要なテストは、CIの実行時間を無駄にし、失敗レポートを肥大化させ、「テストが通っている=安全」という誤った安心感を生みます。加えて、次の開発者が意味を推測したまま修正を加え、バグの温床になるリスクもあります。テストコードも成果物と同じく、定期的にレビューし、不要になれば潔く削除しましょう。テスト実行時間の計測やCIフィルタを活用し、「実行する価値のあるテスト」だけが残る環境を目指すべきです。
テストコードが書かれていると、不要なコードを削除しやすくはなると思います。不要なコードは積極的に削除していきましょう。もちろん削除するときにテストもしましょう。
まとめ
単体テストは「品質の盾」ではなく、「開発を加速するための潤滑油」であるべきです。
ビジネスロジックは複雑になっていくとは思います。我々プログラマが扱える変数であるAPIやテストコードをなるべくシンプルに保つのは大切なことです。
ビジネスロジックの複雑さを受け入れつつAPIはシンプルに保ち、パラメータテストで体系的にカバーし、不要なコードは躊躇なく削除する。この循環を回すだけで、テスト資産は「負債」から「投資」へと変わり続けます。シンプルさこそが、長期的なサービス開発の中で生産性を維持する武器としてシンプルなユニットテストを育てて行ければと思います。
Appendix.
サンプルコード
最近のJavaでは、JUnit5でかなり単体テストが書きやすくなりました。@Nested,@ParameterizedTest,@RepeatedTest,@TestFactory,@DisplayName,@TestInstanceの実用的なアノテーションが追加され、特にパラメータライズドテストなどは使いやすいです。下記サンプルを書いておきましたので、
JUnit5 主要アノテーションのサンプル
テスト対象: 簡単な電卓クラス Calculator
@DisplayName("Calculator のテスト")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CalculatorTest {
private Calculator calculator;
private int repeatCounter = 0;
@BeforeAll
void setUpAll() {
System.out.println("=== テスト全体の開始 ===");
}
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Test
@DisplayName("1 + 1 は 2 になること")
void testAddition() {
assertEquals(2, calculator.add(1, 1));
}
@DisplayName("加算のパラメータ化テスト")
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"-1, 1, 0",
"0, 0, 0"
})
void testAdditionParameterized(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
@DisplayName("正の数の判定テスト")
@ParameterizedTest(name = "{0} は正の数")
@ValueSource(ints = {1, 5, 100})
void testIsPositive(int value) {
assertTrue(calculator.isPositive(value));
}
@DisplayName("カウンターのインクリメントを5回繰り返す")
@RepeatedTest(5)
void testIncrementRepeatedly(RepetitionInfo repetitionInfo) {
repeatCounter++;
System.out.printf("実行回数: %d / %d%n",
repetitionInfo.getCurrentRepetition(),
repetitionInfo.getTotalRepetitions());
assertEquals(repetitionInfo.getCurrentRepetition(), repeatCounter);
}
@DisplayName("動的に生成する減算テスト")
@TestFactory
Collection<DynamicTest> testSubtractionDynamic() {
List<DynamicTest> tests = new ArrayList<>();
int[][] cases = {{5, 3, 2}, {10, 4, 6}, {0, 0, 0}};
for (int[] c : cases) {
String name = String.format("%d - %d = %d", c[0], c[1], c[2]);
Executable executable = () -> assertEquals(c[2], calculator.subtract(c[0], c[1]));
tests.add(dynamicTest(name, executable));
}
return tests;
}
@Nested
@DisplayName("除算に関するテスト")
class DivisionTests {
@Test
@DisplayName("正常な除算ができること")
void testDivision() {
assertEquals(2, calculator.divide(10, 5));
}
@Test
@DisplayName("0で割ると例外がスローされること")
void testDivisionByZero() {
assertThrows(ArithmeticException.class, () -> calculator.divide(1, 0));
}
@Nested
@DisplayName("小数を扱う除算")
class DecimalDivisionTests {
@Test
@DisplayName("割り切れない場合も計算できること")
void testNonExactDivision() {
assertEquals(3, calculator.divide(10, 3));
}
}
}
@AfterAll
void tearDownAll() {
System.out.println("=== テスト全体の終了 ===");
}
}
テスト対象のシンプルな電卓クラス
class Calculator {
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}
int divide(int a, int b) {
return a / b;
}
boolean isPositive(int value) {
return value > 0;
}
}
@TestFactoryは利用が少し難しいんですけど、それ以外は割と直感的に使えると思うので、積極的に使っていくのが良いと思います。