Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

例示! Spring Bootで作られた REST APIのテストコード/ Testing-E...

例示! Spring Bootで作られた REST APIのテストコード/ Testing-Example-for-a-REST-API-created-with-Spring-Boot

JJUG CCC 2023 Spring
2023/06/04 16:15-16:35 ミーティングルームABC

サンプルリポジトリ:
https://github.com/hirotoKirimaru/jjuc-ccc-2023-spring

kirimaru

June 04, 2023
Tweet

More Decks by kirimaru

Other Decks in Programming

Transcript

  1. 例示! Spring Bootで作られた REST APIのテストコード JJUG CCC 2023 Spring 2023/06/04

    16:15-16:35 ミーティングルームABC きり丸(水上 皓登)@nainaistar ソフトバンク株式会社
  2. 話すこと / 話さないこと • テスト書いてるときの気持ち • Spring Securityを使用 • APIのテスト

    • Spring MVCのテスト ◦ Controllerのテスト • Repositoryのテスト • その他テスト • Spring Batchのテスト • Web Fluxのテスト 話すこと 話さないこと
  3. @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @SpringBootConfiguration @EnableAutoConfiguration @Testcontainers @TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, // dataSourceをDIに使用

    }) public abstract class IntegrationTestsTemplate { @Autowired TestRestTemplate restTemplate; @Autowired public DataSource dataSource; @MockBean(answer = Answers.CALLS_REAL_METHODS) // できる限りリアルなメソッドを呼びたい protected 日付関連クラス 日付関連クラス; // 時間は固定化したい APIのテスト:設定
  4. public class 日付関連クラス { OffsetDateTime now() { return OffsetDateTime.now(ZoneId.of("Asia/Tokyo")); };

    public LocalDateTime currentLocalDateTime() { // now()に依存 return now().toLocalDateTime(); } public String formatCurrentToyyyyMMddHHmmss() { // now()に依存 return now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); } } APIのテスト:日付のセットアップ
  5. private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>( DockerImageName.parse("postgres")); @BeforeAll

    public static void setUp() { postgres.start(); } @DynamicPropertySource static void setup(DynamicPropertyRegistry registry) { // コンテナで起動中のPostgresへ接続するためのJDBC URLをプロパティへ設定 registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); } APIのテスト:DB(TestContainers)の準備
  6. • エラーメッセージが正確ではない ◦ row count (table=ORGANIZATIONS) expected:<2> but was:<3> ▪

    件数が違うことしかわからない。中身がわからない。 ◦ value (table=USERS, row=0, col=email) expected: <[email protected][2]> but was: <[email protected][]> ▪ row=0って?少ないうちはいいが、100レコードのうちの51番目 といわれてもピンとは来ない。 DBUnitで工夫しない場合
  7. // 件数エラー用 StringBuilder countErrorSb = new StringBuilder(); DiffCollectingFailureHandler failureHandler =

    new DiffCollectingFailureHandler(); try { Assertion.assertEquals(expectedTable, actualTable, failureHandler); } catch (DbComparisonFailure e) { // 件数一致しなかったときのエラーメッセージの加工 } if (!countErrorSb.isEmpty() || !failureHandler.getDiffList().isEmpty()) { // カラム比較のエラーメッセージの加工 // Assertions.failを読んで明示的な失敗をさせる Assertions.fail("テーブルの比較に失敗しました。\n" + sb); } APIのテスト:データの準備:DBUnit:比較
  8. // Spring Security の設定する認証ユーザを拡張している場合に、TestSecurityContextHolderに設定 AuthUser user = new AuthUser(new User("user",

    "pass", Collections.emptyList()), new kirimaru.biz.domain.User()); Authentication authentication = new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities()); TestSecurityContextHolder.setAuthentication(authentication); APIのテスト:認証の準備
  9. var headers = new HttpHeaders(); headers.add(HttpHeaders.CONTENT_TYPE, "application/json"); HttpEntity<String> stringHttpEntity =

    new HttpEntity<>(null, headers); return restTemplate.exchange( URI.create(uri.value), HttpMethod.GET, stringHttpEntity, String.class ); APIのテスト:HttpClient
  10. protected void assertResponse(ResponseEntity<String> response, String path) throws JSONException, IOException {

    JSONAssert.assertEquals(response.getBody(), IOUtils.toString(getClass().getResourceAsStream(path)), true ); } // JSONAssertを使うと、特定項目の無視がしやすくて、全体のJSONを把握しやすい JSONAssert.assertEquals(“{}”,”{}”, new CustomComparator(JSONCompareMode.STRICT, new Customization("time.insertTime", (o1, o2) -> true), new Customization("time.updateTime", (o1, o2) -> true) ) ); APIのテスト:ResponseBodyの検証
  11. @Test void test_01() throws Exception { // GIVEN setUpDatabase("/integrationTest/users_get/A01/setup.xml"); signIn();

    // WHEN var response = get(“/users”); // THEN assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK) , () -> assertResponse(response, "/integrationTest/users_get/A01/expected.json") , () -> assertDatabase("/integrationTest/users_get/A01/expected.xml") ); } APIのテスト:(ようやく)テスト本体のコード
  12. @SpringBootConfiguration @EnableAutoConfiguration public class ScenarioTest extends IntegrationTestsTemplate { @Test void

    test() { // 見積API実行 // 販売API実行 // 発注API実行 // HttpStatusだけしか確認しない // DBは基本確認しない } } API間の整合性
  13. @RestController @RequestMapping("/users") @RequiredArgsConstructor public class UsersApi { private final UsersService

    usersService; @RequestMapping(value = "", method = RequestMethod.GET) public ResponseEntity<ResponseUserDto> get() { return ResponseEntity.ok(new ResponseUserDto(usersService.execute())); } } Controllerのテスト
  14. @WebMvcTest(UsersApi.class) // テスト対象を指定 class UsersApiTests { @Autowired MockMvc mockMvc; @MockBean

    UsersService usersService; String url = "/users"; } Controllerのテスト
  15. @Test void success() throws Exception { // GIVEN モックのレスポンスを指定 when(usersService.execute()).thenReturn(List.of(

    User.builder().userId("1").email("[email protected]").name("1").build()) ); // WHEN & THEN 実行と比較 var result = this.mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(MockMvcResultMatchers.status().isOk()) // HttpStatusだけ確認 // .andExpect(MockMvcResultMatchers.jsonPath("$.users[0].userId").value("1")) .andReturn(); // THEN responseの確認 JSONAssert.assertEquals(“{}”, result.getResponse().getContentAsString(), true); } Controllerのテスト
  16. @Test void success() throws Exception { // Spring Security の設定する認証ユーザを拡張している場合に、TestSecurityContextHolderに設定

    AuthUser user = new AuthUser(new User("user", "pass", Collections.emptyList()), new kirimaru.biz.domain.User()); Authentication authentication = new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities()); TestSecurityContextHolder.setAuthentication(authentication); } Controllerのテスト:認証(APIと同じ)
  17. @ExtendWith(MockitoExtension.class) class UsersServiceTest { UsersService target; @Mock UsersRepository repository; @BeforeEach

    void setUp() { target = new UsersServiceImpl(repository); } @Test void test_01() { target.execute(); } } Serviceのテスト
  18. @Getter @Setter @Configuration @ConfigurationProperties(prefix = "external.github") public class GitHubProperties {

    private String host; private String protocol; private String port; private String endpoint; public URI getUri() { return URI.create(protocol + "://" + host + ":" + port + "/" + endpoint); } } Serviceのテスト
  19. @Testcontainers @MybatisTest public abstract class RepositoryTestTemplate { // コンテナイメージ(ID等を設定する) private

    static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(); @BeforeAll public static void setUp() { postgres.start(); } @DynamicPropertySource static void setup(DynamicPropertyRegistry registry) { // コンテナで起動中のPostgresへ接続するためのJDBC URLをプロパティへ設定 // … } } Repositoryのテスト:APIと同じ