NHN Cloud NHN Cloud Meetup!

Spring Boot Test

Spring Bootのテスト機能を簡単にまとめました。
Spring Boot公式文書を整理したレベルですが、今後Spring Bootアプリケーションを開発、テストする方の参考になれば幸いです。

Spring Bootでテストを

Spring Bootは、アプリケーションをテストできるたくさんの機能を提供しています。Spring Bootのテストモジュールは、spring-boot-testとspring-boot-test-autoconfigureがあります。ほとんどの場合は、spring-boot-starter-testだけでも十分です。spring-boot-starter-testは、Spring Bootのテストに使用されるStarterパッケージで、JUnitはもちろん、AssertJ、Hamcrestなど、さまざまなライブラリが含まれています。

主なライブラリ

既存のSpring frameworkで使用していたspring-testの他にも、さまざまな有用なライブラリが含まれています。
ライブラリにはMockitoもあり、基本的にMockito1.xバージョンを使用していますが、必要に応じて2.xバージョンを使用することもできます。

  • JUnit
  • Spring Test&Spring Boot Test
  • AssertJ
  • Hamcrest
  • Mockito
  • JSONassert
  • JsonPath

@SpringBootTest

spring-boot-testは、@SpringBootTestというアノテーションを提供しています。このアノテーションを使用すると、テストに使うApplicationContextを簡単に作成して操作できます。既存のspring-testで使用していた@ContextConfigurationの発展した機能と言えます。
@SpringBootTestは非常に多くの機能を提供しています。すべてのBeanの中から特定のBeanを選択して作成したり、特定のBeanをMockに代替したり、テストに使用するプロパティファイルを選択したり、特定のプロパティのみを追加したり、特定のConfigurationを選択して設定することもできます。また主な機能として、テストWeb環境を自動で設定する機能があります。
前述したさまざまな機能を使用する際、最も重要なことは、@SpringBootTest機能は必ず@RunWith(SpringRunner.class)と一緒に使用する必要があるという点です。

Bean

@SpringBootTestアノテーションを使うと、テストに使用できるBeanを非常に簡単に作成することができます。@SpringBootTestアノテーションは、classesというプロパティを提供しており、当該プロパティを用いてBeanを生成するクラスを指定することができます。classes属性に@Configurationアノテーションを使用するクラスがある場合、内部で@Beanアノテーションを用いて生成される頻度が登録されます。classes属性を用いてクラスを指定しない場合は、アプリケーション上に定義されたすべてのBeanを作成します。

@RunWith(SpringRunner.class)
@SpringBootTest(classes = {ArticleServiceImpl.class, CommonConfig.class})
public class SomeClassTest {
    // Serviceで登録するBean
    @Autowired
    private ArticleServiceImpl articleServiceImpl;
    // CommonConfigで作成するBean
    @Autowired
    private RestTemplate restTemplate;
}

TestConfiguration

従来定義したConfigurationをカスタマイズしたい場合は、TestConfiguration機能が使用できます。TestConfigurationはComponentScan過程で生成され、自分が属するテストが実行されるとき、定義されたBeanを作成して登録します。

@RunWith(SpringRunner.class)
@SpringBootTest
public class TestConfigArticleServiceImplTest {
    @MockBean
    private ArticleDao articleDao;
    @Autowired
    private RestTemplate restTemplate;
    @Autowired
    private ArticleServiceImpl articleServiceImpl;

    @Test
    public void test() {
        String good = restTemplate.getForObject("test", String.class);
        assertThat(good).isEqualTo("Good");
    }

    @TestConfiguration
    public static class TestConfig {
        @Bean
        public RestTemplate restTemplate() {
            return new RestTemplate() {
                @Override
                public <T> T getForObject(String url, Class<T> responseType, Object... uriVariables) throws RestClientException {
                    System.out.println("Good");
                    if (responseType == String.class) {
                        return (T) "Good";
                    } else {
                        throw new IllegalArgumentException();
                    }
                }
            };
        }
    }
}

ComponentScanを通じて検出されるため、万が一@SpringBootTestのclasses属性を利用して特定のクラスだけを指定した場合には、TestConfiguationは検出されません。そのような場合はclasses属性に直接TestConfigurationを追加する必要があります。しかしより良い方法は、@Importアノテーションを使用することです。@Importアノテーションを介して使用するTestConfigurationが明示でき、特定のテストクラスの内部クラスではない別途クラスに分離して、さまざまなテストで共有することもできます。

@TestConfiguration
public class TestConfig {
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate() {
            @Override
            public <T> T getForObject(String url, Class<T> responseType, Object... uriVariables) throws RestClientException {
                System.out.println("Good");
                if (responseType == String.class) {
                    return (T) "Good";
                } else {
                    throw new IllegalArgumentException();
                }
            }
        };
    }
}
@RunWith(SpringRunner.class)
@SpringBootTest(classes = ArticleServiceImpl.class)
@Import(TestConfig.class)
public class TestConfigArticleServiceImplTest {
    @MockBean
    private ArticleDao articleDao;
    @Autowired
    private RestTemplate restTemplate;
    @Autowired
    private ArticleServiceImpl articleServiceImpl;

    @Test
    public void test() {
        String good = restTemplate.getForObject("test", String.class);
        assertThat(good).isEqualTo("Good");
    }
}

MockBean

spring-boot-testパッケージはMockitoが含まれているため、従来のようにMockオブジェクトを生成してテストする方法もありますが、spring-boot-testでは新しい方法も提供しています。@MockBeanアノテーションを使って、名前の通りMockオブジェクトをBeanに登録できます。そのため、もし@MockBeanで宣言されたBeanを注入するなら(@Autowired同じアノテーションなどを通じて)SpringのApplicationContextはMockオブジェクトを注入します。
新たに@MockBeanを宣言するとMockオブジェクトをBeanで登録しますが、@MockBeanで宣言したオブジェクトと同じ名前のタイプがすでに登録されている場合は、当該Beanは宣言したMock Beanに置き換えられます。

@RunWith(SpringRunner.class)
@SpringBootTest(classes = ArticleServiceImpl.class)
public class ArticleServiceImplTest {
    @MockBean
    private RestTemplate restTemplate;
    @MockBean
    private ArticleDao articleDao;
    @Autowired
    private ArticleServiceImpl articleServiceImpl;

    @Test
    public void testFindFromDB() {
        List<Article> expected = Arrays.asList(
                new Article(0, "author1", "title1", "content1", Timestamp.valueOf(LocalDateTime.now())),
                new Article(1, "author2", "title2", "content2", Timestamp.valueOf(LocalDateTime.now())));

        given(articleDao.findAll()).willReturn(expected);

        List<Article> articles = articleServiceImpl.findFromDB();
        assertThat(articles).isEqualTo(expected);
    }
}

Properties

Spring Bootは、基本的にクラスパス上、application.properties(またはapplication.yml)を通じてアプリケーションの設定を行います。しかし、テスト中は設定が既存と異なる場合が多いので、その機能をSpringBootTestで提供しています。SpringBootTestは、propertiesという属性が存在します。この属性を使って、別のテストのapplication.properties(またはapplication.yml)を指定できます。

@RunWith(SpringBoot.class)
@SpringBootTest(properties = "classpath:application-test.yml")
public class SomeTest {
    ...
}

Web Environment test

前述のとおり@SpringBootTestアノテーションを使うと、簡単にWebテスト環境を構成できます。@SpringBootTestのwebEnvironmentパラメータを利用するとWebテスト環境を簡単に選択することができる。提供する設定値は以下のとおりです。

  • MOCK
    • WebApplicationContextをロードし、内蔵されたサーブレットコンテナではなく、Mockサーブレットを提供する。@AutoConfigureMockMvcアノテーションを使うと、特別な設定を行う必要がなく、簡単にMockMvcを使用したテストができる。
  • RANDOM_PORT
    • EmbeddedWebApplicationContextをロードし実際のサーブレット環境を構成する。生成されたサーブレットコンテナは任意のポートをlistenする。
  • DEFINED_PORT
    • RAMDOM_PORTと同様に、実際のサーブレット環境を構成するが、ポートはアプリケーションのプロパティで指定されたポートをlistenする。(application.propertiesまたはapplication.ymlで指定したポート)
  • NONE
    • 一般的なApplicationContextをロードし、サーブレット環境は構成しない。

TestRestTemplate

@SpringBootTestTestRestTemplateを使うと簡単にWeb統合テストを行うことができます。TestRestTemplateは名前が示すようにRestTemplateのテストためのバージョンです。@SpringBootTestでWeb Environmentを設定をしたら、TestRestTemplateはそれに合わせて自動的に設定されてBeanが生成されます。

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class RestApiTest {
    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void test() {
        ResponseEntity<Article> response = restTemplate.getForEntity("/api/articles/1", Article.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isNotNull();
        ...
    }
}

既存のコントローラをテストするため頻繁に使用されていたMockMvcとどのような違いがあるのか気になります。最大の違いは、Servlet Containerの使用可否です。MockMvcはServlet Containerを生成しませんが、一方で@SpringBootTestTestRestTemplateはServlet Containerを使用します。そのため実際のサーバーが動作しているかのように(もちろんいくつかのBeanをMockオブジェクトに置き換えることがありますが)テストを実行できます。また、テストする観点もお互いに異なります。MockMvcはサーバーの立場から、実装されたAPIを介してビジネスロジックが正常に実行されるか検証し、TestRestTemplateはクライアントの立場から、RestTemplateを使用してテストを実行できます。

トランザクション

このとき注意すべき点は、@Transactionalアノテーションです。spring-boot-testは、ただspring-testを拡張したものであるため、@Testアノテーションと一緒に@Transactionalアノテーションを使うと、テストが終了するとロールバックされます。しかし、RANDOM_PORTやDEFINED_PORTでテストを設定すると、実際のテストサーバーとは別のスレッドで実行されるため、ロールバックは行われません。

ApplicationContextキャッシュ

ちなみに@SpringBootTest機能により生成されたApplicationContextはキャッシュされます。もし@SpringBootTestの設定が同じなら、同じApplicationContextを使用することになります。

@JsonTest

@JsonTestアノテーションを使用すると、より簡単にJSON serializationとdeserializationをテストできます。@JsonTestアノテーションは、ObjectMapper@JsonComponentのBeanを含むJacksonのテストのモジュールを自動で設定します。
テストのためのBeanとしてJacksonTesterGsonTesterBasicJsonTesterなどがあります。これを注入して使用すると、より簡単にJSONをテストできます。またAssertjはJSONの機能を提供しています。(JSONassert、JsonPath基盤)下記はJSON serializeとDeserializeをテストサンプルです。

@RunWith(SpringRunner.class)
@JsonTest
public class ArticleJsonTest {
    @Autowired
    private JacksonTester<Article> json;

    @Test
    public void testSerialize() throws IOException {
        Article article = new Article(
                1,
                "kwseo",
                "good",
                "good article",
                Timestamp.valueOf((LocalDateTime.now())));

        // assertThat(json.write(article)).isEqualToJson("expected.json");  直接ファイルと比較
        assertThat(json.write(article)).hasJsonPathStringValue("@.author");
        assertThat(json.write(article))
                .extractingJsonPathStringValue("@.title")
                .isEqualTo("good");
    }

    @Test
    public void testDeserialize() throws IOException {
        Article article = new Article(
                1,
                "kwseo",
                "good",
                "good article",
                new Timestamp(1499655600000L));
        String jsonString = "{\"id\": 1, \"author\": \"kwseo\", \"title\": \"good\", \"content\": \"good article\", \"createdDate\": 1499655600000}";

        assertThat(json.parse(jsonString)).isEqualTo(article);
        assertThat(json.parseObject(jsonString).getAuthor()).isEqualTo("kwseo");
    }
}

@WebMvcTest

server-sideでAPIをテストする@WebMvcTestアノテーションについて調べます。このアノテーションは従来のspring-testでコントローラをテストするときによく使用していたMockMvcの設定を自動で実行するアノテーションです。@WebMvcTestアノテーションを使うと、テストに使用する@Controllerクラスと@ControllerAdvice@JsonComponent@FilterWebMvcConfigurerHandlerMethodArgumentResolverなどをスキャンします。そしてMockMvcを自動設定してBeanに登録します。

@RunWith(SpringRunner.class)
@WebMvcTest(ArticleApiController.class)
public class ArticleApiControllerTest {
    @Autowired
    private MockMvc mvc;
    @MockBean
    private ArticleService articleService;

    @Test
    public void testGetArticles() throws Exception {
        List<Article> articles = asList(
                new Article(1, "kwseo", "good", "good content", now()),
                new Article(2, "kwseo", "haha", "good haha", now()));

        given(articleService.findFromDB(eq("kwseo"))).willReturn(articles);

        mvc.perform(get("/api/articles?author=kwseo"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("@[*].author", containsInAnyOrder("kwseo", "kwseo")));
    }

    private Timestamp now() {
        return Timestamp.valueOf(LocalDateTime.now());
    }
}

Async Web Test

コントローラからFuture、またはDeferredResultのオブジェクトを返すと、HTTPリクエストとレスポンスは非同期で動作します。既存と異なる方法で動作するにはMockMvcで若干、テスト方法の変更が必要です。

...
@Test
public void testGetArticle() throws Exception {
    Article expected = new Article(1, "kwseo", "good", "good content", now());

    given(articleService.findOneFromRemote(eq(1))).willReturn(expected);

    MvcResult result = mvc.perform(get("/api/articles/1")).andReturn();
    mvc.perform(asyncDispatch(result))      // asyncDispatch必要
        .andExpect(status().isOk())
        .andExpect(jsonPath("@.id").value(1));
}
...

上記のコードのようにMockMvcで要請した後、MvcResultで受信してasyncDispatchでラップする必要があります。

@DataJpaTest

Spring Data JPAをテストしたい場合、@DataJpaTest機能を使用できます。このアノテーションと一緒にテストを実行すると、基本的にin-memory embedded databaseを作成し、@Entityクラスをスキャンします。一般的な他のコンポーネントはスキャンしません。
ちなみに@DataJpaTest@Transactionalアノテーションを含んでおり、テストが完了すると自動でロールバックされるため@Transactionalアノテーションを行う必要がありません。もし@Transactional機能が不要であれば、以下のように設定できます。

@RunWith(SpringRunner.class)
@DataJpaTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public class SomejpaTest {
    ...
}

@DataJpaTest機能を使うと、@Entityをスキャンしてリポジトリを設定する他にもテスト用にTestEntityManagerというBeanが生成されます。このBeanを使って、テストに用いたデータを定義できます。以下は@DataJpaTestを使ってテストを実行するサンプルです。

@RunWith(SpringRunner.class)
@DataJpaTest
public class ArticleDaoTest {
    @Autowired
    private TestEntityManager entityManager;
    @Autowired
    private ArticleDao articleDao;

    @Test
    public void test() {
        Article articleByKwseo = new Article(1, "kwseo", "good", "hello", Timestamp.valueOf(LocalDateTime.now()));
        Article articleByKim = new Article(2, "kim", "good", "hello", Timestamp.valueOf(LocalDateTime.now()));
        entityManager.persist(articleByKwseo);
        entityManager.persist(articleByKim);


        List<Article> articles = articleDao.findByAuthor("kwseo");
        assertThat(articles)
                .isNotEmpty()
                .hasSize(1)
                .contains(articleByKwseo)
                .doesNotContain(articleByKim);
    }
}

テストにin-memory embedded databaseではなく、real databaseを使う場合は、@AutoConfigureTestDatabaseアノテーションを使うと簡単に設定できます。

@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
public class SomeJpaTest {
    ...
}

@JdbcTest

Spring Data JPAを使わなくてもデータベースのテストは実施できます。@JdbcTest@DataJpaTestと同じような設定を行いますが、純粋なJDBCのテストを準備します。@JdbcTestアノテーションを使用すると、同様にin-memory embedded databaseが設定され、テスト用のJdbcTemplateが生成されます。

@DataMongoTest

最近ますます人気を博しているNoSQL DBのMongoDBにも便利なテスト機能を提供しています。@DataMongoTestアノテーションが当該機能を提供しており、設定内容は@DataJpaTestと類似しています。上記の他データのテストモジュールと同様に、in-memory embedded MongoDBを使用しますが、@DataMongoTest@Entityではなく、@DocumentをスキャンしてMongoTemplateを生成します。

@RunWith(SpringRunner.class)
@DataMongoTest
public class SomeMongoTest {
    @Autowired 
    private MongoTemplate mongoTemplate;
    ...
}

in-memory embedded MongoDBではなく、外部に構築したMongoDBを使用する場合は、以下のように属性を追加します。

@DataMongoTest(excludeAutoConfiguration = EmbeddedMongoAutoConfiguration.class)

@RestClientTest

@RestClientTest機能は、自分がサーバーではなく、クライアントの立場となるコードをテストするときに便利です。例えば、Apache HttpClientやSpringのRestTemplateを使って、外部サーバーにWeb要求を送信する場合があります。@RestClientTestは、要求に応答する仮想のMockサーバーを作成する、と想像してみましょう。内部コードでWeb要求が発生した場合、@RestClientTestによって作成された仮想サーバーが応答します。もちろん、その仮想サーバーがどのように応答するかを定義することもできます。これを使用すると、RestTemplateのようなオブジェクトをMockオブジェクトに変えてテストするよりも、リアル環境に近い単体テストを実行できます。この機能を使うと、自動でMockRestServiceServerと呼ばれるBeanが生成され、これを利用すると簡単に要求と応答の設定を行うことができます。

@RunWith(SpringRunner.class)
@RestClientTest(ArticleServiceImpl.class)
public class ArticleServiceImplWithRestClientTest {
    @MockBean
    private ArticleDao dao;
    @Autowired
    private ArticleServiceImpl service;
    @Autowired
    private MockRestServiceServer server;

    @Test
    public void testGetFindOneFromRemote() throws Exception {
        String articleJson = "{ \"id\": 1, \"author\": \"kwseo\", \"title\": \"gogogo\", \"content\": \"good\", \"date\": 1502322765 }";

        server.expect(requestTo("http://sample.com/some/articles/1"%29%29
            .andRespond(withSuccess(articleJson, MediaType.APPLICATION_JSON));

        Article article = service.findOneFromRemote(1);
        assertThat(article.getId()).isEqualTo(1);
    }
}

 

NHN Cloud Meetup 編集部

NHN Cloudの技術ナレッジやお得なイベント情報を発信していきます
pagetop