この記事はソースコードがテスタブルになるようにリファクタを行い、プライベートメソッドをJavaのリフレクションを使ってテストしていく方法を紹介します。
※SpringFrameworkを利用している前提になります。
リファクタ前
まず元のソースコードです。beforeパッケージに格納しています。
こちらのUserServieクラスは、publicメソッドcalculateTimeZoneAge
つ持っています。フィールドに@AutowiredでUserRepositoryを注入しています。
UserRepositoryはDBアクセスするクラスでuserIdをもとに、DBにアクセスしてUserクラスを返却するメソッドgetUser
を持っています。
calculateTimeZoneAge
というメソッドは、ユーザーが住んでいるタイムゾーンを考慮して、年齢を計算して返却するメソッドとなっています。
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/**
* Userの住んでいるゾーンでの年齢を計算します。
*/
public long calculateTimeZoneAge(int userId){
User user = userRepository.getUser(userId);
if (ObjectUtils.isEmpty(user)){
throw new RuntimeException(String.format("UserId does not exist, userId={}", userId));
}
LocalDate birthday = LocalDate.parse(user.getBirthday(), DTF);// yyyy-MM-ddという文字列の誕生日からLocalDateを生成
ZonedDateTime now = ZonedDateTime.now(user.getZoneId());// UserのZoneIdでの現在日時を取得
LocalDate todayOnUserZone = LocalDate.of(now.getYear(), now.getMonth(), now.getDayOfMonth());// Userのnowから日付だけ取り出して、LocalDateを生成
long age = ChronoUnit.YEARS.between(birthday, todayOnUserZone);// LocalDate同士の年の差を計算
return age;
}
}
public class User {
private int userId;
/**
* yyyy-mm-dd
*/
private String birthday;
/*
* ユーザーが住んでいるゾーン
* EX) US/Pacific (UTC-07:00), Asia/Tokyo (UTC+09:00), Asia/Manila (UTC+08:00)
*/
private String zoneId;
public User(int userId, String birthday, String zoneId) {
this.userId = userId;
this.birthday = birthday;
this.zoneId = zoneId;
}
...
テストを試みる
さてこのcalculateTimeZoneAge
メソッドはテスタブルでしょうか?ためしに頑張って以下のようにテストを書いてみましょう。
@SpringBootTest
public class UserServiceTest {
@Autowired
private UserService userService;
@Test
public void calculateAge(){
long age = userService.calculateTimeZoneAge(???);// userIdどうしたらいい?
}
}
まずここで止まると思います。
calculateTimeZoneAge
メソッドに渡すuserIdをどうしたらいいでしょうか?
そもそもUnitTestの中でDBにアクセスしないとUserのデータが取れないしと悩むと思います。
他にも以下のような問題に当たると思います。
- Repositoryクラスに強く依存しているので、そちらの挙動に左右される。(=DBアクセスがある)
- LocalDate.parseの挙動のみをテストしたいのにできない。
- ChronoUnit.YEARS.betweenの挙動のみをテストしたいのにできない。
- userIdが存在しない場合の挙動をチェックしたいのにできない。
う〜ん、なかなかテストが書きづらいですね。では、リファクタしてみましょう。
リファクタしてみよう
まずリファクタ後のパッケージ構造です。
UserRepositoryとUserServiceにはインターフェースを定義して、インターフェースに対して実装するようにしました。
そしてUserServiceクラスはUserServiceImplクラスとなり、以下のように生まれ変わりました。
@Service
public class UserServiceImpl implements UserServiceInterface{
private UserRepositoryInterface userRepository;
private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@Autowired
public UserServiceImpl(UserRepositoryInterface userRepository){
this.userRepository = userRepository;
}
// Userの住んでいるゾーンでの年齢を計算します。
public long calculateTimeZoneAge(int userId){
User user = userRepository.getUser(userId);
if (ObjectUtils.isEmpty(user)){
throw new RuntimeException(String.format("UserId does not exist, userId=%s", userId));
}
LocalDate birthday = convertLocalDate(user.getBirthday());
LocalDate todayOnUserZone = generateNowLocalDateFromTimeZone(user.getZoneId());
long age = this.calculateYearsBetween(birthday, todayOnUserZone);
return age;
}
// yyyy-MM-ddという文字列から、LocalDateを生成
private LocalDate convertLocalDate(String dateStr){
return LocalDate.parse(dateStr, DTF);
}
// zoneIdから現時点でのLocalDateを生成
private LocalDate generateNowLocalDateFromTimeZone(String zoneId){
ZonedDateTime now = ZonedDateTime.now(ZoneId.of(zoneId));
LocalDate todayFromZoneId = LocalDate.of(now.getYear(), now.getMonth(), now.getDayOfMonth());
return todayFromZoneId;
}
// LocalDateを比べて、何年差あるのか計算する
private long calculateYearsBetween(LocalDate from, LocalDate to){
return ChronoUnit.YEARS.between(from, to);
}
}
大きな変更としては2つあります。
- リファクタ前はUserRepositoryを直接フィールドにAutowiredで依存性を注入していましたが、依存性を下げるためにUserRepositoryImplへの直接の参照ではなく、UserRepositoryInterfaceとインターフェースへの参照に変更しました。また、フィールドでの依存注入ではなくて、コンストラクターから依存性を注入する ように変更しました。
-
calculateTimeZoneAge
のメソッドの中身をプライベートメソッドに切り出していきました。今回はサンプルなので過剰に切り出しています。
この変更により、ユニットテストクラスはどのように変化したでしょうか?
@SpringBootTest
public class UserServiceImplTest {
@Test
public void calculateTimeZoneAge(){
UserRepositoryInterface mockUserRepo = mock(UserRepositoryInterface.class);// Mockを生成
when(mockUserRepo.getUser(1)).thenReturn(new User(1, "2001-02-04", "America/Los_Angeles"));// Stubメソッド設定
UserServiceImpl userService = new UserServiceImpl(mockUserRepo);// MockのRepositoryクラスをコンストラクタから注入して、UserServiceImplを生成
long age = userService.calculateTimeZoneAge(1);
assertTrue(age > 20);// 現在時刻に依存して、チェックが難しいので、実装日より将来かどうかだけをチェックする
}
// Exceptionのテスト
@Test
public void calculateTimeZoneAgeUserNotExist(){
UserServiceImpl userService = new UserServiceImpl(mock(UserRepositoryInterface.class));
try {
userService.calculateTimeZoneAge(2);// UserId=2は存在しないので、エラーになる
} catch (RuntimeException e) {
assertTrue(e.getMessage().startsWith("UserId does not exist"));
return;
}
assertTrue(false, "Should not reach here");
}
...
}
非常にテストが書きやすくなりました。
まずMockitoを利用して、UserRepositoryImplへの依存をコンストラクターで注入することができています。これこそがAutowiredや依存性注入機能のメリットです。フィールドに対して依存性を注入しているとこのようなことまできません(Reflection使えばできなくはないですが、やりすぎです)。
また、クラス間の依存を減らしたり、別の実装を差し込めるようにするためにもInterfaceに依存してもらうようにしています。Mockのお作法的にもInterfaceをMockする感じではあると思うのですが、まぁ過剰にインターフェース作りすぎなくてもいいと思います。実装クラスもMockにできますので。
プライベートメソッドをテストする
そしてプライベートメソッドである以下をReflectionを使って強引にテストします。
例えば、以下のプライベートメソッドcalculateYearsBetween
をテストしてみましょう。
private long calculateYearsBetween(LocalDate from, LocalDate to){
return ChronoUnit.YEARS.between(from, to);
}
@Test
public void calculateYearsBetween() throws NoSuchMethodException, SecurityException, IllegalAccessException, IllegalArgumentException, InvocationTargetException{
UserServiceImpl userService = new UserServiceImpl(null);//mockすら不要
Method method = userService.getClass().getDeclaredMethod("calculateYearsBetween", LocalDate.class, LocalDate.class);// ReflectionでMethodを定義
method.setAccessible(true);// プライベートメソッドアクセス許可する
long result = (long) method.invoke(userService, LocalDate.of(2000, 1, 1), LocalDate.of(2000, 12,31));// Methodを実行する
assertEquals(result, 0);
// 境界値テスト
result = (long) method.invoke(userService, LocalDate.of(2010, 2, 1), LocalDate.of(2020, 1,31));
assertEquals(result, 9);
result = (long) method.invoke(userService, LocalDate.of(2010, 2, 1), LocalDate.of(2020, 2,1));
assertEquals(result, 10);
// うるう年テスト
result = (long) method.invoke(userService, LocalDate.of(2008, 2, 29), LocalDate.of(2018, 2,28));
assertEquals(result, 9);
result = (long) method.invoke(userService, LocalDate.of(2008, 2, 29), LocalDate.of(2018, 3,1));
assertEquals(result, 10);
result = (long) method.invoke(userService, LocalDate.of(2008, 2, 29), LocalDate.of(2020, 2,29));
assertEquals(result, 12);
}
このようにリファクタすることで以下のようなメリットがあります。
- 外部クラスへの依存をConstructorを使って渡す方法を取ることにより、依存クラスのmock化がしやすくなります。
- privateメソッドを直接テストすることが可能です
- TDDな開発が非常にしやすいです
- メソッド内で外部クラスへの依存がなければ、mock化すら不要です
小さいメソッドに分割して、確実にテストしながら進めていくことで、最終的にはそれらの小さいメソッドをメインのメソッド(今回だとcalculateTimeZoneAge
)から呼べば実装完了となるようにしましょう。メインのメソッドにロジックを持たすとまたそれもテストしないといけなくなるので、ロジックはprivateメソッドに切り出して、メインのメソッドは「ただ呼び出すだけ」くらいの役割にしておくことをお勧めします。またコードの見通しも立てやすいです。
今回のコードは以下にあります。
https://github.com/nishiyama-k0130/private-method-unit-test
今回の学び
- 依存はコンストラクタから設定しましょう
- プライベートメソッドに処理を分割していって、細かくテストしましょう
以上