Spring Bootでのテストの仕方をまとめたかった
公式Docを読む時間がある人はこの項目を自分で読むのが一番です。
テストについて学ぶには、Spring Framework、Spring Bootについてのややディープな理解が必須なので、不安な人は中田/Techメモ/SpringBoot等を参照してください。
PitCoinで実際にテストを書いてみたので、こちらも参考になると思います。
Spring Bootでテストを行うためには、様々な知識が必要です。
Spring Frameworkのテスト = (バニラJava + 頻出ライブラリ) + (テスト + アサーション) + (DI + モッキング) + Auto-configuration
Spring Bootのテストは、Spring Frameworkのテストをまとめたうえで指向性を持たせたものです。
build.gradleに testImplementation 'org.springframework.boot:spring-boot-starter-test' という依存を追加することで、Spring Frameworkのテストライブラリに加え、以下のような外部ライブラリがいくつかimportして利用できるようになります。
テストの種類に応じてアノテーションを変更することで、コンポーネントスキャンとAuto-configurationの範囲を指定(スライス)することができます。(@DataJpaTest、@WebMvcTestなど)
Auto-configurationの内容は、@SpringBootTest、スライスを参照してください。
@...Testのアノテーションは、 JUnit5の@ExtendWith(SpringExtension.class)を含み、Spring拡張機能が利用可能になります。JUnit4を利用する場合は@RunWith(...)を追加してください。
公式Docを参照。
//公式Docより @DataJpaTest class ExampleRepositoryTests { @Autowired private TestEntityManager entityManager; @Autowired private UserRepository repository; @Test void testExample() throws Exception { this.entityManager.persist(new User("sboot", "1234")); User user = this.repository.findByUsername("sboot"); assertThat(user.getUsername()).isEqualTo("sboot"); assertThat(user.getVin()).isEqualTo("1234"); } }
Repository単体テストでは、インメモリのDBを利用します。
そのため、テスト用に依存を追加しておく必要があります。(例:H2を利用する場合は、testImplementation 'com.h2database:h2' をbuild.gradleに追記)
@DataJpaTestは、@RepositoryをスキャンしてBeanを自動構成するため、RepositoryのAutowiredが可能です。その他のコンポーネント(@Serviceなど)はデフォルトではスキャンされません。
また、TestEntityManager(標準JavaのJPAにおけるEntityManagerのSpring Boot Testラップ版)のBeanも利用可能なため、Repositoryの機能を使わずにデータベースの初期化が可能になります(perisist()、clear()など)。
@DataJpaTestは@Transactionalを含むアノテーションであるため、テストごとにトランザクションがロールバックされます。
インメモリではなく、設定ファイルで定義したDBに対してテストを実行したい場合は、以下のようにアノテーションを追記します。
// 公式Docより @DataJpaTest @AutoConfigureTestDatabase(replace=Replace.NONE) class ExampleRepositoryTests { // ... }
この時利用するデータベース設定は、application.propetiesから読み込まれます。
src/test/resources -> src/main/resources の順で走査され、src/test以下にapplication.propetiesが存在しない場合はsrc/mainのapplication.propetiesを参照します。
本番・開発用のDBを汚染しないように、テスト用データベース設定の作成を忘れないように注意が必要です。
// 静的インポート(他のインポートは省略) import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; // モックを有効化 @ExtendWith(MockitoExtension.class) public class MyServiceUnitTests { // Repositoryをモックに @Mock private MyRepository repository; // モックRepositoryを注入してServiceを構成 @InjectMocks private MyService service; @Test void testMethod(){ // Serviceで呼び出されるRepositoryメソッドの動作を定義 given(repository.myRepositoryMethod(a)).willReturn(b); // Serviceを呼び出す(内部で上で定義した動作が動く) final returnValue = service.myServiceMethod(a); // 適当なアサーションを行う asertThat(returnValue.getName()).isEqualTo(b); } }
Service単体テストでは、テスト対象のService内で使用する他のService、Repositoryクラスをモックにします。
上の例では、@ExtendWith(MockitoExtension.class)でモックBeanの生成を有効化し(Mockito機能)、モックにするクラスに@Mockを、モックを注入したいインスタンスに@InjectMocksを付加することで、モックの生成を行っています。
テストメソッドの中では、
という手順でテストを行っています。
また、今回はAuto-configurationの必要がなく(モック以外で特にBeanや機能を使用していない)、コンポーネントスキャンも不要なため(@Autowiredを使用していない)、@...Testは使用していません。
必要な機能を付加したい場合は@AutoConfigure...を、自作クラスをAutowiredしたい場合は@Impot(...)を付加します。
もしくは、@...Testを利用してもよいですが、その場合はアノテーション内に@ExtendWith(SpringExtension.class)が含まれており、その内部でモックBeanの生成が有効化されるため、@ExtendWith(MockitoExtension.class)は不要です。
// 静的インポート(他のインポートは省略) import static org.assertj.core.api.Assertions.*; // クラスアノテーションの説明は下記 /* @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @AutoConfigureTestEntityManager @Transactional */ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @Import(MyService.class) public class MyServiceIntegrationTests { // モックではなく全てAutowired @Autowired private TestEntityManager entityManager; @Autowired private MyRepository repository; @Autowired private MyService service; @Test void testMethod(){ // テスト内容 } }
Serviceクラス以下の結合テストです。上の例では特にモックは使用していません。
テストの手順の一例は次の通りです。
結合テストのアノテーションは、テストの方針によって付加の仕方が変わってきます。
この話はService+Repository+DBに限らず、部分的な結合テスト全般で応用できると思います。
一つ目は、ほぼ全体をスキャンしてから必要に応じて機能を付加・制限していくスタイルです。
結合の規模が大きい場合は便利ですが、機能の制限が面倒であったり、余分なAuto-configurationによってテスト時間が増える可能性があります。
二つ目は、最大公約数的な@...Testを付加してから必要に応じて機能を付加・制限していくスタイルです。
結合の規模が小さい場合や必要な機能が少ない場合は便利ですが、アノテーションが膨大になる可能性があります。
公式Docを参照。
// 静的インポート import static org.mockito.BDDMockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; // MockMvcの有効化など @WebMvcTest(MyController.class) public class MyControllerUnitTests { @Autowired private MockMvc mvc; @Autowired private ObjectMapper mapper; // Spring Boot版モック生成 @MockBean private MyService service; @Test void testMethod() throws Exception { // MockMvcはExceptionを投げる // モックメソッド定義 given(service.myMethod(a)).willReturn(b); // REST呼び出しとアサーションを同時に行う mvc .perform(post("/this/is/path/") .content(mapper.writeValueAsString(new MyForm(a))) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.result.name").value(b)); } }
Controller単体テストでは、MockMvcを用いることでサーバーを立ち上げずにテストが可能です。
@WebMvcTest(...)によって、以下が行われます。
また、引数として渡した単一のControllerに対して、モックBeanの注入が可能になります。
@MockBeanはSpring Boot版のモック生成アノテーションで、@WebMvcTestとの連携等が可能です。
上の例では、Controller内で呼び出すServiceをモックにしています。
MockMvcは、performでAPIを疑似的に叩き、andExpectで戻り値をアサーションできます。
例では、引数としてJSONをボディに持つPOSTを叩き、戻り値のステータスとJSONの個別の値を検証しています。
詳細やGET等の他の方法はここでは省略します。
Spring Securityのテストをしたい方は、自分は当分使う機会がないので検証していませんが、こちらを参照してください。うまくいった場合は情報提供お願いします。
Thymeleafも検証可能です
.andExpect(view().name("hoge")) .andExpect(model().attribute("name", name));
// 静的インポート import static org.assertj.core.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; // もろもろのクラスアノテーション @SpringBootTest @AutoConfigureMockMvc @AutoConfigureTestEntityManager @Transactional public class PitCoinUserControllerIntegrationTests { // モックなし @Autowired private MockMvc mvc; @Autowired private TestEntityManager entityManager; @Autowired private ObjectMapper mapper; @Autowired private AccountRepository accounts; @Test void testMethod() throws Exception{ // テスト内容 } }
はじめに注意として、これは"全体"結合テストですが、サーバーの起動は行っていません。
その理由は以下の通りです。
全体結合テストの手順の例は以下の通りです。
// テストメソッド @Test void testMethod(){ // テスト内容 }
// 各テスト前に実行するメソッド @BeforeEach void beforeEach(){ // DBの初期化等 }
class AllTests{ // 入れ子クラスをテスト対象にする @Nested class HalfTests{ @Test void testMethod(){ // テスト内容 } } }
// テストクラスで様々な拡張機能を使用可能にする @ExtendWith(...Extension.class) public class TestClass { // テスト }
// アサーションメソッドを静的インポートする import static org.assertj.core.api.Assertions.*;
// aとbが一致するかチェック assertThat(a).isEqualTo(b);
// 真偽値をチェック assertThat(a).isTrue(); assertThat(b).isFalse();
// 例外チェック assertThatThrownBy(() -> { // 例外が発生する処理 }). isInstanceOfSatisfying( /* 正しい例外クラス */, (e) ->{ // 発生した例外eが満たすべき条件のアサーションを記述する } );
// BDDMockメソッドの静的インポート import static org.mockito.BDDMockito.*;
//JUnit5でモックの作成・注入を可能にする @ExtendWith(MockitoExtension.class) public class TestClass{ // モックを使ったテスト }
// モック @Mock private SubService sub; // 内部でモックを利用する(=モックを注入される) @InjectMocks private Service service;
// 注入先での動作を定義する // service.myMethod(a)が呼ばれるとbを返す given(service.myMethod(a)).willReturn(b); // service.myMethod()の引数にMyClass型が渡されるとcを返す given(service.myMethod(any(MyClass.class))).willReturn(c);