単体テストとCI/CD自動化ガイド
目次
1. 単体テストの基本概念
1.1 単体テストとは
単体テスト(Unit Testing)とは、ソフトウェア開発における最小単位のテストで、個々のモジュール、関数、クラスなどが正しく動作することを確認するテスト手法です。
単体テストの特徴:
- コードの最小単位(通常は関数やメソッド)をテスト対象とする
- 外部依存(データベース、ファイルシステム、API等)を分離して実施
- 自動化が可能で繰り返し実行できる
- 開発の早い段階で問題を発見できる
1.2 単体テストの目的
- バグの早期発見:機能実装直後にテストを書き、実行することで、バグを早期に発見できます
- リグレッションの防止:既存の機能に影響を与えずに新機能を追加できることを確認できます
- 設計の改善:テストしやすいコードを書くことで、結合度を下げ、凝集度を高める設計が促進されます
- ドキュメントとしての役割:テストコードは、プロダクションコードがどのように動作すべきかを示す生きたドキュメントになります
- 安全なリファクタリング:テストがあることで、コードを安全に改善できます
1.3 テスト駆動開発(TDD)
テスト駆動開発(Test-Driven Development)は、単体テストを中心とした開発手法です。
TDDのサイクル(Red-Green-Refactor):
- Red:最初に失敗するテストを書く
- Green:テストが通るように最小限のコードを実装する
- Refactor:テストが通ることを確認しながらコードをリファクタリングする
1.4 単体テストの基本原則
- 高速性(Fast):テストは素早く実行できること
- 独立性(Isolated/Independent):テストは他のテストに依存せず、どの順番で実行しても同じ結果になること
- 繰り返し可能(Repeatable):何度実行しても同じ結果が得られること
- 自己検証(Self-validating):テスト結果は自動的に判定され、手動確認が不要であること
- 適時性(Timely):テスト対象のコードを書く前か直後にテストを書くこと
これらの頭文字を取って「FIRST」原則とも呼ばれます。
2. 単体テストの観点と判断基準
2.1 テスト観点
単体テストを設計する際の主な観点は以下の通りです:
-
正常系テスト
- 正常な入力に対して期待通りの結果を返すか
- 境界値(最小値、最大値など)での正常動作
- パフォーマンス要件を満たすか
-
異常系テスト
- 無効な入力に対して適切なエラー処理が行われるか
- 境界外の値に対する動作
- 例外処理の検証
-
エッジケース
- null値、空の配列・文字列
- 整数の最大値・最小値
- 日付の境界(閏年など)
- 極端に大きいデータ、小さいデータ
-
ビジネスロジック
- 業務要件に基づいた条件分岐
- 複雑な計算ロジック
- ステータス変更の検証
2.2 テスト判断基準
2.2.1 テストカバレッジ
テストカバレッジは、テストによってどれだけのコードが実行されたかを示す指標です。業界では以下のような表記も使われます:
主なカバレッジメトリクス:
-
ライン(行)カバレッジ (C0)
- テストによって実行されたコードの行数の割合
- 一般的な目標値:70-80%以上
- 「ステートメントカバレッジ」とも呼ばれます
具体例:
public int calculateDiscount(int price, boolean isPremiumCustomer) { int discount = 0; // この行は実行された if (isPremiumCustomer) { discount = price * 20 / 100; // テストでisPremiumCustomer=trueの場合のみこの行が実行された } else { discount = price * 10 / 100; // テストでisPremiumCustomer=falseの場合のみこの行が実行された } return discount; // この行は実行された }テストケース1: calculateDiscount(1000, true) → 200が返る
テストケース2: calculateDiscount(1000, false) → 100が返るこの場合、すべての行が少なくとも1回実行されているため、ラインカバレッジは100%です。
-
分岐カバレッジ (C1)
- if文やswitch文などの条件分岐がテストで網羅されている割合
- 一般的な目標値:80%以上
- 「ブランチカバレッジ」とも呼ばれます
具体例:
public String checkTemperature(int temp) { if (temp < 0) { return "凍結注意"; // 分岐1 } else if (temp < 15) { return "肌寒い"; // 分岐2 } else if (temp < 25) { return "快適"; // 分岐3 } else { return "暑い"; // 分岐4 } }テストケース1: checkTemperature(-5) → "凍結注意"が返る
テストケース2: checkTemperature(20) → "快適"が返るこの場合、4つの分岐のうち2つしかテストされていないため、分岐カバレッジは50%です。
-
条件カバレッジ (C2)
- 複合条件(AND、OR)の各部分がテストで評価されている割合
- 例:
if (a && b)の場合、a=true/false、b=true/falseの組み合わせ
具体例:
public boolean isEligibleForDiscount(int age, boolean isStudent) { if (age < 18 || isStudent) { return true; } return false; }条件カバレッジで完全に網羅するには、以下の組み合わせをテストする必要があります:
- age < 18 が true、isStudent が true
- age < 18 が true、isStudent が false
- age < 18 が false、isStudent が true
- age < 18 が false、isStudent が false
テストケース1: isEligibleForDiscount(16, true) → trueが返る (age < 18 = true, isStudent = true)
テストケース2: isEligibleForDiscount(20, false) → falseが返る (age < 18 = false, isStudent = false)この場合、4つの条件組み合わせのうち2つしかテストされていないため、条件カバレッジは50%です。
-
パスカバレッジ (C3)
- プログラム内の可能なすべての実行パスが網羅されている割合
- 最も厳しいカバレッジ基準
具体例:
public String categorizeOrder(boolean isPriority, boolean isExpensive, boolean isInternational) { String category = "Standard"; if (isPriority) { category = "Priority"; } if (isExpensive) { category += " Valuable"; } if (isInternational) { category += " International"; } return category; }この関数には2³ = 8つの異なる実行パスがあります。パスカバレッジ100%を達成するには、すべてのパスをテストする必要があります。
2.2.2 テスト成功率
テスト成功率は、全テストケースのうち成功したテストの割合を示します。
業界標準の判断基準:
- 合格基準:100%(すべてのテストが成功)
- 許容基準:テスト失敗は許容されないのが原則
ただし、初期開発フェーズやレガシーコードのリファクタリング時には、段階的な改善を目標にすることもあります。
2.2.3 テスト品質の評価
カバレッジだけでなく、テスト自体の品質も重要です:
-
テストの独立性
- 各テストは他のテストに依存せず、単独で実行できるか
-
テストの信頼性
- 何度実行しても同じ結果が得られるか(フラキーテストがないか)
-
テストの可読性
- テストの意図が明確か
- テスト名が目的を表しているか
-
アサーションの品質
- 単一責任の原則に従っているか
- 適切なアサーションを使用しているか
-
テストの実行速度
- テストスイート全体が妥当な時間内に完了するか
2.3 モックとスタブの活用
外部依存がある場合、テスト対象を分離するためにモックやスタブを使用します:
-
スタブ(Stub)
- 外部依存の代わりに使用する単純な実装
- 特定の入力に対して決まった出力を返す
-
モック(Mock)
- 外部依存の代わりに使用する高度なテストダブル
- メソッド呼び出しの検証や動的な振る舞いの制御が可能
-
スパイ(Spy)
- 実際のオブジェクトの動作を記録する
- メソッド呼び出し回数や引数を検証できる
-
フェイク(Fake)
- 本物と同様の動作をする軽量な実装
- 例:インメモリデータベース
-
ダミー(Dummy)
- 単にパラメータを満たすためだけに使用するオブジェクト
- 実際には使用されない
3. JUnit活用ガイド
3.1 JUnitの概要
JUnitは、Javaプログラム用の単体テストフレームワークの代表的な存在です。JUnit 5は次の3つの主要コンポーネントで構成されています:
- JUnit Platform:テスト実行のための基盤
- JUnit Jupiter:JUnit 5のための新しいプログラミングモデルとエクステンション
- JUnit Vintage:JUnit 3および4との後方互換性のためのモジュール
3.2 JUnitの基本的な使い方
ここでは、もっと具体的なシナリオを想定してJUnitの基本的な使い方を説明します。
3.2.1 テストクラスとテストメソッド
例えば、オンラインショップの注文処理システムを開発していると想定します。このシステムには以下のような「OrderProcessor」クラスがあるとします:
public class OrderProcessor { // 注文を処理して割引を適用する public double calculateFinalPrice(Order order) { double price = order.getBasePrice(); // 5000円以上で5%割引 if (price >= 5000) { price = price * 0.95; } // プレミアム会員はさらに10%割引 if (order.getCustomer().isPremium()) { price = price * 0.9; } // 送料追加(北海道・沖縄は追加送料) String prefecture = order.getShippingAddress().getPrefecture(); if ("北海道".equals(prefecture) || "沖縄県".equals(prefecture)) { price += 500; } else { price += 300; } return price; } } このOrderProcessorクラスをテストするためのJUnitテストクラスを作成します:
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; import static org.junit.jupiter.api.Assertions.*; public class OrderProcessorTest { private OrderProcessor processor; private Customer regularCustomer; private Customer premiumCustomer; private Address tokyoAddress; private Address hokkaidoAddress; @BeforeEach void setUp() { // テストごとに新しいインスタンスを準備 processor = new OrderProcessor(); regularCustomer = new Customer("田中太郎", false); // 一般会員 premiumCustomer = new Customer("佐藤花子", true); // プレミアム会員 tokyoAddress = new Address("東京都", "新宿区", "1-1-1"); hokkaidoAddress = new Address("北海道", "札幌市", "2-2-2"); } @Test void testRegularCustomerWithSmallOrderInTokyo() { // 通常顧客が東京で3000円の注文を行うケース Order order = new Order(3000, regularCustomer, tokyoAddress); double finalPrice = processor.calculateFinalPrice(order); // 3000円 + 送料300円 = 3300円になるはず assertEquals(3300, finalPrice, "通常顧客の小額注文(東京)の計算が正しくありません"); } @Test void testPremiumCustomerWithLargeOrderInHokkaido() { // プレミアム顧客が北海道で10000円の注文を行うケース Order order = new Order(10000, premiumCustomer, hokkaidoAddress); double finalPrice = processor.calculateFinalPrice(order); // 10000円 - 5%割引 = 9500円 // 9500円 - 10%割引 = 8550円 // 8550円 + 送料500円 = 9050円になるはず assertEquals(9050, finalPrice, "プレミアム顧客の大口注文(北海道)の計算が正しくありません"); } } 3.2.2 テストライフサイクル
JUnitのテストライフサイクルを制御するアノテーション:
import org.junit.jupiter.api.*; public class LifecycleDemoTest { @BeforeAll static void setUpAll() { // テストクラス全体の前に1回だけ実行 System.out.println("BeforeAll"); } @BeforeEach void setUp() { // 各テストメソッドの前に実行 System.out.println("BeforeEach"); } @Test void testMethod1() { System.out.println("Test method 1"); } @Test void testMethod2() { System.out.println("Test method 2"); } @AfterEach void tearDown() { // 各テストメソッドの後に実行 System.out.println("AfterEach"); } @AfterAll static void tearDownAll() { // テストクラス全体の後に1回だけ実行 System.out.println("AfterAll"); } } 3.2.3 アサーション
JUnit Jupiterは多様なアサーションメソッドを提供しています:
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class AssertionsDemoTest { @Test void standardAssertions() { assertEquals(2, 1 + 1); // 値の比較 assertTrue(1 < 2); // 条件が真かどうか assertFalse(1 > 2); // 条件が偽かどうか } @Test void groupedAssertions() { // グループ化されたアサーション(すべて実行され、すべての失敗がレポートされる) assertAll("person", () -> assertEquals("John", person.getFirstName()), () -> assertEquals("Doe", person.getLastName()) ); } @Test void exceptionTesting() { // 例外発生の検証 Exception exception = assertThrows(ArithmeticException.class, () -> { int result = 1 / 0; }); assertEquals("/ by zero", exception.getMessage()); } @Test void timeoutNotExceeded() { // タイムアウトの検証 assertTimeout(Duration.ofMillis(100), () -> { // 100ミリ秒以内に完了する処理 }); } } 3.2.4 パラメータ化テスト
同じテストロジックで異なる入力値をテストする場合に便利です:
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; class ParameterizedTestsDemo { @ParameterizedTest @ValueSource(ints = {1, 2, 3}) void testWithValueSource(int argument) { assertTrue(argument > 0); } @ParameterizedTest @CsvSource({"1, 1, 2", "2, 3, 5", "5, 8, 13"}) void testAdd(int a, int b, int expected) { Calculator calculator = new Calculator(); assertEquals(expected, calculator.add(a, b)); } } 3.3 モックフレームワークの活用
外部依存を持つコードをテストするには、モックフレームワークを使って依存部分を置き換えることが重要です。これを実際のビジネスシナリオで見てみましょう。
3.3.1 外部APIに依存するケース
例えば、以下のような「PaymentService」クラスが外部の決済APIに依存しているとします:
public class PaymentService { private PaymentGateway paymentGateway; // 外部の決済API public PaymentService(PaymentGateway paymentGateway) { this.paymentGateway = paymentGateway; } // 注文の支払い処理を行う public PaymentResult processPayment(Order order, CreditCard card) { // 不正なカード番号をはじく if (!isValidCardNumber(card.getNumber())) { return new PaymentResult(false, "不正なカード番号です"); } // 決済金額を計算 double amount = order.getFinalPrice(); try { // 外部APIを呼び出して実際に決済処理 TransactionResult transaction = paymentGateway.charge(card, amount); if (transaction.isSuccessful()) { // 成功時:注文ステータスを「支払い完了」に更新 order.setStatus(OrderStatus.PAID); return new PaymentResult(true, "支払いが完了しました"); } else { // 失敗時:エラーメッセージを返す return new PaymentResult(false, transaction.getErrorMessage()); } } catch (PaymentException e) { // 例外発生時:システムエラーとして処理 return new PaymentResult(false, "システムエラーが発生しました: " + e.getMessage()); } } private boolean isValidCardNumber(String cardNumber) { // カード番号の検証ロジック(省略) return cardNumber != null && cardNumber.length() >= 14 && cardNumber.length() <= 16; } } 3.3.2 Mockitoを使ったテスト
このPaymentServiceをテストするには、外部APIを呼び出すPaymentGatewayをモック化します:
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class PaymentServiceTest { private PaymentService paymentService; private PaymentGateway mockGateway; private Order testOrder; private CreditCard validCard; private CreditCard invalidCard; @BeforeEach void setUp() { // モックの作成 mockGateway = mock(PaymentGateway.class); // テスト対象クラスにモックを注入 paymentService = new PaymentService(mockGateway); // テスト用のデータ準備 testOrder = new Order(); testOrder.setFinalPrice(5000); validCard = new CreditCard("1234567890123456", "12/25", "123"); invalidCard = new CreditCard("123", "12/25", "123"); // 短すぎる番号 } @Test void testSuccessfulPayment() { // モックの振る舞いを設定:決済成功のケース TransactionResult successResult = new TransactionResult(true, "承認番号: 123456"); when(mockGateway.charge(validCard, 5000)).thenReturn(successResult); // テスト実行 PaymentResult result = paymentService.processPayment(testOrder, validCard); // 検証 assertTrue(result.isSuccess()); assertEquals("支払いが完了しました", result.getMessage()); assertEquals(OrderStatus.PAID, testOrder.getStatus()); // モックメソッドが呼ばれたことを検証 verify(mockGateway).charge(validCard, 5000); } @Test void testFailedPayment() { // モックの振る舞いを設定:決済失敗のケース TransactionResult failureResult = new TransactionResult(false, "残高不足です"); when(mockGateway.charge(validCard, 5000)).thenReturn(failureResult); // テスト実行 PaymentResult result = paymentService.processPayment(testOrder, validCard); // 検証 assertFalse(result.isSuccess()); assertEquals("残高不足です", result.getMessage()); assertNotEquals(OrderStatus.PAID, testOrder.getStatus()); // モックメソッドが呼ばれたことを検証 verify(mockGateway).charge(validCard, 5000); } @Test void testInvalidCardNumber() { // テスト実行 PaymentResult result = paymentService.processPayment(testOrder, invalidCard); // 検証 assertFalse(result.isSuccess()); assertEquals("不正なカード番号です", result.getMessage()); // 外部APIは呼ばれないことを検証 verify(mockGateway, never()).charge(any(), anyDouble()); } @Test void testExceptionInPaymentGateway() { // モックの振る舞いを設定:例外をスローするケース when(mockGateway.charge(validCard, 5000)).thenThrow(new PaymentException("ネットワークエラー")); // テスト実行 PaymentResult result = paymentService.processPayment(testOrder, validCard); // 検証 assertFalse(result.isSuccess()); assertTrue(result.getMessage().contains("システムエラー")); assertTrue(result.getMessage().contains("ネットワークエラー")); // モックメソッドが呼ばれたことを検証 verify(mockGateway).charge(validCard, 5000); } } 3.4 テストスイートの構成
大規模なプロジェクトでは、テストをグループ化して実行することが有効です:
import org.junit.platform.suite.api.SelectPackages; import org.junit.platform.suite.api.Suite; @Suite @SelectPackages({"com.example.module1", "com.example.module2"}) public class AllTests { // この中身は空でOK } 4. JUnit完全チートシート
JUnit 5(JUnit Jupiter)を使った単体テストの実装における主要な機能を一覧化します。このチートシートは、日々の開発で参照できる実用的なリファレンスとなります。
4.1 基本アノテーション
| アノテーション | 説明 |
|---|---|
@Test | テストメソッドを指定 |
@BeforeEach | 各テストメソッドの前に実行 |
@AfterEach | 各テストメソッドの後に実行 |
@BeforeAll | テストクラス内の全テストの前に1回実行(staticメソッドに付ける) |
@AfterAll | テストクラス内の全テストの後に1回実行(staticメソッドに付ける) |
@Disabled | テストの実行を無効化 |
@DisplayName | テストの表示名を指定 |
@Tag | テストにタグ付けして選択的に実行するために使用 |
@Timeout | テストのタイムアウト時間を指定 |
@ExtendWith | JUnit拡張機能を適用 |
4.2 アサーションメソッド
| メソッド | 説明 |
|---|---|
assertEquals(expected, actual) | 値が等しいか検証 |
assertNotEquals(expected, actual) | 値が等しくないか検証 |
assertTrue(condition) | 条件が真か検証 |
assertFalse(condition) | 条件が偽か検証 |
assertNull(object) | オブジェクトがnullか検証 |
assertNotNull(object) | オブジェクトがnullでないか検証 |
assertSame(expected, actual) | 同一オブジェクトか検証 |
assertNotSame(expected, actual) | 異なるオブジェクトか検証 |
assertThrows(exceptionType, executable) | 指定した例外が発生するか検証 |
assertDoesNotThrow(executable) | 例外が発生しないか検証 |
assertAll(executables...) | 複数のアサーションをグループ化 |
assertTimeout(duration, executable) | 処理が指定時間内に完了するか検証 |
fail() | テストを強制的に失敗させる |
4.3 パラメータ化テスト
| アノテーション | 説明 |
|---|---|
@ParameterizedTest | パラメータ化テストを指定 |
@ValueSource | 単一の値のセットを提供 |
@CsvSource | CSV形式の値を提供 |
@CsvFileSource | CSVファイルからの値を提供 |
@MethodSource | メソッドからの値を提供 |
@EnumSource | 列挙型の値を提供 |
@ArgumentsSource | カスタム引数プロバイダー |
例:
@ParameterizedTest @CsvSource({ "apple, 1", "banana, 2", "orange, 3" }) void testWithCsvSource(String fruit, int rank) { assertNotNull(fruit); assertTrue(rank > 0); } 4.4 条件付きテスト実行
| アノテーション | 説明 |
|---|---|
@EnabledOnOs | 特定のOSでのみテストを実行 |
@DisabledOnOs | 特定のOSでテストを無効化 |
@EnabledOnJre | 特定のJREバージョンでのみテストを実行 |
@DisabledOnJre | 特定のJREバージョンでテストを無効化 |
@EnabledIfSystemProperty | システムプロパティの条件に基づいてテストを実行 |
@DisabledIfSystemProperty | システムプロパティの条件に基づいてテストを無効化 |
@EnabledIfEnvironmentVariable | 環境変数の条件に基づいてテストを実行 |
@DisabledIfEnvironmentVariable | 環境変数の条件に基づいてテストを無効化 |
例:
@Test @EnabledOnOs(OS.LINUX) void testOnlyOnLinux() { // Linuxでのみ実行されるテスト } 4.5 ネストされたテスト
テストを論理的にグループ化するために、ネストされたテストクラスを使用できます:
@DisplayName("計算機テスト") class CalculatorTest { Calculator calculator = new Calculator(); @Nested @DisplayName("加算テスト") class AdditionTests { @Test @DisplayName("正の数の加算") void testAddPositiveNumbers() { assertEquals(5, calculator.add(2, 3)); } @Test @DisplayName("負の数の加算") void testAddNegativeNumbers() { assertEquals(-5, calculator.add(-2, -3)); } } @Nested @DisplayName("除算テスト") class DivisionTests { @Test @DisplayName("正常な除算") void testDivision() { assertEquals(2, calculator.divide(4, 2)); } @Test @DisplayName("ゼロ除算例外") void testDivisionByZero() { assertThrows(ArithmeticException.class, () -> calculator.divide(1, 0)); } } } 4.6 動的テスト
実行時にテストケースを生成するための動的テスト機能:
@TestFactory Collection<DynamicTest> dynamicTests() { return Arrays.asList( dynamicTest("1番目の動的テスト", () -> assertTrue(true)), dynamicTest("2番目の動的テスト", () -> assertEquals(4, 2 * 2)) ); } 4.7 Mockitoとの連携
JUnitとMockitoを組み合わせることで、3.3節で説明したような外部依存を持つコードのテストが効率的に行えます。以下にその基本的な使い方を示します:
// JUnit 5とMockitoを統合するための拡張機能 @ExtendWith(MockitoExtension.class) class UserServiceTest { // モックを自動生成 @Mock private UserRepository userRepository; // テスト対象クラスに@Mockでモック化したオブジェクトを自動注入 @InjectMocks private UserService userService; @Test void testGetUserById() { // モックの振る舞いを設定 User mockUser = new User("1", "John Doe"); when(userRepository.findById("1")).thenReturn(mockUser); // テスト実行 User result = userService.getUserById("1"); // 検証 assertEquals("John Doe", result.getName()); verify(userRepository).findById("1"); } } 4.8 Spring Bootとの連携
Spring BootアプリケーションのテストにおけるJUnitの使用例:
@SpringBootTest class UserControllerIntegrationTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void testGetUserById() throws Exception { // モックの振る舞いを設定 User mockUser = new User("1", "John Doe"); when(userService.getUserById("1")).thenReturn(mockUser); // APIリクエストをモック mockMvc.perform(get("/api/users/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value("1")) .andExpect(jsonPath("$.name").value("John Doe")); } } 5. Mockito完全チートシート
5.1 Mockito基本機能一覧
| カテゴリ | メソッド | 説明 | 使用例 |
|---|---|---|---|
| モック作成 | mock() | モックオブジェクト作成 | UserRepository mockRepo = mock(UserRepository.class); |
@Mock | フィールドにモック注入 | @Mock private UserRepository userRepository; | |
@InjectMocks | モックを自動注入 | @InjectMocks private UserService userService; | |
| スタブ設定 | when().thenReturn() | 戻り値設定 | when(mockRepo.findById(1L)).thenReturn(Optional.of(user)); |
when().thenThrow() | 例外スロー設定 | when(mockRepo.save(null)).thenThrow(new IllegalArgumentException()); | |
doReturn().when() | 代替構文 | doReturn(user).when(mockRepo).findById(1L); | |
doNothing().when() | void用 | doNothing().when(mockRepo).deleteById(1L); | |
| 検証 | verify() | 呼び出し検証 | verify(mockRepo).save(user); |
verify(times(n)) | 回数検証 | verify(mockRepo, times(2)).findAll(); | |
never() | 非呼び出し検証 | verify(mockRepo, never()).delete(any()); | |
atLeast(n) | 最低呼び出し回数 | verify(mockRepo, atLeast(1)).findById(any()); | |
atMost(n) | 最大呼び出し回数 | verify(mockRepo, atMost(5)).save(any()); | |
verifyNoMoreInteractions() | 追加呼び出しなし確認 | verifyNoMoreInteractions(mockRepo); | |
| 引数マッチャー | any() | 任意引数 | when(mockRepo.findById(any())).thenReturn(Optional.of(user)); |
anyInt(), anyString() | 型指定任意引数 | when(mockRepo.findByAge(anyInt())).thenReturn(users); | |
eq() | 等価値 | when(mockRepo.findById(eq(1L))).thenReturn(Optional.of(user)); | |
argThat() | 条件マッチ | when(mockRepo.save(argThat(u -> u.getName().equals("テスト")))).thenReturn(user); | |
| その他 | @Captor | 引数キャプチャ | @Captor private ArgumentCaptor<User> userCaptor; |
reset() | モックをリセット | reset(mockRepo); | |
spy() | 実オブジェクトのスパイ作成 | List<String> spyList = spy(new ArrayList<>()); |
5.2 高度なモック化テクニック
@ExtendWith(MockitoExtension.class) class AdvancedMockingTest { @Mock private UserRepository userRepository; @InjectMocks private UserService userService; @Captor private ArgumentCaptor<User> userCaptor; @Test void argumentCaptorExample() { // サービスメソッド呼び出し userService.registerUser("テスト太郎", "test@example.com"); // 引数キャプチャ verify(userRepository).save(userCaptor.capture()); // キャプチャした引数を検証 User capturedUser = userCaptor.getValue(); assertThat(capturedUser.getName()).isEqualTo("テスト太郎"); assertThat(capturedUser.getEmail()).isEqualTo("test@example.com"); } @Test void mockingConsecutiveCalls() { // 連続呼び出しで異なる戻り値を設定 when(userRepository.findById(1L)) .thenReturn(Optional.of(new User(1L, "初回ユーザー"))) .thenReturn(Optional.of(new User(1L, "2回目ユーザー"))) .thenThrow(new RuntimeException("3回目エラー")); // 1回目の呼び出し Optional<User> firstCall = userRepository.findById(1L); assertThat(firstCall).isPresent(); assertThat(firstCall.get().getName()).isEqualTo("初回ユーザー"); // 2回目の呼び出し Optional<User> secondCall = userRepository.findById(1L); assertThat(secondCall).isPresent(); assertThat(secondCall.get().getName()).isEqualTo("2回目ユーザー"); // 3回目の呼び出し(例外がスローされる) assertThrows(RuntimeException.class, () -> userRepository.findById(1L)); } @Test void answerExample() { // 引数に基づいて動的に戻り値を計算 when(userRepository.findById(anyLong())).thenAnswer(invocation -> { Long id = invocation.getArgument(0); if (id < 0) { return Optional.empty(); } return Optional.of(new User(id, "ユーザー" + id)); }); // 異なるIDで検証 assertThat(userRepository.findById(1L)).isPresent(); assertThat(userRepository.findById(1L).get().getName()).isEqualTo("ユーザー1"); assertThat(userRepository.findById(42L).get().getName()).isEqualTo("ユーザー42"); assertThat(userRepository.findById(-1L)).isEmpty(); } } 5.3 Mockitoのベストプラクティス
-
スタブとモックの使い分け
- スタブ:メソッド呼び出しに対する戻り値設定(when...thenReturn)
- モック:メソッド呼び出しの検証(verify)
- 適切な場面で適切なテクニックを使用する
-
必要最小限のモック化
- テストに直接関係ない部分のみモック化
- 過剰なモック化は実装の詳細に依存するテストになりがち
-
テストの可読性向上
- モックの設定とテスト実行、検証を明確に分ける
- 適切な変数名と説明的なコメントを使用
-
よくある落とし穴と回避策
-
any()などの引数マッチャーと実際の引数は混在させない -
thenReturnとdoReturnの適切な使い分け - スパイの不用意な使用による副作用の注意
-
6. Spring Bootのテスト詳細ガイド
6.1 Spring Bootテストアノテーション概要
Spring Bootは、さまざまなテストシナリオに対応するための専用テストアノテーションを提供しています。これらのアノテーションは、必要な部分だけをロードする「スライス」テストを可能にし、テスト実行の効率化と焦点を絞ったテストの作成を支援します。
6.2 主要テストアノテーションの使い分け
| アノテーション | 目的 | テスト対象 | 特徴 | 適切なユースケース |
|---|---|---|---|---|
@SpringBootTest | 統合テスト | アプリケーション全体 | 全コンポーネントの連携テスト | エンドツーエンドの機能検証 |
@WebMvcTest | コントローラーテスト | Controllerレイヤー | MVC関連コンポーネントのみロード | REST APIの入出力検証 |
@DataJpaTest | リポジトリテスト | Repositoryレイヤー | データアクセス関連のみ設定 | JPAクエリや永続化処理の検証 |
@Service | サービステスト | Serviceレイヤー | ビジネスロジック検証 | サービスレイヤーの単体テスト |
@JsonTest | JSON変換テスト | オブジェクト⇔JSON変換 | JSONシリアライズ/デシリアライズ | DTOとJSONの変換処理検証 |
@RestClientTest | REST Clientテスト | 外部API呼び出し | 外部サービス連携 | 外部APIクライアントの検証 |
6.3 各アノテーションの実装例と設定オプション
6.3.1 @SpringBootTest
// 基本的な統合テスト実装 @SpringBootTest class UserServiceIntegrationTest { @Autowired private UserService userService; @Test void registerUserSuccessfully() { // テスト実装 UserDto dto = new UserDto("テスト太郎", "test@example.com"); User savedUser = userService.registerUser(dto); assertThat(savedUser.getId()).isNotNull(); assertThat(savedUser.getName()).isEqualTo("テスト太郎"); } } // WebEnvironment設定オプション例 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class WebIntegrationTest { @Autowired private TestRestTemplate restTemplate; @Test void apiEndpointTest() { // 実際にHTTPリクエストを送信するテスト ResponseEntity<UserDto> response = restTemplate.getForEntity("/api/users/1", UserDto.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody().getName()).isEqualTo("テスト太郎"); } } 6.3.2 @WebMvcTest
@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void getUserById() throws Exception { // モックサービスの振る舞いを設定 User mockUser = new User(1L, "テスト太郎", "test@example.com"); when(userService.findById(1L)).thenReturn(mockUser); // コントローラーエンドポイントへのリクエストをテスト mockMvc.perform(get("/api/users/1") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(1)) .andExpect(jsonPath("$.name").value("テスト太郎")) .andExpect(jsonPath("$.email").value("test@example.com")); } @Test void createUserSuccessfully() throws Exception { // リクエストボディの作成 UserDto dto = new UserDto("新規ユーザー", "new@example.com"); User createdUser = new User(1L, "新規ユーザー", "new@example.com"); when(userService.createUser(any(UserDto.class))).thenReturn(createdUser); // POSTリクエストのテスト mockMvc.perform(post("/api/users") .contentType(MediaType.APPLICATION_JSON) .content(new ObjectMapper().writeValueAsString(dto))) .andExpect(status().isCreated()) .andExpect(header().string("Location", containsString("/api/users/1"))); } } 6.3.3 @DataJpaTest
@DataJpaTest class UserRepositoryTest { @Autowired private UserRepository userRepository; @Autowired private TestEntityManager entityManager; @Test void findByEmailReturnsCorrectUser() { // テストデータのセットアップ User user = new User(null, "テスト太郎", "test@example.com"); entityManager.persist(user); entityManager.flush(); // リポジトリメソッドのテスト Optional<User> found = userRepository.findByEmail("test@example.com"); assertThat(found).isPresent(); assertThat(found.get().getName()).isEqualTo("テスト太郎"); } @Test void findActiveUsersByRoleReturnsCorrectList() { // 複数のテストデータをセットアップ Role adminRole = entityManager.persist(new Role("ADMIN")); Role userRole = entityManager.persist(new Role("USER")); User user1 = new User(null, "管理者A", "admin1@example.com"); user1.setActive(true); user1.setRole(adminRole); entityManager.persist(user1); User user2 = new User(null, "管理者B", "admin2@example.com"); user2.setActive(false); // 非アクティブ user2.setRole(adminRole); entityManager.persist(user2); User user3 = new User(null, "一般ユーザー", "user@example.com"); user3.setActive(true); user3.setRole(userRole); entityManager.persist(user3); entityManager.flush(); // カスタムクエリメソッドのテスト List<User> activeAdmins = userRepository.findActiveUsersByRole("ADMIN"); assertThat(activeAdmins).hasSize(1); assertThat(activeAdmins.get(0).getName()).isEqualTo("管理者A"); } } 7. テストデータベース設定と管理
7.1 テスト用データベースの選択と構成
テスト実行時には、本番環境のデータベースとは分離したテスト用データベースを使用することが重要です。主なアプローチとしては、インメモリデータベース(H2など)と実際のDBMSを使ったコンテナ型テストがあります。
7.1.1 H2インメモリDB設定
# application-test.yml spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 username: sa password: driver-class-name: org.h2.Driver jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop properties: hibernate: format_sql: true show-sql: true テストクラスでの指定方法:
@SpringBootTest @ActiveProfiles("test") // application-test.ymlを使用 class UserServiceIntegrationTest { // テスト実装 } 7.1.2 Testcontainersによる実DBテスト
@SpringBootTest @Testcontainers class DatabaseIntegrationTest { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14") .withDatabaseName("testdb") .withUsername("test") .withPassword("test"); @DynamicPropertySource static void dbProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); } @Autowired private UserRepository userRepository; @Test void testWithRealPostgreSQL() { // 実DBを使ったテスト User user = new User(null, "コンテナテストユーザー", "container@example.com"); User saved = userRepository.save(user); Optional<User> found = userRepository.findById(saved.getId()); assertThat(found).isPresent(); assertThat(found.get().getName()).isEqualTo("コンテナテストユーザー"); } } 7.2 テストデータのセットアップ手法
7.2.1 @Sqlアノテーションの活用
@SpringBootTest class OrderServiceTest { @Autowired private OrderService orderService; @Test @Sql("/sql/create-test-users.sql") @Sql(scripts = "/sql/create-test-products.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Sql(scripts = "/sql/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) void processOrderSuccessfully() { // テストデータがセットアップされた状態でテスト実行 Order order = orderService.createOrder(1L, Arrays.asList(1L, 2L)); assertThat(order).isNotNull(); assertThat(order.getItems()).hasSize(2); assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED); } } SQL例(src/test/resources/sql/create-test-users.sql):
INSERT INTO users (id, name, email, active) VALUES (1, 'テストユーザー', 'test@example.com', true); INSERT INTO users (id, name, email, active) VALUES (2, '管理者ユーザー', 'admin@example.com', true); 7.2.2 TestEntityManagerの活用
@DataJpaTest class ProductRepositoryTest { @Autowired private TestEntityManager entityManager; @Autowired private ProductRepository productRepository; @BeforeEach void setup() { // テストデータ準備 Category category = new Category("電子機器"); entityManager.persist(category); Product product1 = new Product("スマートフォン", 50000, category); entityManager.persist(product1); Product product2 = new Product("タブレット", 40000, category); entityManager.persist(product2); entityManager.flush(); } @Test void findByCategoryName() { List<Product> products = productRepository.findByCategoryName("電子機器"); assertThat(products).hasSize(2); assertThat(products).extracting("name") .containsExactlyInAnyOrder("スマートフォン", "タブレット"); } } 7.3 トランザクション管理のベストプラクティス
- テストの独立性確保
- 各テストは独立して実行できるように、テスト間でデータが干渉しないようにする
-
@Transactionalアノテーションを使用してテスト後にロールバックする
@SpringBootTest @Transactional class UserServiceTransactionalTest { @Autowired private UserService userService; @Autowired private UserRepository userRepository; @Test void registerUserRollsBackAfterTest() { // このテスト内でのデータ変更は、テスト完了後にロールバックされる User user = userService.registerUser(new UserDto("一時ユーザー", "temp@example.com")); assertThat(user.getId()).isNotNull(); // テスト内ではデータが存在することを確認 assertThat(userRepository.findById(user.getId())).isPresent(); // テスト終了後、このデータはロールバックされる } } - 明示的なトランザクション境界のテスト
- トランザクション境界(コミット、ロールバック)の動作を検証する場合
- テストメソッドに
@Transactionalを付けず、手動でトランザクションを管理
@SpringBootTest // クラスレベルの@Transactionalは付けない class BankTransferServiceTest { @Autowired private BankTransferService transferService; @Autowired private AccountRepository accountRepository; @Test void transferFailsAndRollsBackWhenInsufficientFunds() { // 事前データ設定 Account sourceAccount = accountRepository.save(new Account("送金元", 1000)); Account targetAccount = accountRepository.save(new Account("送金先", 0)); // 残高不足での送金テスト assertThrows(InsufficientFundsException.class, () -> { transferService.transfer(sourceAccount.getId(), targetAccount.getId(), 2000); }); // トランザクションがロールバックされていることを確認 Account updatedSource = accountRepository.findById(sourceAccount.getId()).orElseThrow(); Account updatedTarget = accountRepository.findById(targetAccount.getId()).orElseThrow(); assertThat(updatedSource.getBalance()).isEqualTo(1000); // 変更なし assertThat(updatedTarget.getBalance()).isEqualTo(0); // 変更なし } } - テストデータのクリーンアップ戦略
- テスト前後でデータをクリーンアップする方法
-
@BeforeEachと@AfterEachの活用
@SpringBootTest class DataCleanupTest { @Autowired private UserRepository userRepository; @BeforeEach void setup() { // テスト前にデータをクリーンアップ userRepository.deleteAll(); } @Test void testUserCreation() { // テストの実装 } @AfterEach void cleanup() { // テスト後にデータをクリーンアップ(必要に応じて) userRepository.deleteAll(); } } 8. 実践的なテストケース設計手法
8.1 テスト分析手法
8.1.1 同値分割
同値分割は、入力データを等価なグループ(クラス)に分け、各グループから代表的な値を選んでテストする方法です。類似の動作をする値は同じグループに分類します。
@ParameterizedTest @CsvSource({ "10, 'A'", // 0-20のクラスからの代表値 "35, 'B'", // 21-50のクラスからの代表値 "75, 'C'" // 51-100のクラスからの代表値 }) void gradeCalculationByEquivalencePartitioning(int score, char expectedGrade) { assertEquals(expectedGrade, gradeService.calculateGrade(score)); } 8.1.2 境界値分析
境界値分析は、入力範囲の境界付近の値をテストする方法です。境界値においてバグが発生しやすいことに着目しています。
@ParameterizedTest @CsvSource({ "0, 'A'", // 下限境界値 "20, 'A'", // 上限境界値 "21, 'B'", // 下限境界値+1 "50, 'B'", // 上限境界値 "51, 'C'", // 下限境界値+1 "100, 'C'" // 上限境界値 }) void gradeCalculationByBoundaryValueAnalysis(int score, char expectedGrade) { assertEquals(expectedGrade, gradeService.calculateGrade(score)); } 8.2 効率的なテストデータ生成
8.2.1 テストデータビルダーパターン
テストデータビルダーパターンを使用すると、テストに必要なオブジェクトを柔軟かつ可読性高く生成できます。
// ビルダークラス定義 public class UserTestBuilder { private Long id = 1L; private String name = "テストユーザー"; private String email = "test@example.com"; private List<Role> roles = new ArrayList<>(); private boolean active = true; public static UserTestBuilder aUser() { return new UserTestBuilder(); } public UserTestBuilder withId(Long id) { this.id = id; return this; } public UserTestBuilder withName(String name) { this.name = name; return this; } public UserTestBuilder withEmail(String email) { this.email = email; return this; } public UserTestBuilder withRole(Role role) { this.roles.add(role); return this; } public UserTestBuilder inactive() { this.active = false; return this; } public User build() { User user = new User(); user.setId(id); user.setName(name); user.setEmail(email); user.setRoles(roles); user.setActive(active); return user; } } // 使用例 @Test void testWithBuilder() { User testUser = UserTestBuilder.aUser() .withName("テスト太郎") .withEmail("taro@example.com") .withRole(new Role("ADMIN")) .build(); // テスト実装 userService.sendWelcomeEmail(testUser); verify(emailService).sendEmail(eq(testUser.getEmail()), any(), any()); } 8.2.2 テストデータファクトリー
複数のテストで共通のデータセットを使用する場合、テストデータファクトリーが有効です。
public class TestDataFactory { // 標準的なユーザーセット public static List<User> createSampleUsers() { List<User> users = new ArrayList<>(); users.add(new User(1L, "一般ユーザー", "user@example.com")); users.add(new User(2L, "管理者", "admin@example.com")); users.add(new User(3L, "非アクティブユーザー", "inactive@example.com", false)); return users; } // 標準的な商品セット public static List<Product> createSampleProducts() { Category electronics = new Category(1L, "電化製品"); Category food = new Category(2L, "食品"); List<Product> products = new ArrayList<>(); products.add(new Product(1L, "スマートフォン", 50000, electronics)); products.add(new Product(2L, "タブレット", 40000, electronics)); products.add(new Product(3L, "お菓子セット", 1000, food)); return products; } // 特定のテストケース用のデータセット public static Order createOrderWithItems() { Order order = new Order(1L, createSampleUsers().get(0)); List<OrderItem> items = new ArrayList<>(); items.add(new OrderItem(1L, createSampleProducts().get(0), 1)); items.add(new OrderItem(2L, createSampleProducts().get(2), 3)); order.setItems(items); return order; } } // 使用例 @Test void calculateOrderTotalCorrectly() { Order testOrder = TestDataFactory.createOrderWithItems(); double total = orderService.calculateTotal(testOrder); // スマートフォン1台(50000) + お菓子セット3個(3000) = 53000 assertEquals(53000, total); } 9. 単体テスト自動化の概念
9.1 継続的インテグレーション(CI)とは
継続的インテグレーション(Continuous Integration, CI)は、開発者がコードを共有リポジトリに頻繁に統合し、自動的にビルドとテストを実行することで、早期に問題を発見するプラクティスです。
CIの主な目的:
- コードの品質を維持する
- 統合の問題を早期に発見する
- デプロイ可能な状態を常に維持する
- 開発サイクルを短縮する
9.2 単体テスト自動化のメリット
- 手動作業の削減:反復的なテスト実行を自動化することで人的リソースを節約
- 迅速なフィードバック:変更がコードベースに与える影響を即座に把握
- 一貫性の確保:同じテストを同じ環境で常に実行
- リグレッションの防止:既存機能への影響を早期に検出
- ドキュメントとしての役割:自動テストはコードの期待動作を示す
- デプロイの信頼性向上:十分にテストされたコードのみがデプロイされる
9.3 Jenkins概要
Jenkinsは、オープンソースの自動化サーバーで、ビルド、テスト、デプロイのパイプラインを自動化するのに広く使用されています。
Jenkinsの主な機能:
- ジョブの作成と実行:定期的または特定のトリガーに基づいてタスクを実行
- パイプラインの構築:複数のステップを連結したワークフローを定義
- プラグインエコシステム:多様なツールやプラットフォームとの統合
- 分散ビルド環境:複数のエージェントでタスクを並列実行
- 通知機能:ビルド結果をEメールやSlackなどで通知
9.4 Jenkins + JUnitによる自動化フロー
ここではJenkinsを使った単体テスト自動化の具体的な流れを、実際の開発現場のシナリオを想定して説明します。
9.4.1 具体的なCI/CD自動化シナリオ
例えば、あるECサイトの開発チームが以下のような環境で作業しているとします:
- GitHubでソースコード管理
- Mavenでビルド
- JUnitとJaCoCoでテストとカバレッジ測定
- Slackでチーム内コミュニケーション
想定する自動化フロー
-
コード変更:開発者が新機能「クーポン割引計算」の実装を完了し、GitHubのfeatureブランチにプッシュ
# 開発者の作業 git checkout -b feature/coupon-discount # コード修正後 git add src/main/java/com/example/service/CouponService.java git add src/test/java/com/example/service/CouponServiceTest.java git commit -m "クーポン割引機能の実装とテスト追加" git push origin feature/coupon-discount -
プルリクエスト作成:開発者がGitHubでプルリクエスト(PR)を作成
- PRのタイトル:「クーポン割引機能の実装 #123」
- PR説明:「任意のクーポンコードに対して割引率を設定できる機能を追加しました」
-
トリガー:JenkinsがWebhookを通じてPR作成を検知し、ビルドジョブを自動開始
- Jenkinsコンソールに表示:「GitHub PR #42からのビルドを開始します」
-
コードチェックアウト:JenkinsがGitHubからPRのコードをチェックアウト
[Jenkins] > git fetch origin pull/42/head:pr-42 [Jenkins] > git checkout pr-42 -
ビルド:Jenkinsがプロジェクトをビルド
[Jenkins] > mvn clean compile [INFO] Building MyECサイト 1.0-SNAPSHOT [INFO] ------------------------------------------------------------------------ [INFO] Compiling 32 source files to /var/jenkins_home/workspace/pr-builder/target/classes [INFO] BUILD SUCCESS -
単体テスト実行:JUnitテストを実行
[Jenkins] > mvn test [INFO] ------------------------------------------------------- [INFO] Running com.example.service.CouponServiceTest [INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.823 s [INFO] Results: [INFO] Tests run: 42, Failures: 0, Errors: 0, Skipped: 0 -
テスト結果収集:テスト結果とカバレッジレポートを収集
[Jenkins] JUnitレポートを記録: target/surefire-reports/*.xml [Jenkins] JaCoCoカバレッジレポートを処理中: target/site/jacoco/jacoco.xml [Jenkins] ライン網羅率: 87.2% [Jenkins] 分岐網羅率: 83.1% -
コード品質チェック:SonarQubeでコード品質チェック
[Jenkins] > mvn sonar:sonar [INFO] ANALYSIS SUCCESSFUL, you can find the results at: http://sonarqube:9000/dashboard?id=my-ec-site [Jenkins] 新規コード品質: 合格 -
レポート生成:テスト結果を視覚化したレポートを生成
- Jenkins UI上に表示されるテスト結果グラフ
- テストトレンド(テスト数、成功率の推移)
- コードカバレッジマップ(どの部分がテストされているか視覚的に表示)
-
通知:結果をSlackに通知
[Jenkins -> Slack] 📊 ビルド #247 (PR #42) 成功 • テスト: 42件実行, 全て成功 ✅ • コード網羅率: 87.2% ✅ • コード品質: 合格 ✅ 詳細: http://jenkins:8080/job/pr-builder/247/ -
PRへのコメント:Jenkinsがビルド結果をGitHubのPRにコメント
[Jenkins -> GitHub] ビルド #247: ✅ 成功 • 単体テスト: 42件中42件成功 • コードカバレッジ: 87.2% (前回比 +1.3%) • 詳細レポート: http://jenkins:8080/job/pr-builder/247/ -
マージ承認:レビューアがコードとテスト結果を確認し、PRを承認
[GitHub] レビューコメント: "コードとテストが適切に実装されており、すべてのテストが通過しているためマージ承認します。" -
masterブランチへのマージ:PRがmasterブランチにマージされる
[GitHub] PR #42をマージしました: "クーポン割引機能の実装 #123" -
本番デプロイ準備:masterブランチへのマージを検知して、リリース用ビルドが開始
[Jenkins] master-buildジョブを開始します (#183) [Jenkins] リリース候補バージョン: 2.4.0 をビルド中
以上の流れにより、コード変更から結果確認までが自動化され、問題があれば早期に発見できるようになります。
9.5 Jenkinsパイプラインの構成
前述の自動化フローを実現するためには、Jenkinsパイプラインを適切に構成する必要があります。Jenkinsパイプラインは、Jenkinsfileと呼ばれるスクリプトファイルで定義します:
pipeline { agent any stages { stage('Checkout') { steps { checkout scm } } stage('Build') { steps { sh 'mvn clean compile' } } stage('Test') { steps { sh 'mvn test' } post { always { junit 'target/surefire-reports/*.xml' jacoco execPattern: 'target/jacoco.exec' } } } stage('Package') { steps { sh 'mvn package -DskipTests' } } } post { success { echo 'Build succeeded!' } failure { echo 'Build failed!' } always { // 常に実行するクリーンアップステップ } } } 9.6 他のCI/CDツール
Jenkinsの他にも、単体テストの自動化に使用できる主要なCI/CDツールがあります:
- GitHub Actions:GitHubリポジトリに統合されたCI/CDサービス
- GitLab CI/CD:GitLabに組み込まれたCI/CDツール
- CircleCI:クラウドベースのCI/CDプラットフォーム
- Travis CI:オープンソースプロジェクト向けのCI/CDサービス
- TeamCity:JetBrains社のCIサーバー
- Azure DevOps:MicrosoftのDevOpsサービス
- AWS CodePipeline:AWSのCI/CDサービス
10. CI/CD自動化完全チートシート
単体テストの自動化に関連するCI/CDツールの設定と利用方法を一覧化します。このチートシートは、CI/CD環境を構築・運用する際の実用的なリファレンスとなります。
10.1 Jenkins基本コマンド
| コマンド | 説明 |
|---|---|
jenkins-cli.jar help | 使用可能なコマンドリストを表示 |
jenkins-cli.jar build JOB_NAME | 指定したジョブをビルド |
jenkins-cli.jar build JOB_NAME -p param=value | パラメータ付きでジョブをビルド |
jenkins-cli.jar console JOB_NAME | ジョブのコンソール出力を表示 |
jenkins-cli.jar who-am-i | 現在のユーザー情報を表示 |
jenkins-cli.jar list-jobs | すべてのジョブを一覧表示 |
jenkins-cli.jar safe-restart | Jenkinsを安全に再起動 |
10.2 Jenkins環境変数
| 変数 | 説明 |
|---|---|
BUILD_NUMBER | 現在のビルド番号 |
BUILD_ID | 現在のビルドID |
JOB_NAME | ジョブの名前 |
WORKSPACE | ワークスペースディレクトリのパス |
JENKINS_HOME | Jenkinsのホームディレクトリ |
GIT_COMMIT | Gitコミットハッシュ |
GIT_BRANCH | Gitブランチ名 |
BUILD_URL | ビルドのURL |
NODE_NAME | ビルドを実行しているノード名 |
10.3 Jenkinsfileスニペット集
10.3.1 基本的なパイプライン
pipeline { agent any stages { stage('Build') { steps { echo 'Building..' } } stage('Test') { steps { echo 'Testing..' } } stage('Deploy') { steps { echo 'Deploying....' } } } } 10.3.2 条件付きステージ
pipeline { agent any stages { stage('Build') { steps { echo 'Building..' } } stage('Deploy to Production') { when { branch 'master' } steps { echo 'Deploying to production...' } } } } 10.3.3 並列実行
pipeline { agent any stages { stage('Tests') { parallel { stage('Unit Tests') { steps { echo 'Running unit tests...' } } stage('Integration Tests') { steps { echo 'Running integration tests...' } } } } } } 10.3.4 環境変数
pipeline { agent any environment { MAVEN_HOME = '/usr/share/maven' } stages { stage('Build') { environment { DEBUG_FLAG = 'true' } steps { sh 'echo $MAVEN_HOME' sh 'echo $DEBUG_FLAG' } } } } 10.3.5 JUnitテスト結果の収集
pipeline { agent any stages { stage('Test') { steps { sh 'mvn test' } post { always { junit 'target/surefire-reports/*.xml' } } } } } 10.3.6 コードカバレッジレポート(JaCoCo)
pipeline { agent any stages { stage('Test') { steps { sh 'mvn test' } post { success { jacoco( execPattern: 'target/jacoco.exec', classPattern: 'target/classes', sourcePattern: 'src/main/java', exclusionPattern: 'src/test*' ) } } } } } 10.4 GitHub Actions
GitHub Actionsを使用したJavaプロジェクトの単体テスト自動化の例:
name: Java CI on: [push, pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up JDK 11 uses: actions/setup-java@v2 with: java-version: '11' distribution: 'adopt' - name: Build with Maven run: mvn -B package --file pom.xml - name: Test run: mvn test - name: Publish Test Report uses: mikepenz/action-junit-report@v2 if: always() # テストが失敗しても常に実行 with: report_paths: '**/target/surefire-reports/TEST-*.xml' - name: JaCoCo Code Coverage Report uses: codecov/codecov-action@v1 with: file: ./target/site/jacoco/jacoco.xml fail_ci_if_error: true 10.5 GitLab CI/CD
GitLab CI/CDを使用したJavaプロジェクトの単体テスト自動化の例:
image: maven:3.8.1-openjdk-11 stages: - build - test - deploy variables: MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository" cache: paths: - .m2/repository build: stage: build script: - mvn compile test: stage: test script: - mvn test artifacts: reports: junit: - target/surefire-reports/TEST-*.xml paths: - target/site/jacoco/jacoco.xml code_quality: stage: test script: - mvn verify -DskipTests artifacts: paths: - target/site/ deploy: stage: deploy script: - mvn package -DskipTests only: - master artifacts: paths: - target/*.jar 10.6 CircleCI
CircleCIを使用したJavaプロジェクトの単体テスト自動化の例:
version: 2.1 jobs: build-and-test: docker: - image: cimg/openjdk:11.0 steps: - checkout - restore_cache: key: maven-{{ checksum "pom.xml" }} - run: name: Build command: mvn -B -DskipTests clean package - run: name: Test command: mvn test - save_cache: paths: - ~/.m2 key: maven-{{ checksum "pom.xml" }} - store_test_results: path: target/surefire-reports - store_artifacts: path: target/site/jacoco/jacoco.xml destination: jacoco/jacoco.xml workflows: version: 2 build-test-deploy: jobs: - build-and-test 10.7 AWS CodePipeline
AWS CloudFormationテンプレートを使用したCodePipelineの設定例:
AWSTemplateFormatVersion: '2010-09-09' Resources: CodeBuildServiceRole: Type: 'AWS::IAM::Role' Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: codebuild.amazonaws.com Action: 'sts:AssumeRole' ManagedPolicyArns: - 'arn:aws:iam::aws:policy/AmazonS3FullAccess' - 'arn:aws:iam::aws:policy/AmazonECR-FullAccess' CodePipelineServiceRole: Type: 'AWS::IAM::Role' Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: codepipeline.amazonaws.com Action: 'sts:AssumeRole' ManagedPolicyArns: - 'arn:aws:iam::aws:policy/AWSCodeBuildAdminAccess' - 'arn:aws:iam::aws:policy/AmazonS3FullAccess' CodeBuildProject: Type: 'AWS::CodeBuild::Project' Properties: Artifacts: Type: CODEPIPELINE Environment: Type: LINUX_CONTAINER ComputeType: BUILD_GENERAL1_SMALL Image: aws/codebuild/amazonlinux2-x86_64-standard:3.0 ServiceRole: !GetAtt CodeBuildServiceRole.Arn Source: Type: CODEPIPELINE BuildSpec: | version: 0.2 phases: install: runtime-versions: java: corretto11 build: commands: - mvn compile test: commands: - mvn test reports: junit: files: - 'target/surefire-reports/TEST-*.xml' artifacts: files: - target/*.jar - appspec.yml discard-paths: no ArtifactBucket: Type: 'AWS::S3::Bucket' Properties: VersioningConfiguration: Status: Enabled Pipeline: Type: 'AWS::CodePipeline::Pipeline' Properties: RoleArn: !GetAtt CodePipelineServiceRole.Arn ArtifactStore: Type: S3 Location: !Ref ArtifactBucket Stages: - Name: Source Actions: - Name: Source ActionTypeId: Category: Source Owner: AWS Provider: CodeStarSourceConnection Version: '1' Configuration: ConnectionArn: !Ref CodeStarConnection FullRepositoryId: your-repo-owner/your-repo-name BranchName: main OutputArtifacts: - Name: SourceCode - Name: Build Actions: - Name: BuildAndTest ActionTypeId: Category: Build Owner: AWS Provider: CodeBuild Version: '1' Configuration: ProjectName: !Ref CodeBuildProject InputArtifacts: - Name: SourceCode OutputArtifacts: - Name: BuildOutput 10.8 テスト自動化のベストプラクティス
-
テストの独立性を保つ
- 各テストは他のテストに依存せず、どの順番で実行しても同じ結果になるようにする
- テスト間で共有状態を使用しない
-
高速なテストを維持する
- 単体テストは数ミリ秒から数秒で完了するようにする
- 時間のかかるテストは別のテストスイートに分離する
-
テスト環境の一貫性を確保する
- コンテナ技術(Docker等)を使用して再現性のある環境を構築する
- 依存関係の管理を適切に行う
-
フラキーテスト(不安定なテスト)への対処
- 非決定的な結果を返すテストを特定し修正する
- 必要に応じてリトライメカニズムを実装する
-
テスト結果の可視化
- わかりやすいレポートを生成する
- トレンド分析を行い、時間経過に伴う品質変化を把握する
-
テストカバレッジの監視
- カバレッジ目標を設定し、定期的に監視する
- 新機能追加時は対応するテストも追加する
-
通知の最適化
- 重要なイベントに対してのみ通知を送る
- 関連する担当者にターゲットを絞った通知を行う
-
パイプラインの最適化
- 並列実行を活用して実行時間を短縮する
- 依存関係に基づいてステージを適切に設計する
-
セキュリティの確保
- 機密情報(APIキー、パスワードなど)はセキュアに管理する
- 適切なアクセス制御を実施する
-
定期的なメンテナンス
- 使用していないジョブやアーティファクトを削除する
- CIツールとプラグインを最新の状態に保つ
11. レガシーコードへのテスト導入戦略
11.1 依存性が高いコードのテスト手法
レガシーコードは、外部依存が多くテストが困難なことが多いですが、以下の手法でテストを導入できます:
-
シーム(Seam)の特定
- コードを変更せずにテスト可能にする挿入ポイントを見つける
- 依存関係の注入ポイントや継承を利用した置き換えポイントを探す
-
抽出リファクタリング
- テスト対象となるロジックを純粋な関数やメソッドに抽出する
- 外部依存とビジネスロジックを分離する
-
アダプターパターンの活用
- 外部システムとの境界にアダプターを導入
- インターフェースを使用して依存関係を抽象化
// 元のレガシーコード(テスト困難) public class LegacyPaymentProcessor { public boolean processPayment(double amount) { // 直接データベースへアクセス Connection conn = DatabaseSingleton.getConnection(); // 直接外部APIを呼び出し String apiKey = Configuration.getApiKey(); PaymentGateway gateway = new PaymentGateway(apiKey); try { TransactionResult result = gateway.charge(amount); if (result.isSuccessful()) { saveToDatabase(conn, result); return true; } return false; } catch (Exception e) { return false; } } private void saveToDatabase(Connection conn, TransactionResult result) { // データベース処理 } } // リファクタリング後(テスト可能) public class ModernPaymentProcessor { private PaymentGatewayInterface gateway; private TransactionRepository repository; // 依存性注入によるテスト容易性の向上 public ModernPaymentProcessor(PaymentGatewayInterface gateway, TransactionRepository repository) { this.gateway = gateway; this.repository = repository; } public boolean processPayment(double amount) { try { TransactionResult result = gateway.charge(amount); if (result.isSuccessful()) { repository.save(result); return true; } return false; } catch (Exception e) { return false; } } } // テストコード @Test void testSuccessfulPaymentProcessing() { // モックの準備 PaymentGatewayInterface mockGateway = mock(PaymentGatewayInterface.class); TransactionRepository mockRepo = mock(TransactionRepository.class); TransactionResult successResult = new TransactionResult(true, "12345"); when(mockGateway.charge(100.0)).thenReturn(successResult); // テスト対象クラスの作成 ModernPaymentProcessor processor = new ModernPaymentProcessor(mockGateway, mockRepo); // テスト実行 boolean result = processor.processPayment(100.0); // 検証 assertTrue(result); verify(mockRepo).save(successResult); } 11.2 段階的なテスト導入アプローチ
レガシーコードにテストを導入する場合、段階的なアプローチが効果的です:
-
特性化テスト(Characterization Testing)
- 現状の振る舞いを把握するためのテスト作成
- 「このコードが何をしているか」を文書化
-
コードの保護
- 重要な部分を特性化テストで保護
- リファクタリング前に安全ネットを確保
-
小さな変更とリファクタリング
- 依存関係を一つずつ解きほぐす
- インターフェース抽出、依存性注入の導入
-
テスト容易性の向上
- テスタビリティを高めるための設計改善
- 単一責任の原則を適用
-
テストカバレッジの漸進的拡大
- 最も重要な部分から順にテストカバレッジを拡大
- ホットスポット(変更頻度の高い箇所)を優先
11.3 レガシーコードのテストパターン
11.3.1 特性化テスト
現状の振る舞いを把握して変更の安全性を確保するためのテスト:
@Test void characterizeExistingBehavior() { // 既存クラスの現在の動作を記録 LegacyCalculator calculator = new LegacyCalculator(); // さまざまな入力に対する既存の出力を記録 assertEquals(42, calculator.complexCalculation(10, 20)); assertEquals(65, calculator.complexCalculation(15, 25)); assertEquals(-3, calculator.complexCalculation(-5, 2)); // 例外ケースの振る舞いも記録 assertThrows(ArithmeticException.class, () -> calculator.complexCalculation(999, 0)); } 11.3.2 サブクラス化とオーバーライド
テスト用サブクラスで依存部分を置き換える:
// テスト困難なオリジナルクラス public class LegacyService { protected Database getDatabase() { return DatabaseSingleton.getInstance(); } public List<Customer> getActiveCustomers() { Database db = getDatabase(); return db.executeQuery("SELECT * FROM customers WHERE active = 1"); } } // テスト用サブクラス class TestableService extends LegacyService { private Database mockDatabase; public TestableService(Database mockDatabase) { this.mockDatabase = mockDatabase; } @Override protected Database getDatabase() { return mockDatabase; } } // テストコード @Test void testGetActiveCustomers() { // モックデータベースの準備 Database mockDb = mock(Database.class); List<Customer> expectedCustomers = Arrays.asList( new Customer(1, "Alice"), new Customer(2, "Bob") ); when(mockDb.executeQuery(anyString())).thenReturn(expectedCustomers); // テスト用サブクラスを使用 TestableService service = new TestableService(mockDb); // テスト実行 List<Customer> result = service.getActiveCustomers(); // 検証 assertEquals(2, result.size()); assertEquals("Alice", result.get(0).getName()); assertEquals("Bob", result.get(1).getName()); } 11.3.3 ゴールデンマスターテスト
特に複雑なアルゴリズムに対して、リファクタリング前後の出力が同じであることを確認するテスト:
@Test void goldenMasterTest() throws IOException { // 広範囲のテストデータを準備 List<TestInput> testInputs = generateTestInputs(); // 元のアルゴリズムの出力を記録 Map<TestInput, Result> originalResults = new HashMap<>(); LegacyAlgorithm legacy = new LegacyAlgorithm(); for (TestInput input : testInputs) { originalResults.put(input, legacy.process(input)); } // 新しいアルゴリズムの出力を比較 RefactoredAlgorithm refactored = new RefactoredAlgorithm(); for (TestInput input : testInputs) { Result originalResult = originalResults.get(input); Result newResult = refactored.process(input); assertEquals(originalResult, newResult, "Input: " + input + " produced different results"); } } 12. テスト品質メトリクスとダッシュボード構築
12.1 テスト品質の主要メトリクス
テスト品質を評価するための主要なメトリクス:
-
テストカバレッジ
- ライン(行)カバレッジ:テスト実行で実行される行の割合
- 分岐カバレッジ:テストされる条件分岐の割合
- 条件カバレッジ:複合条件の各部分が評価される割合
- 変異テストカバレッジ:コードに故意に不具合を入れても検出できるか
-
テスト品質メトリクス
- テスト成功率:成功したテストの割合
- フラキーテスト(不安定なテスト)の割合:実行毎に結果が変わるテスト
- テスト実行時間:テストスイート全体の実行時間
- テスト密度:コード行数あたりのテスト数
-
コード品質メトリクス
- サイクロマティック複雑度:コードの制御フローの複雑さ
- 結合度と凝集度:モジュール間の依存関係の強さと内部一貫性
- 重複コード:複製されているコードの割合
- コードスメル:潜在的な問題を示す兆候
-
変更に関するメトリクス
- 欠陥流出率:品質保証を通過して顧客に到達した欠陥の割合
- 欠陥修正時間:欠陥の発見から修正までの時間
- リグレッション発生率:機能変更によって引き起こされた新たな不具合の割合
12.2 SonarQubeとの連携
SonarQubeは、コード品質とセキュリティの継続的な検査を行うためのプラットフォームです。
12.2.1 SonarQubeの設定
Maven設定例:
<plugin> <groupId>org.sonarsource.scanner.maven</groupId> <artifactId>sonar-maven-plugin</artifactId> <version>3.9.1.2184</version> </plugin> JenkinsパイプラインでのSonarQube実行:
stage('SonarQube Analysis') { steps { withSonarQubeEnv('SonarQube Server') { sh 'mvn sonar:sonar -Dsonar.projectKey=my-project -Dsonar.projectName="My Project"' } } } 12.2.2 主要な設定パラメータ
| パラメータ | 説明 | 例 |
|---|---|---|
sonar.projectKey | プロジェクトの一意識別子 | com.example:my-project |
sonar.sources | ソースコードディレクトリ | src/main/java |
sonar.tests | テストコードディレクトリ | src/test/java |
sonar.java.binaries | コンパイル済みクラスファイル | target/classes |
sonar.coverage.jacoco.xmlReportPaths | JaCoCoレポートパス | target/site/jacoco/jacoco.xml |
sonar.junit.reportPaths | JUnitレポートパス | target/surefire-reports |
sonar.exclusions | 解析から除外するファイル | **/generated/** |
12.2.3 品質ゲートの設定
SonarQubeでは、以下のような品質ゲートを設定することができます:
- 新規コードの行カバレッジが80%以上
- テストの成功率が100%
- 重複コードが5%未満
- バグと脆弱性が0
- コードスメルが特定数以下
これらのルールを満たさない場合、CIパイプラインを失敗させることができます。
12.3 テスト品質ダッシュボードの構築
12.3.1 効果的なダッシュボード要素
-
概要ビュー
- テスト成功率
- カバレッジ全体像
- 最近の品質トレンド
-
詳細ビュー
- モジュール/パッケージ別のカバレッジ
- テスト実行時間の分布
- フラキーテストリスト
-
トレンドビュー
- 時間経過に伴うテストカバレッジの変化
- 失敗テストの推移
- 新機能追加に対するテスト増加率
-
アラートビュー
- 品質閾値を下回る領域の警告
- 長時間実行テストの特定
- テストされていない新規コード
12.3.2 ダッシュボード実装例(Grafana + Prometheus)
- Prometheusを使ってテスト結果メトリクスを収集
- Grafanaでダッシュボードを構築
# docker-compose.yml version: '3' services: prometheus: image: prom/prometheus volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml ports: - "9090:9090" grafana: image: grafana/grafana depends_on: - prometheus ports: - "3000:3000" volumes: - grafana-storage:/var/lib/grafana environment: - GF_SECURITY_ADMIN_PASSWORD=admin - GF_USERS_ALLOW_SIGN_UP=false volumes: grafana-storage: 13. よくある問題とトラブルシューティング
13.1 JUnitに関する問題
-
テストが見つからない
- 解決策:テストクラス名が「Test」で始まるか終わることを確認
- 解決策:テストメソッドに
@Testアノテーションが付いていることを確認
-
テストが実行されない
- 解決策:
@Disabledアノテーションが付いていないか確認 - 解決策:条件付きテスト(
@EnabledOnOsなど)の条件を確認
- 解決策:
-
フラキーテスト(不安定なテスト)
- 解決策:外部依存をモック化する
- 解決策:競合状態を排除する(同期化、スレッドセーフなコードへの修正)
-
テストが遅い
- 解決策:不要な初期化を削減する
- 解決策:テストの粒度を適切に保つ
- 解決策:重い処理をモックに置き換える
13.2 単体テスト自動化に関する問題
-
ビルドの失敗
- 解決策:ローカルで再現し、問題を特定する
- 解決策:ビルドログを詳細に確認する
-
テスト環境の不一致
- 解決策:Dockerなどのコンテナ技術を使用して環境を統一する
- 解決策:依存関係の厳密なバージョン管理を行う
-
パイプラインが遅い
- 解決策:並列実行を活用する
- 解決策:不要なステップを削除する
- 解決策:キャッシュを適切に使用する
-
リソース枯渇
- 解決策:必要に応じてビルドエージェントのスケールアップ/スケールアウト
- 解決策:不要なジョブやアーティファクトを定期的にクリーンアップ
14. 参考資料とリソース
14.1 公式ドキュメント
- JUnit 5ユーザーガイド
- Mockitoドキュメント
- Jenkinsドキュメント
- GitHub Actionsドキュメント
- GitLab CI/CDドキュメント
- Spring Boot テストガイド
- SonarQubeドキュメント
14.2 書籍
- 「Effective Unit Testing」by Lasse Koskela
- 「Test-Driven Development: By Example」by Kent Beck
- 「単体テストの考え方/使い方」by Vladimir Khorikov
- 「継続的デリバリー」by Jez Humble, David Farley
- 「Working Effectively with Legacy Code」by Michael Feathers
- 「Clean Code」by Robert C. Martin