2
3

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.

JavaAdvent Calendar 2022

Day 19

JUnit5 + Mockitoをつかったテストコードの書き方

Last updated at Posted at 2022-12-19

はじめに

Java Advent Calendar 2022の記事です。事前に作成しきれなかったので、当日に編集して投稿してます。

JUnit5 + Mockito + spring boot をつかってテストコードを書く仕事があったので、そのときの知見のまとめです。JUnit4から比べると、だいぶいろんなことがやりやすくなっています。

  • @Nestedによる階層化
  • XXXSpecを参考にしたメソッド名のネーミング
  • Arrange-Act-Assertパターンによるテストメソッドの実装
  • Extensionを使ったテストの事前・事後処理の共通化
  • @ParameterizedTestを使ったパターンの網羅

によって、保守性・可読性と、パラメタのパターンの網羅の、実践的なやり方の紹介になります。

テストクラス全体の構造

@Nested によってテストクラスに、階層構造を簡単にいれられるようになりました。
それを利用して、 メソッド -> ハイレベルのテストケース -> 具体的なテストケース という風に、ネストしていくにつれ、詳細になっていく形をとります。

テストするコードとして、下記のようなspring bootでのcontrollerクラスをサンプルとしてあげます。

UserController
@RequestMapping("users")
@Controller
class UserController

   private final UserService userService;

   UserController(UserService userService) {
        this.userService = userService;
   }

   @GetMapping("{id}")
   public User findById(@PathVariable long id) {
       return userService.findById(id);
   }

   @GetMapping
   public List<User> findAll() {
       return userService.findAll();
   }

これに対して、下記のようなテストコードを書いていきます。

UserControllerTest
class UserControllerTest {

  @Mock
  UserService userService;

  @InjectMocks
  UserController userController;

  @Mock
  User mockUser; 

  @BeforeEach
  void setup() {
     MockitoAnnotations.openMocks(this);
  }

  @Nested
  class findBy {

     @Nested
     class existingId {

         @Test
         void shouldReturnUser() {
             when(userService.findBy(1L)).thenReturn(mockUser);

             User actual = controller.findBy(1L)

             verify(userService, times(1)).findBy(1L); 
             assertEquals(mockUser, actual);
         }

     }

    @Nested
    class notFoundId {

        @Test
        void shouldThrowNotFoundException() {
             when(userService.findBy(anyLong())).thenThrow(NotFoundException.class);

             assertThrows(NotFoundException.class () -> controller.findBy(1L));
        }

    } 

  }

  @Nested
  class findAll {
 
       @Test
       void shouldCallFindAll() {
           when(userService.findAll()).thenReturn(Collections.emptyList());

           controller.findAll();
         
           verify(userService, times(1)).findAll();
       } 

  }    

}

ここから、このテストコードの各部分について解説をしていきます。

Mockの使い方

  // DIの対象
  @Mock
  UserService userService;

  // mockされたものをテスト対象へ注入
  @InjectMocks
  UserController userController;

  // テスト用のインスタンス生成のため
  @Mock
  User mockUser; 

  @BeforeEach
  void setup() {
     // @Mockなどのアノテーションがついたものの初期化
     MockitoAnnotations.openMocks(this);
  }

かつてはMockito#mock でモック化されたインスタンスをつくるやり方でした。これだと、Genericsの場合にうまく対応できない問題があります。

@Mock + @BeforeEachMockitoAnnotations#openMocks の組み合わせで、モック化されたインスタンスを作成します。

DIの対象はもちろん、テスト用に都合のいい状態をもったインスタンスをつくるためにも、@Mockを使います。
これは、テストコードのためだけのインスタンス生成のメソッドを、productionコードや、テストパッケージ下につくるのをなるべく回避するためです。複数のケースで使う場合は、ParameterResolverなどをつかって提供する分にはよいのですが、特定のテストケースのためだけや、逆に不特定にいろんなテストケースに使われるメソッドができてしまうと、テストコード自体の保守が難しくなるからです。

単発なら、テストメソッド内でMockito#whenを、同じ条件を満たす複数のテストケースで使うなら、@Nested + @BeforeEach をつけてsetupメソッド内でmockの振る舞いを設定すれば、大概のケースは対応できます。

Nestedクラスの命名規則

  • 最初の階層は、テスト対象のメソッド名
  • その下は、テスト対象のメソッドに対しての、ハイレベルのテストケース
    • 「正常系」「異常系」など、大きく2つに分ける
    • 値が存在する、存在しない
    • 以上以下などの境界値の各ケース

最初にメソッドで階層をつくることで、そのメソッドに改修が入ったり、削除された場合など、テストコードの変更の範囲がすぐに特定できるようになります。
その次に、テストすべき、ハイレベルのケースで階層をいれることで、仕様や、変更があったときの影響範囲がわかりやすくなります。@BeforeEachと組み合わせれば、前提条件の記述を一箇所にまとめ、複数のテストケースに再利用させやすくなります。
これだけでも、可読性・保守性がだいぶ維持できるようになります。

テストメソッドの命名規則

assertしている内容がメソッド名からわかるように

  • shouldXXX
  • XXXShouldYYY

の形にします。assertしたい内容によっては、canなどの別の助動詞もありとします。

だいぶ前ですが、playframework+scalaをやった経験があり、specsがテストコードのフレームワークでした。それをまねたネーミングです。

assertしている内容がわかるのと、チーム内で最低限度の統一感を出すために編み出したルールなので、これを満たせるなら別のネーミングでなんら問題ないです。

テストメソッド内の構造

Arrange - Act - Assert パターンを採用します。

  1. 前提条件を設定(Arrange)
  2. テスト対象のメソッドを実行(Act)
  3. 期待どおりの結果を確認(Assert)

の順に書いていきます。すこしこなれてくると、自然とこういった形になることが多いですが、名前をつけて、意識的にやるのは、再現性が高まるのでおすすめです。

@Test
void shouldReturnUser() {
     // Arrange
     when(userService.findBy(1L)).thenReturn(mockUser);

     // Act
     User actual = controller.findBy(1L)

     // Assert
     verify(userService, times(1)).findBy(1L); 
     assertEquals(mockUser, actual);
}

ちなみに、これはBDDでいうところの、Given - When - Then パターンの言い換え


ExtendedWithを使った事前・事後処理の共通化

JUnit4のTestRunnerなどに相当するものは、Junit5ではExtension になります。

  • テスト用のwebサーバを起動する
  • テスト用のDBサーバを起動する
  • テスト用のランダムパラメタを提供する
  • Utilクラスのstaticのモック化

などは、Extensionの機能で実現できます。

junit5のサンプルコードのリポジトリにあるこちらを参考に、staticメソッドのモック化の共通化のExtesionを作成しました。

UserUtilExtension
public class UserUtilExtension implements BeforeEachCallBack, AfterEachCallback, ParameterResolver {

  	@Retention(RetentionPolicy.RUNTIME)
	@Target(ElementType.PARAMETER)
	public @interface MockedUserUtil {
	}

  private MockedStatic<UserUtil> mockedUserUtil;

  @Override
  public void beforeEach(ExtensionContext context) {
      // テストメソッドごとのにmockインスタンス生成
      mockedUserUtil = mockStatic(UserUtil.class);
      mockedUserUtil.when(mockedUserUtil::getId).thenReturn(1L);
  }

  @Override
  public void afterEach(ExtensionContext context) {
      if (mockedUtil != null) {
          // MockedStaticはAutoclosableなので、close処理を呼び出し
          mockedUserUtil.close();
      }
  }

  @Override
  public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
      return mockedUserUtil;
  }

  @Override 
  public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
       return parameterContext.isAnnotated(MockedUserUtil.class);

}

mockitoのみでも、下記のようなコードで、staticメソッドをモック化できるようになりました。

try (MockedStatic<UserUtil> mocked = mockStatic(UserUtil.class)) {
 mocked.when(UserUtil::getId).thenReturn(1L);
 assertEquals(1L, UserUtil.getId());
 mocked.verify(UserUtil::getId);
}

ただ、このやり方の難点としては、try-with-resources構文が必要で、ネストが一段深くなり、読みづらくなってしまうことです。

単発なら問題ないですが、ひとつのクラスのテストクラスで、これが複数回でてくると、厳しくなってきます。
staticメソッドではなく、インスタンスメソッドに依存するようにリファクタリングできれば回避できますが、それが難しい場合の、対象療法です。

例のためだけですが、下記のようなUserUtil#getIdに依存したクラスがあるとします。

SampleController
public SampleController {

   private UserService userService;

   ...
   
   public long getUserId() {
       return UserUtil.getId(); 
   }

   
   public String getUserName() {
       User user = userService.findById(UserUtil.getId());
       return user.getName();
   }

}

これに対して、Extensionをつかうと、テストコードは以下のようになります。

SampleControllerTest
@ExtendWith(UserUtilExtension.class)
public class SampleControllerTest {

  @Mock
  UserService userService;

  @InjectMocks
  SampleController sampleController;

  @Mock
  User mockUser; 

  @BeforeEach
  void setup() {
     MockitoAnnotations.openMocks(this);
  }

  @Test
  void shouldReturnUserId() {
      long actual = sampleController.getUserId();

      assertEquals(1L, actual);
  }

  @Test
  void shouldReturnUserName() {
      when(mockUser.getName()).thenReturn("test")
      when(userService.findById(1L)).thenReturn(mockUser);

      String actual = sampleController.getUserName();

      assertEquals("test", actual);
  }

  @Test
  void shouldReturn2(@MockedUserUtil MockedStatic<UserUtil> mockedUserUtil) {
      // モックの振る舞いを変えたい場合はparameter resolver経由で取得
      mockedUserUtil.when(UserUtil::getId).thenReturn(2L);

      long actual = sampleController.getUserId();

      assertEquals(2L, actual);
  }
    
}

Extensionなしだと、下記のように、毎回try-with-resources構文を使う形になります。

@Test 
shouldReturnUserId() {
   try(MockedStatic<UserUtil> mocked = mockStatic(UserUtil.class)) {
       mocked.when(UserUtil::getId).thenReturn(1L);
       ...
  }
}

@Test
shouldReturnUserName() {
  try(MockedStatic<UserUtil> mocked = mockStatic(UserUtil.class)) {
       mocked.when(UserUtil::getId).thenReturn(1L);
       ...
  }
}

ParamterizedTestを使ったパターンの網羅

JUnitへのサポート

JUnit5の公式サイトからdonationできます。スポンサーになれば、自社サイトのリンクをJUnitのホームページに掲載できます。個人レベルのサポートでも、希望すれば名前を掲載することができます。

過去のJUnit関連の記事

参考

JUnitについて

Mockitoについて

2
3
0

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
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?