はじめに
JUnitでテストを書いていて、「えっ、なんでこのテスト失敗するの?」と頭を抱えた経験はありませんか?
今回は、レガシーコードに対して仕様化テストを記述していた際に、参照の値渡しに気づかず時間を無駄にしたので備忘録として残しておきます。
仕様化テストとは?
「レガシーコード改善ガイド」に登場する単語で、ざっくり言うとテストや仕様書が存在しないシステムに対して、既存の仕様を表現する + 既存の仕様を保護するために作成する単体テストの事です。
問題の発生
テストコードの実装
ある日、以下のようにサービスのテストを書いていました。
UserRepositoryをモック化しており、返り値にexpectedUsersを指定しています。
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void testProcessUsers() {
// Arrange
String[] expectedUsers = {"Alice", "Bob", "Charlie"};
// モックの振る舞いを定義
when(userRepository.findAllUserNames())
.thenReturn(expectedUsers);
// Act
String[] result = userService.processUsers();
// Assert
assertArrayEquals(expectedUsers.size + 1, result.size, "processUsersの返り値のサイズは取得したユーザー数 + 1");
}
}
プロダクションコードの実装
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public String[] processUsers() {
String[] users = userRepository.findAllUserNames();
// 管理者ユーザーを追加
users = Arrays.copyOf(users, users.length + 1);
users[users.length - 1] = "Admin";
return users;
}
}
まさかのテスト失敗
上記のコードを実行すると、テストが失敗しました。
Expected: 5
Actual: 4
「あれ?expectedUsersは3件のデータがあって、+1したら4になるはずなのに何で5?…。」
問題の本質:Javaの参照の値渡し
Javaにおける配列の扱い
Javaでは、配列はオブジェクトとして扱われ、変数には配列へのポインタが格納されます。
String[] array1 = {"A", "B", "C"};
String[] array2 = array1; // 参照のコピー
array2[0] = "Z";
System.out.println(array1[0]); // "Z" が出力される
MockitoのthenReturnの動作
thenReturn()メソッドは、指定されたオブジェクトの参照をそのまま返すため、以下のような問題が発生します:
- モックが配列の参照を返す
- プロダクションコードがその配列を変更
- テストコードで使用する期待値も同じ参照を指しているため、値が変更されている
- アサーションが失敗
解決策:thenAnswerを使用して毎回新しいインスタンスを返す
@Test
void testProcessUsers() {
String[] expectedUsers = {"Alice", "Bob", "Charlie"};
// 毎回新しい配列インスタンスを返す
when(userRepository.findAllUserNames())
.thenAnswer(invocation -> expectedUsers.clone());
String[] result = userService.processUsers();
// 期待値は元の配列のまま
// 処理結果は管理者が追加されている
assertEquals(expectedUsers.size + 1, result.length, "processUsersの返り値のサイズは取得したユーザー数 + 1");
}
thenAnswerを使用することで、モックメソッドが呼び出されるたびに新しい配列インスタンスを生成して返すようになります。これにより、プロダクションコードで配列を変更しても、テストコードの期待値には影響しません。
まとめ
-
Mockitoの
thenReturn()は参照をそのまま返す- 配列やミュータブルなオブジェクトを返す場合は要注意
-
Javaの配列は常に参照の値渡し
- 配列を渡すことは、そのメモリアドレスを渡すこと
普段あまり参照の値渡しなどを意識していなかったツケが回ってきました。
最近goを勉強していたおかげでサラッと気づけて良かったです。