6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

株式会社クライドAdvent Calendar 2022

Day 22

[Java] テスタブルにリファクタして、プライベートメソッドをテストしていこう

Last updated at Posted at 2022-12-22

この記事はソースコードがテスタブルになるようにリファクタを行い、プライベートメソッドをJavaのリフレクションを使ってテストしていく方法を紹介します。
※SpringFrameworkを利用している前提になります。

リファクタ前

まず元のソースコードです。beforeパッケージに格納しています。

image.png

こちらのUserServieクラスは、publicメソッドcalculateTimeZoneAgeつ持っています。フィールドに@AutowiredでUserRepositoryを注入しています。
UserRepositoryはDBアクセスするクラスでuserIdをもとに、DBにアクセスしてUserクラスを返却するメソッドgetUserを持っています。
calculateTimeZoneAgeというメソッドは、ユーザーが住んでいるタイムゾーンを考慮して、年齢を計算して返却するメソッドとなっています。

UserService.java
@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;
    }
}
User.java
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メソッドはテスタブルでしょうか?ためしに頑張って以下のようにテストを書いてみましょう。

UserServiceTest.java
@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にはインターフェースを定義して、インターフェースに対して実装するようにしました。
image.png

そしてUserServiceクラスはUserServiceImplクラスとなり、以下のように生まれ変わりました。

UserServiceImpl.java
@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のメソッドの中身をプライベートメソッドに切り出していきました。今回はサンプルなので過剰に切り出しています。

この変更により、ユニットテストクラスはどのように変化したでしょうか?

UserServiceImplTest.java
@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

今回の学び

  • 依存はコンストラクタから設定しましょう
  • プライベートメソッドに処理を分割していって、細かくテストしましょう

以上

6
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?