単体テストの本質
単体テストは動作保証と設計品質向上の二面性を持ちます:
- 動作保証: 特定の入力に対して期待される出力を返すこと
-
品質保証: 「テスト容易なコードであること」と「高凝集・低結合」
※凝集度:あるクラス(モジュール)やメソッド内において関連するフィールドやメソッドがどれだけあるか
※結合度:あるクラス(モジュール)が他のモジュールにどれだけ依存しているか
単体テストは「正しい条件でテストが通る」ことが重要です。
つまり、ビジネスロジックの正確性を検証する必要があります。
Spring Boot構成ごとの単体テストガイド
1. Controller層のテスト
Controller層では主にHTTPリクエスト/レスポンスの処理とService層の呼び出しに焦点を当てます。
テスト観点
- HTTPメソッドとパスのマッピング
- リクエストパラメータ/ボディの解析
- 入力バリデーション
- Serviceの適切な呼び出し
- 適切なHTTPステータスコードの返却
- 期待されるレスポンスボディの返却
- 例外時の適切なエラーハンドリング
サンプルコード
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void getUserById_正常系_ユーザーが存在する場合() throws Exception {
// Given
Long userId = 1L;
User user = new User(userId, "テストユーザー", "test@example.com");
when(userService.findById(userId)).thenReturn(user);
// When & Then
mockMvc.perform(get("/api/users/{id}", userId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(userId))
.andExpect(jsonPath("$.name").value("テストユーザー"))
.andExpect(jsonPath("$.email").value("test@example.com"));
verify(userService, times(1)).findById(userId);
}
@Test
void getUserById_異常系_ユーザーが存在しない場合() throws Exception {
// Given
Long userId = 999L;
when(userService.findById(userId)).thenThrow(new UserNotFoundException("User not found"));
// When & Then
mockMvc.perform(get("/api/users/{id}", userId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("User not found"));
verify(userService, times(1)).findById(userId);
}
@Test
void createUser_正常系() throws Exception {
// Given
UserCreateRequest request = new UserCreateRequest("新規ユーザー", "new@example.com");
User createdUser = new User(1L, "新規ユーザー", "new@example.com");
when(userService.createUser(any(User.class))).thenReturn(createdUser);
// When & Then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.name").value("新規ユーザー"));
}
@Test
void createUser_異常系_バリデーションエラー() throws Exception {
// Given
UserCreateRequest request = new UserCreateRequest("", ""); // 空値でバリデーションエラー
// When & Then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors.length()").value(2));
}
}
レビュー観点
- 全てのエンドポイントがテストされているか
- 正常系と異常系の両方がテストされているか
- 入力バリデーションのエラーケースがテストされているか
- Serviceのモックが適切に設定されているか
- レスポンスの検証が適切か(ステータスコード、ボディ)
- 全てのリクエストパラメータの組み合わせがテストされているか
2. Service層のテスト
Service層ではビジネスロジックの正確性に焦点を当てます。
テスト観点
- ビジネスロジックの正確性
- ビジネスルールの検証
- 例外ケースの適切な処理
- トランザクション管理
- 依存コンポーネント(リポジトリなど)との連携
- データ変換処理の正確性
サンプルコード
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserServiceImpl userService;
@Test
void findById_正常系_ユーザーが存在する場合() {
// Given
Long userId = 1L;
User expectedUser = new User(userId, "テストユーザー", "test@example.com");
when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));
// When
User result = userService.findById(userId);
// Then
assertNotNull(result);
assertEquals(userId, result.getId());
assertEquals("テストユーザー", result.getName());
assertEquals("test@example.com", result.getEmail());
verify(userRepository, times(1)).findById(userId);
}
@Test
void findById_異常系_ユーザーが存在しない場合() {
// Given
Long userId = 999L;
when(userRepository.findById(userId)).thenReturn(Optional.empty());
// When & Then
assertThrows(UserNotFoundException.class, () -> userService.findById(userId));
verify(userRepository, times(1)).findById(userId);
}
@Test
void createUser_正常系_重複メールなし() {
// Given
User newUser = new User(null, "新規ユーザー", "new@example.com");
User savedUser = new User(1L, "新規ユーザー", "new@example.com");
when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
when(userRepository.save(any(User.class))).thenReturn(savedUser);
doNothing().when(emailService).sendWelcomeEmail(any(User.class));
// When
User result = userService.createUser(newUser);
// Then
assertNotNull(result);
assertEquals(1L, result.getId());
assertEquals("新規ユーザー", result.getName());
verify(userRepository, times(1)).findByEmail("new@example.com");
verify(userRepository, times(1)).save(any(User.class));
verify(emailService, times(1)).sendWelcomeEmail(any(User.class));
}
@Test
void createUser_異常系_メールアドレス重複() {
// Given
User existingUser = new User(1L, "既存ユーザー", "existing@example.com");
User newUser = new User(null, "新規ユーザー", "existing@example.com");
when(userRepository.findByEmail("existing@example.com")).thenReturn(Optional.of(existingUser));
// When & Then
assertThrows(DuplicateEmailException.class, () -> userService.createUser(newUser));
verify(userRepository, times(1)).findByEmail("existing@example.com");
verify(userRepository, never()).save(any(User.class));
verify(emailService, never()).sendWelcomeEmail(any(User.class));
}
@Test
void updateUserStatus_正常系_ステータス変更成功() {
// Given
Long userId = 1L;
UserStatus oldStatus = UserStatus.ACTIVE;
UserStatus newStatus = UserStatus.INACTIVE;
User user = new User(userId, "テストユーザー", "test@example.com");
user.setStatus(oldStatus);
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
// When
User result = userService.updateUserStatus(userId, newStatus);
// Then
assertEquals(newStatus, result.getStatus());
verify(userRepository).findById(userId);
verify(userRepository).save(user);
}
}
レビュー観点
- 全てのビジネスケースがテストされているか
- 境界値や特殊ケースがテストされているか
- トランザクション処理の正確性(ロールバックなど)
- 依存コンポーネントが適切にモック化されているか
- 例外処理が正しくテストされているか
- モックの呼び出し回数が期待通りか
- 副作用(外部サービス呼び出しなど)が適切に検証されているか
3. Repository/Mapper層のテスト
JPA Repositoryのテスト
JPA Repositoryではデータアクセスロジックを検証します。
テスト観点
- クエリの正確性
- フィルタ条件の正確性
- ソート順の正確性
- ページネーションの正確性
- エンティティの保存と読み込み
- リレーションシップの正確性
サンプルコード
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@BeforeEach
void setUp() {
// テストデータセットアップ
User user1 = new User(null, "ユーザー1", "user1@example.com");
user1.setStatus(UserStatus.ACTIVE);
User user2 = new User(null, "ユーザー2", "user2@example.com");
user2.setStatus(UserStatus.INACTIVE);
User user3 = new User(null, "管理者", "admin@example.com");
user3.setStatus(UserStatus.ACTIVE);
entityManager.persist(user1);
entityManager.persist(user2);
entityManager.persist(user3);
entityManager.flush();
}
@Test
void findByEmail_メールアドレスが存在する場合() {
// When
Optional<User> result = userRepository.findByEmail("user1@example.com");
// Then
assertTrue(result.isPresent());
assertEquals("ユーザー1", result.get().getName());
}
@Test
void findByEmail_メールアドレスが存在しない場合() {
// When
Optional<User> result = userRepository.findByEmail("nonexistent@example.com");
// Then
assertFalse(result.isPresent());
}
@Test
void findByStatus_有効なユーザーのみ取得() {
// When
List<User> result = userRepository.findByStatus(UserStatus.ACTIVE);
// Then
assertEquals(2, result.size());
assertTrue(result.stream().allMatch(user -> user.getStatus() == UserStatus.ACTIVE));
}
@Test
void findByNameContaining_名前部分一致検索() {
// When
List<User> result = userRepository.findByNameContaining("管理");
// Then
assertEquals(1, result.size());
assertEquals("管理者", result.get(0).getName());
}
@Test
void save_新規ユーザー保存() {
// Given
User newUser = new User(null, "新規ユーザー", "new@example.com");
// When
User savedUser = userRepository.save(newUser);
// Then
assertNotNull(savedUser.getId());
// データベースから再取得して確認
User retrievedUser = entityManager.find(User.class, savedUser.getId());
assertNotNull(retrievedUser);
assertEquals("新規ユーザー", retrievedUser.getName());
assertEquals("new@example.com", retrievedUser.getEmail());
}
}
レビュー観点
- 全てのカスタムクエリメソッドがテストされているか
- 検索条件の組み合わせがテストされているか
- 実際のデータベースアクセスが行われているか(インメモリではなく)
- 複雑なクエリが正しく動作するか
- リレーションシップの取得が正しく行われるか
- データの変更(CRUD操作)が正しく動作するか
MyBatis Mapperのテスト
MyBatis Mapperでは、SQLの正確性と結果のマッピングを検証します。
テスト観点
- SQLクエリの正確性
- パラメータバインディングの正確性
- 結果のマッピング正確性
- 動的SQLの正確性
- 複雑な結合クエリの正確性
サンプルコード
@MybatisTest
class UserMapperTest {
@Autowired
private UserMapper userMapper;
@Autowired
private SqlSession sqlSession;
@BeforeEach
void setUp() {
// テストデータをセットアップ
sqlSession.insert("insertTestUsers");
}
@Test
void findById_IDが存在する場合() {
// When
User user = userMapper.findById(1L);
// Then
assertNotNull(user);
assertEquals(1L, user.getId());
assertEquals("テストユーザー1", user.getName());
}
@Test
void findById_IDが存在しない場合() {
// When
User user = userMapper.findById(999L);
// Then
assertNull(user);
}
@Test
void findByStatus_ステータスでフィルタリング() {
// When
List<User> users = userMapper.findByStatus(UserStatus.ACTIVE.name());
// Then
assertEquals(2, users.size());
assertTrue(users.stream().allMatch(u -> u.getStatus() == UserStatus.ACTIVE));
}
@Test
void findAllWithDepartment_部署情報含めて取得() {
// When
List<UserWithDepartment> users = userMapper.findAllWithDepartment();
// Then
assertFalse(users.isEmpty());
users.forEach(u -> {
assertNotNull(u.getUser());
assertNotNull(u.getDepartment());
assertEquals(u.getUser().getDepartmentId(), u.getDepartment().getId());
});
}
@Test
void insertUser_新規ユーザー登録() {
// Given
User newUser = new User();
newUser.setName("新規ユーザー");
newUser.setEmail("new@example.com");
newUser.setStatus(UserStatus.ACTIVE);
// When
int count = userMapper.insert(newUser);
// Then
assertEquals(1, count);
assertNotNull(newUser.getId()); // 自動生成IDが設定されていることを確認
// 登録されたデータを取得して検証
User savedUser = userMapper.findById(newUser.getId());
assertNotNull(savedUser);
assertEquals("新規ユーザー", savedUser.getName());
assertEquals("new@example.com", savedUser.getEmail());
}
@Test
void findBySearchCriteria_動的SQLテスト() {
// Given
UserSearchCriteria criteria = new UserSearchCriteria();
criteria.setNameLike("テスト");
criteria.setStatus(UserStatus.ACTIVE);
// When
List<User> users = userMapper.findBySearchCriteria(criteria);
// Then
assertFalse(users.isEmpty());
users.forEach(u -> {
assertTrue(u.getName().contains("テスト"));
assertEquals(UserStatus.ACTIVE, u.getStatus());
});
}
}
レビュー観点
- 全てのSQLマッピングメソッドがテストされているか
- 動的SQLが各条件分岐でテストされているか
- 複雑なジョインクエリが正しく結果を返すか
- null値やオプションパラメータの処理が正しいか
- バッチ処理が正しく動作するか
- トランザクションが適切に処理されるか
単体テストの品質を確保するためのベストプラクティス
1. テストの独立性
各テストは他のテストに依存せず、独立して実行できるようにします。
// 悪い例
@Test
void test1_ユーザー作成() {
userService.createUser(new User("テスト", "test@example.com"));
}
@Test
void test2_作成したユーザーを検索() { // test1に依存している
User user = userService.findByEmail("test@example.com");
assertNotNull(user);
}
// 良い例
@Test
void ユーザー作成と検索() {
// テスト内でセットアップから検証まで完結
User newUser = new User("テスト", "test@example.com");
userService.createUser(newUser);
User foundUser = userService.findByEmail("test@example.com");
assertNotNull(foundUser);
}
2. GivenWhenThenパターン
テストコードを「準備(Given)」「実行(When)」「検証(Then)」の3段階に分けて記述します。
@Test
void ユーザー検索_メールアドレスで検索() {
// Given - 準備
String email = "test@example.com";
User expectedUser = new User(1L, "テストユーザー", email);
when(userRepository.findByEmail(email)).thenReturn(Optional.of(expectedUser));
// When - 実行
User actualUser = userService.findByEmail(email);
// Then - 検証
assertNotNull(actualUser);
assertEquals(expectedUser.getId(), actualUser.getId());
assertEquals(expectedUser.getName(), actualUser.getName());
assertEquals(expectedUser.getEmail(), actualUser.getEmail());
}
3. 境界値テスト
境界条件や特殊ケースを意識的にテストします。
@Test
void ページネーション_最初のページ() {
// ページ番号0、サイズ10のテスト
}
@Test
void ページネーション_最大ページ() {
// 最大ページ番号のテスト
}
@Test
void ページネーション_サイズゼロ() {
// ページサイズ0の異常系テスト
}
@Test
void 金額計算_最大値() {
// Integer.MAX_VALUE近辺の計算
}
4. 例外テスト
例外発生シナリオを明示的にテストします。
@Test
void ユーザー検索_存在しないID_例外発生() {
// Given
Long nonExistentId = 999L;
when(userRepository.findById(nonExistentId)).thenReturn(Optional.empty());
// When & Then
UserNotFoundException exception = assertThrows(
UserNotFoundException.class,
() -> userService.findById(nonExistentId)
);
assertEquals("User not found with id: 999", exception.getMessage());
}
5. モックの適切な使用
テスト対象の依存コンポーネントを適切にモック化します。
@Test
void メール送信機能付きユーザー登録() {
// Given
User newUser = new User("テスト", "test@example.com");
when(userRepository.save(any(User.class))).thenReturn(newUser);
// emailServiceのモック化
doNothing().when(emailService).sendWelcomeEmail(any(User.class));
// When
userService.registerUser(newUser);
// Then
// userRepositoryのsaveが呼ばれたことを検証
verify(userRepository, times(1)).save(any(User.class));
// emailService.sendWelcomeEmailが適切なユーザーで呼ばれたことを検証
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
verify(emailService, times(1)).sendWelcomeEmail(userCaptor.capture());
User capturedUser = userCaptor.getValue();
assertEquals(newUser.getName(), capturedUser.getName());
assertEquals(newUser.getEmail(), capturedUser.getEmail());
}
6. テストデータファクトリ
テストデータを効率的に生成するためのファクトリクラスを使用します。
public class UserTestFactory {
public static User createDefaultUser() {
return new User(1L, "テストユーザー", "test@example.com");
}
public static User createUserWithStatus(UserStatus status) {
User user = createDefaultUser();
user.setStatus(status);
return user;
}
public static List<User> createUserList(int count) {
return IntStream.range(0, count)
.mapToObj(i -> new User((long) i, "ユーザー" + i, "user" + i + "@example.com"))
.collect(Collectors.toList());
}
}
// 使用例
@Test
void アクティブユーザー一覧取得() {
// Given
List<User> activeUsers = UserTestFactory.createUserList(5).stream()
.map(u -> {
u.setStatus(UserStatus.ACTIVE);
return u;
})
.collect(Collectors.toList());
when(userRepository.findByStatus(UserStatus.ACTIVE)).thenReturn(activeUsers);
// When
List<User> result = userService.findActiveUsers();
// Then
assertEquals(5, result.size());
}
単体テストの本質に迫る高度なテクニック
1. パラメータ化テスト
同じテストロジックで異なる入力パターンをテストします。
@ParameterizedTest
@CsvSource({
"test@example.com, true",
"invalid-email, false",
"another@test.com, true",
"@missing-part.com, false",
"null, false"
})
void メールアドレスバリデーション(String email, boolean expected) {
if ("null".equals(email)) {
email = null;
}
boolean result = userService.isValidEmail(email);
assertEquals(expected, result);
}
2. プロパティベースドテスト
ランダムに生成されたデータを使用して幅広いケースをテストします。
@Property
void IDによるユーザー検索は常に一意(
@ForAll @IntRange(min = 1, max = 1000) int id
) {
// テスト対象のIDをランダム生成
Long userId = (long) id;
// この例では常に固定のユーザーを返すようにモック化
User expectedUser = new User(userId, "テスト", "test@example.com");
when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));
// テスト実行
User result = userService.findById(userId);
// 検証
assertNotNull(result);
assertEquals(userId, result.getId());
}
3. 状態ベースドテストと振る舞いベースドテストの使い分け
両方のアプローチを適切に使い分けます。
// 状態ベースドテスト(結果の状態を検証)
@Test
void ユーザー更新_状態ベースドテスト() {
// Given
User user = new User(1L, "古い名前", "old@example.com");
User updatedUser = new User(1L, "新しい名前", "new@example.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
when(userRepository.save(any(User.class))).thenReturn(updatedUser);
// When
User result = userService.updateUser(1L, "新しい名前", "new@example.com");
// Then - 結果の状態を検証
assertEquals("新しい名前", result.getName());
assertEquals("new@example.com", result.getEmail());
}
// 振る舞いベースドテスト(メソッド呼び出しを検証)
@Test
void ユーザー更新_振る舞いベースドテスト() {
// Given
User user = new User(1L, "古い名前", "old@example.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
userService.updateUser(1L, "新しい名前", "new@example.com");
// Then - メソッド呼び出しと引数を検証
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(userCaptor.capture());
User savedUser = userCaptor.getValue();
assertEquals(1L, savedUser.getId());
assertEquals("新しい名前", savedUser.getName());
assertEquals("new@example.com", savedUser.getEmail());
}
4. テストダブルの使い分け
状況に応じて様々なテストダブルを使い分けます。
// Mockを使用(振る舞いを検証)
@Test
void メールサービスモック() {
// サービスの振る舞いを検証するためのモック
EmailService mockEmailService = mock(EmailService.class);
doNothing().when(mockEmailService).sendEmail(anyString(), anyString());
// 検証
mockEmailService.sendEmail("test@example.com", "Hello");
verify(mockEmailService).sendEmail("test@example.com", "Hello");
}
// Stubを使用(戻り値を固定)
@Test
void ユーザーリポジトリスタブ() {
// 固定の戻り値を返すスタブ
UserRepository stubRepo = mock(UserRepository.class);
when(stubRepo.findById(1L)).thenReturn(Optional.of(new User(1L, "テスト", "test@example.com")));
// スタブを使用
Optional<User> user = stubRepo.findById(1L);
assertTrue(user.isPresent());
assertEquals("テスト", user.get().getName());
}
// Fakeを使用(簡易実装で代用)
@Test
void インメモリリポジトリ() {
// インメモリのフェイク実装
Map<Long, User> userMap = new HashMap<>();
UserRepository fakeRepo = new UserRepository() {
@Override
public Optional<User> findById(Long id) {
return Optional.ofNullable(userMap.get(id));
}
@Override
public User save(User user) {
if (user.getId() == null) {
user.setId((long) (userMap.size() + 1));
}
userMap.put(user.getId(), user);
return user;
}
// 他のメソッドも実装...
};
// フェイクを使用
User user = new User(null, "新規", "new@example.com");
User saved = fakeRepo.save(user);
assertNotNull(saved.getId());
Optional<User> found = fakeRepo.findById(saved.getId());
assertTrue(found.isPresent());
}
品質を保証するためのテスト戦略
1. テストピラミッド
テストの量はユニットテスト > インテグレーションテスト > E2Eテストとなるようにします。
- ユニットテスト: 各クラス・メソッドの機能を検証
- インテグレーションテスト: 複数のコンポーネントの連携を検証
- E2Eテスト: システム全体の動作を検証
// ユニットテスト(数が多い)
@Test
void validateEmail_正常なメールアドレス() {
assertTrue(emailValidator.isValid("user@example.com"));
}
// インテグレーションテスト(中程度)
@SpringBootTest
@Transactional
class UserServiceIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void registerUser_データベースに保存される() {
// Given
UserRegistrationDto dto = new UserRegistrationDto("テストユーザー", "test@example.com", "password");
// When
User user = userService.registerUser(dto);
// Then
assertNotNull(user.getId());
// データベースに保存されたことを確認
Optional<User> savedUser = userRepository.findById(user.getId());
assertTrue(savedUser.isPresent());
}
}
// E2Eテスト(少数)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class UserRegistrationE2ETest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void registerUserEndToEnd() {
// Given
UserRegistrationRequest request = new UserRegistrationRequest("E2Eテスト", "e2e@example.com", "password");
// When
ResponseEntity<UserResponse> response = restTemplate.postForEntity("/api/users", request, UserResponse.class);
// Then
assertEquals(HttpStatus.CREATED, response.getStatusCode());
assertNotNull(response.getBody());
assertNotNull(response.getBody().getId());
}
}
2. テストカバレッジ目標
コードカバレッジの目標を設定します。
- ライン/ステートメントカバレッジ: 各行が実行されたか
- ブランチカバレッジ: 全ての分岐が実行されたか
- 条件カバレッジ: 複合条件の全ての組み合わせがテストされたか
// 条件カバレッジの例
public boolean isEligibleForDiscount(User user) {
return user.isPremium() && user.getRegistrationDate().isBefore(LocalDate.now().minusYears(1));
}
// テストケース
@Test
void isEligibleForDiscount_プレミアムかつ1年以上() {
User user = UserTestFactory.createPremiumUser();
user.setRegistrationDate(LocalDate.now().minusYears(2));
assertTrue(service.isEligibleForDiscount(user));
}
@Test
void isEligibleForDiscount_プレミアムだが1年未満() {
User user = UserTestFactory.createPremiumUser();
user.setRegistrationDate(LocalDate.now().minusMonths(6));
assertFalse(service.isEligibleForDiscount(user));
}
@Test
void isEligibleForDiscount_非プレミアムで1年以上() {
User user = UserTestFactory.createNormalUser();
user.setRegistrationDate(LocalDate.now().minusYears(2));
assertFalse(service.isEligibleForDiscount(user));
}
@Test
void isEligibleForDiscount_非プレミアムで1年未満() {
User user = UserTestFactory.createNormalUser();
user.setRegistrationDate(LocalDate.now().minusMonths(6));
assertFalse(service.isEligibleForDiscount(user));
}
3. ミューテーションテスト
コードの突然変異に対するテストの耐性を検証します。
// ミューテーションテストの例(PitestなどのツールでJavaでも実施可能)
// 元のコード
public int calculateDiscount(int price, boolean isPremium) {
if (isPremium) {
return price * 9 / 10; // 10%割引
}
return price;
}
// ミューテーションテスト - 演算子の変更 (*9 -> *8)
// このミューテーションに気づけないテストは不十分
// 適切なテスト
@Test
void calculateDiscount_プレミアム会員は10パーセント割引() {
int price = 1000;
int discounted = service.calculateDiscount(price, true);
assertEquals(900, discounted); // 正確な値をテスト
}
// 不適切なテスト
@Test
void calculateDiscount_プレミアム会員は割引あり() {
int price = 1000;
int discounted = service.calculateDiscount(price, true);
assertTrue(discounted < price); // ミューテーションに気づけない
}
4. プロパティベーステスト
QuickCheck/jqwikなどを使用したプロパティベーステスト
@Property
void calculateTax_常に税込み価格は元の価格以上(@ForAll @IntRange(min = 0, max = 100000) int price) {
double taxRate = 0.10; // 10%
int taxIncluded = taxService.calculateTaxIncluded(price);
// 税込み価格は元の価格以上であるべき
assertTrue(taxIncluded >= price);
// 税込み価格は元の価格 + 税額に等しいべき
assertEquals(price + Math.round(price * taxRate), taxIncluded);
}
単体テスト実践のための高度なパターン
1. テストフィクスチャ
テストデータの準備を効率化します。
// TestFixture設計パターン
public class UserTestFixture {
private Long id = 1L;
private String name = "デフォルトユーザー";
private String email = "default@example.com";
private UserStatus status = UserStatus.ACTIVE;
private LocalDate registrationDate = LocalDate.now();
public UserTestFixture withId(Long id) {
this.id = id;
return this;
}
public UserTestFixture withName(String name) {
this.name = name;
return this;
}
public UserTestFixture withEmail(String email) {
this.email = email;
return this;
}
public UserTestFixture withStatus(UserStatus status) {
this.status = status;
return this;
}
public UserTestFixture withRegistrationDate(LocalDate date) {
this.registrationDate = date;
return this;
}
public User build() {
User user = new User();
user.setId(id);
user.setName(name);
user.setEmail(email);
user.setStatus(status);
user.setRegistrationDate(registrationDate);
return user;
}
public static UserTestFixture aUser() {
return new UserTestFixture();
}
}
// 使用例
@Test
void プレミアムユーザーのみ特典付与() {
// Given
User premiumUser = UserTestFixture.aUser()
.withId(1L)
.withStatus(UserStatus.PREMIUM)
.withRegistrationDate(LocalDate.now().minusYears(2))
.build();
User normalUser = UserTestFixture.aUser()
.withId(2L)
.withStatus(UserStatus.ACTIVE)
.build();
// When
boolean premiumEligible = userService.isEligibleForBenefit(premiumUser);
boolean normalEligible = userService.isEligibleForBenefit(normalUser);
// Then
assertTrue(premiumEligible);
assertFalse(normalEligible);
}
2. テストコンテキスト
テストの前提条件をわかりやすく記述します。
public class UserServiceTestContext {
private UserRepository userRepository = mock(UserRepository.class);
private EmailService emailService = mock(EmailService.class);
private UserService userService;
private User testUser;
public UserServiceTestContext() {
userService = new UserServiceImpl(userRepository, emailService);
testUser = UserTestFixture.aUser().build();
}
public UserServiceTestContext withExistingUser(User user) {
this.testUser = user;
when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
when(userRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
return this;
}
public UserServiceTestContext withNonExistingUser(Long userId) {
when(userRepository.findById(userId)).thenReturn(Optional.empty());
return this;
}
public UserServiceTestContext withSuccessfulSave() {
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
return this;
}
public UserService getUserService() {
return userService;
}
public User getTestUser() {
return testUser;
}
public UserRepository getUserRepository() {
return userRepository;
}
public EmailService getEmailService() {
return emailService;
}
}
// 使用例
@Test
void ユーザー更新_成功() {
// Given
UserServiceTestContext context = new UserServiceTestContext()
.withExistingUser(UserTestFixture.aUser().withId(1L).build())
.withSuccessfulSave();
UserService userService = context.getUserService();
User user = context.getTestUser();
// When
UserUpdateRequest request = new UserUpdateRequest("新しい名前", "new@example.com");
User updatedUser = userService.updateUser(user.getId(), request);
// Then
assertEquals("新しい名前", updatedUser.getName());
assertEquals("new@example.com", updatedUser.getEmail());
verify(context.getUserRepository()).save(any(User.class));
}
3. ポケットモンスターパターン
全ての条件分岐を捕まえる(テストする)アプローチ。
/**
* ポケットモンスターパターン - 全ての条件分岐をテストで「捕まえる」
*/
public class OrderDiscountServiceTest {
private OrderDiscountService service;
@BeforeEach
void setUp() {
service = new OrderDiscountService();
}
// 金額による分岐テスト(境界値テスト)
@Test
void calculateDiscount_少額注文() {
assertEquals(0, service.calculateDiscount(999)); // 1000円未満は割引なし
}
@Test
void calculateDiscount_中額注文_下限() {
assertEquals(100, service.calculateDiscount(1000)); // 1000円ちょうど
}
@Test
void calculateDiscount_中額注文_上限() {
assertEquals(499, service.calculateDiscount(4999)); // 5000円未満
}
@Test
void calculateDiscount_高額注文_下限() {
assertEquals(750, service.calculateDiscount(5000)); // 5000円ちょうど
}
@Test
void calculateDiscount_高額注文_標準() {
assertEquals(1500, service.calculateDiscount(10000)); // 標準的な高額注文
}
// ユーザータイプによる分岐テスト
@Test
void applyDiscount_一般ユーザー() {
Order order = new Order(5000);
User user = UserTestFixture.aUser().withStatus(UserStatus.NORMAL).build();
service.applyDiscount(order, user);
assertEquals(4250, order.getFinalPrice()); // 通常割引15%
}
@Test
void applyDiscount_プレミアムユーザー() {
Order order = new Order(5000);
User user = UserTestFixture.aUser().withStatus(UserStatus.PREMIUM).build();
service.applyDiscount(order, user);
assertEquals(4000, order.getFinalPrice()); // プレミアム割引20%
}
@Test
void applyDiscount_VIPユーザー() {
Order order = new Order(5000);
User user = UserTestFixture.aUser().withStatus(UserStatus.VIP).build();
service.applyDiscount(order, user);
assertEquals(3750, order.getFinalPrice()); // VIP割引25%
}
// 特別日の分岐テスト
@Test
void applyDiscount_特別セール日() {
// セール日のモック
DiscountCalendar calendar = mock(DiscountCalendar.class);
when(calendar.isSpecialSaleDay(any())).thenReturn(true);
service = new OrderDiscountService(calendar);
Order order = new Order(5000);
User user = UserTestFixture.aUser().withStatus(UserStatus.NORMAL).build();
service.applyDiscount(order, user);
assertEquals(3750, order.getFinalPrice()); // 通常割引+特別割引
}
}
4. テストケースクラス
複雑なテストケースを整理する方法。
/**
* 複雑なテストケースを整理するためのクラス
*/
class PricingRuleTest {
@Nested
class 基本価格計算 {
@Test
void 標準価格() {
// テスト内容
}
@Test
void 特別価格() {
// テスト内容
}
}
@Nested
class 数量割引 {
@Test
void 少量購入() {
// テスト内容
}
@Test
void 大量購入() {
// テスト内容
}
}
@Nested
class 季節割引 {
@Test
void 夏季割引() {
// テスト内容
}
@Test
void 冬季割引() {
// テスト内容
}
}
@Nested
class 複合割引 {
@Test
void 数量割引と季節割引の組み合わせ() {
// テスト内容
}
}
}
単体テストレビューのためのチェックリスト
1. コードカバレッジ
- ステートメントカバレッジは目標以上か(通常80%以上)
- ブランチカバレッジは目標以上か(通常70%以上)
- 複雑な条件式に対する条件カバレッジは十分か
- 例外パスのカバレッジは十分か
- テストされていないコードには意図的な理由があるか
2. テスト品質
- テスト名が目的を明確に示しているか
- GivenWhenThenパターンが適用されているか
- 1つのテストは1つの機能/動作のみをテストしているか
- テスト間の独立性が保たれているか
- 境界値と特殊ケースがテストされているか
- ビジネスロジックの正確性が検証されているか
- モックとスタブが適切に使用されているか
- 不要な依存関係がモック化されているか
3. テスト可読性
- テストコードは明確で理解しやすいか
- 冗長なコードやボイラープレートが最小化されているか
- テストデータの準備が整理されているか
- テストの意図が明確か
- 検証(アサーション)が具体的で明確か
4. テスト保守性
- テストが脆弱(小さな実装変更で壊れやすい)ではないか
- テストヘルパーやフィクスチャが適切に使用されているか
- 重複コードが最小化されているか
- テストデータ生成が一元管理されているか
5. テスト速度
- テストの実行速度は許容範囲内か
- 不要な外部依存(DB、ネットワークなど)がないか
- インメモリデータベースなど高速化手法が適用されているか
単体テストの本質:まとめ
単体テストの本質は以下の3点に集約されます:
- 信頼性の証明: コードが期待通りに動作することを証明する
- 設計品質の向上: テスト可能なコードは自然と高品質な設計になる
- 回帰バグの防止: 将来の変更で発生する可能性のあるバグを早期発見する
単体テストは単に「テストを書く」という作業ではなく、「品質を設計する」プロセスです。テストカバレッジを満たすことだけを目的とせず、ビジネスロジックの正確性を保証するための重要な手段と位置付けましょう。
テストコードも本番コードと同様に重要な資産です。リファクタリング、パターン適用、継続的な改善を通じて、テストコード自体の品質も高めていくことが大切です。
実用リソース
- JUnit 5: https://junit.org/junit5/docs/current/user-guide/
- Mockito: https://site.mockito.org/
- AssertJ: https://assertj.github.io/doc/
- JaCoCo: https://github.com/jacoco/jacoco
以上が単体テストの完全ガイドとなります。このガイドを参考に、高品質なテストコードを作成し、アプリケーションの信頼性向上に役立ててください。