Slide 1

Slide 1 text

例示! Spring Bootで作られた REST APIのテストコード JJUG CCC 2023 Spring 2023/06/04 16:15-16:35 ミーティングルームABC きり丸(水上 皓登)@nainaistar ソフトバンク株式会社

Slide 2

Slide 2 text

1. 自己紹介 と 導入 2. APIのテストについて 3. Controllerのテストについて 4. Serviceのテストについて 5. Repositoryのテストについて

Slide 3

Slide 3 text

名前:きり丸(水上 皓登) twitter1 :nainaistar twitter2 :mizuHiroto GitHub :hirotoKirimaru ブログ :きり丸の技術日記 https://nainaistar.hatenablog.com/ 3 結局、環境構築に 50時間くらいかかった

Slide 4

Slide 4 text

※ バージョンによっては 動かないことがあります

Slide 5

Slide 5 text

※ このセッションでは 雰囲気だけ掴んで 詳細はGitHubのソースコード を見てください

Slide 6

Slide 6 text

サンプルリポジトリへのQRコード

Slide 7

Slide 7 text

今回の環境 ● Java 17 ● Spring Boot 3.1.0 ● Lombok 使用

Slide 8

Slide 8 text

対象者 / 非対象者 ● 初心者向け ● テストを導入したい ● 高速化したい人 対象者 非対象者

Slide 9

Slide 9 text

話すこと / 話さないこと ● テスト書いてるときの気持ち ● Spring Securityを使用 ● APIのテスト ● Spring MVCのテスト ○ Controllerのテスト ● Repositoryのテスト ● その他テスト ● Spring Batchのテスト ● Web Fluxのテスト 話すこと 話さないこと

Slide 10

Slide 10 text

テストが書きたいが テストを書くための 環境構築が難しい

Slide 11

Slide 11 text

私がエンドポイント単位の テストが好き なので API のテスト から説明します ココから! 1 2 3

Slide 12

Slide 12 text

あくまで水上の主張 E2E:サーバで完結しない  (例:ブラウザ, AWS, 他システム) Integration:サーバで完結する  (例:TestContainers で代用可能) Unit:1レイヤー, または 1ファイルのみ

Slide 13

Slide 13 text

レイヤーと呼んだもの 私の好きな ポートアンドアダプター に絡めて説明します

Slide 14

Slide 14 text

今日覚えて帰ってほしいこと

Slide 15

Slide 15 text

@SpringBootTest だけがテスト可能な アノテーションじゃない

Slide 16

Slide 16 text

必要最小限な設定の テストアノテーション @WebMvcTest @DataRedisTest @MyBatisTest @FlywayTest @SpringJUnitConfig

Slide 17

Slide 17 text

APIのテスト

Slide 18

Slide 18 text

キーワード ● SpringBootTest ● TestContainers ● Flyway ● TestSecurityContextHolder ● DBUnit ● JSONAssert

Slide 19

Slide 19 text

@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のテスト:設定

Slide 20

Slide 20 text

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のテスト:日付のセットアップ

Slide 21

Slide 21 text

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)の準備

Slide 22

Slide 22 text

spring: flyway: locations: classpath:db/migration; classpath:db/record DDL resources/db/migration/V1__create_table.sql マスタデータ resources/db/record/R__initialdata.sql APIのテスト:Flywayに実行させるDDLとマスタデータ

Slide 23

Slide 23 text

セットアップ: 期待値: APIのテスト:データの準備:XML

Slide 24

Slide 24 text

@Test void test_01() throws Exception { // テストの最初にセットアップメソッドを呼び出す。引数はXMLまでのパス。 setUpDatabase("/integrationTest/users_get/A01/setup.xml"); // DBの比較 assertDatabase("/integrationTest/users_get/A01/expected.xml") } APIのテスト:データの準備:DBUnit

Slide 25

Slide 25 text

// ファイルの読み込み var dataSet = new FlatXmlDataSetBuilder().build(getClass().getResourceAsStream(path)); // XMLでファイルを読み込んで、CLEAN_INSERTで設定する DatabaseOperation.CLEAN_INSERT.execute(dbUnitConnection, dataSet); APIのテスト:データの準備:DBUnit:セットアップ

Slide 26

Slide 26 text

● 差分が出るとすぐにExceptionを投げる ○ 10項目差分があるなら、10回テストしないと分からない ○ 1回で差分をすべて出し切りたい DBUnitで工夫しない場合

Slide 27

Slide 27 text

● エラーメッセージが正確ではない ○ 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で工夫しない場合

Slide 28

Slide 28 text

件数が異なった場合にはDBの値を記載し、 値が異なった場合は主キーを記載するようにする Caused by: java.lang.AssertionError: テーブルの比較に失敗しました。 件数不一致:データがありません[table=USER_ROLES] 件数不一致:データ[table=ORGANIZATIONS,organization_id=1,name=null,] 件数不一致:データ[table=ORGANIZATIONS,organization_id=2,name=null,] 件数不一致:データ[table=ORGANIZATIONS,organization_id=3,name=null,] 値不一致:[Table=USERS,pk1=user_id=1, columnName=email, [email protected], [email protected]] 値不一致:[Table=USERS,pk1=user_id=1, columnName=name, expectedValue=ONE, actualValue=TWO] DBUnitで工夫する

Slide 29

Slide 29 text

// [null] を nullとして比較する ReplacementDataSet expected = new ReplacementDataSet(loadExpectedXml); expected.addReplacementObject("[null]", null); APIのテスト:データの準備:DBUnit:比較

Slide 30

Slide 30 text

// 件数エラー用 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:比較

Slide 31

Slide 31 text

@Test // Spring Security の設定する認証ユーザを拡張していない場合にいづれかを使用 // @WithAnonymousUser // @WithMockUser // @WithUserDetails void success() throws Exception { } APIのテスト:認証の準備

Slide 32

Slide 32 text

// 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のテスト:認証の準備

Slide 33

Slide 33 text

var headers = new HttpHeaders(); headers.add(HttpHeaders.CONTENT_TYPE, "application/json"); HttpEntity stringHttpEntity = new HttpEntity<>(null, headers); return restTemplate.exchange( URI.create(uri.value), HttpMethod.GET, stringHttpEntity, String.class ); APIのテスト:HttpClient

Slide 34

Slide 34 text

protected void assertResponse(ResponseEntity 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の検証

Slide 35

Slide 35 text

@SpringBootConfiguration @EnableAutoConfiguration public class Users_get extends IntegrationTestsTemplate { } APIのテスト:テスト本体のコード

Slide 36

Slide 36 text

@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のテスト:(ようやく)テスト本体のコード

Slide 37

Slide 37 text

Integration テストが通ったら Unit テストでもよし API間の整合性確認でもよし

Slide 38

Slide 38 text

API間の整合性 最初から最後までのテストを 1件でも作っていると API間の整合性が合わなかった場合に すぐ気づける

Slide 39

Slide 39 text

Aチーム Bチーム Cチーム 極端な例だと それぞれのAPIが別チームの場合に 最後まで認識が合わないことがある

Slide 40

Slide 40 text

@SpringBootConfiguration @EnableAutoConfiguration public class ScenarioTest extends IntegrationTestsTemplate { @Test void test() { // 見積API実行 // 販売API実行 // 発注API実行 // HttpStatusだけしか確認しない // DBは基本確認しない } } API間の整合性

Slide 41

Slide 41 text

TDD RED GREEN REFACTOR

Slide 42

Slide 42 text

フラクタル構造の TDD RED GREEN REFACTOR

Slide 43

Slide 43 text

Controller のテスト

Slide 44

Slide 44 text

キーワード ● WebMvcTest ● MockMvc ● JSONAssert ● TestSecurityContextHolder

Slide 45

Slide 45 text

@RestController @RequestMapping("/users") @RequiredArgsConstructor public class UsersApi { private final UsersService usersService; @RequestMapping(value = "", method = RequestMethod.GET) public ResponseEntity get() { return ResponseEntity.ok(new ResponseUserDto(usersService.execute())); } } Controllerのテスト

Slide 46

Slide 46 text

@WebMvcTest(UsersApi.class) // テスト対象を指定 class UsersApiTests { @Autowired MockMvc mockMvc; @MockBean UsersService usersService; String url = "/users"; } Controllerのテスト

Slide 47

Slide 47 text

@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のテスト

Slide 48

Slide 48 text

@Test // Spring Security の設定する認証ユーザを拡張していない場合にいづれかを使用 // @WithAnonymousUser // @WithMockUser // @WithUserDetails void success() throws Exception { } Controllerのテスト

Slide 49

Slide 49 text

@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と同じ)

Slide 50

Slide 50 text

Service のテスト

Slide 51

Slide 51 text

キーワード ● なし

Slide 52

Slide 52 text

@ExtendWith(MockitoExtension.class) class UsersServiceTest { UsersService target; @Mock UsersRepository repository; @BeforeEach void setUp() { target = new UsersServiceImpl(repository); } @Test void test_01() { target.execute(); } } Serviceのテスト

Slide 53

Slide 53 text

(強いて言えば)キーワード ● SpringJUnitConfig

Slide 54

Slide 54 text

@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のテスト

Slide 55

Slide 55 text

// Propertyファイルの読み込みで使用するテスト @SpringJUnitConfig(initializers = ConfigDataApplicationContextInitializer.class) // 有効にするPropertyファイル @EnableConfigurationProperties({GitHubProperties.class}) class GitHubPropertiesTest { @Autowired GitHubProperties target; @Test void test_01() { assertThat(target.getUri().toString()).isEqualTo("http://localhost:80/github"); } } Serviceのテスト

Slide 56

Slide 56 text

@Service public class AppService { @Value("${app.config.appName}") private String appName; } Serviceのテスト

Slide 57

Slide 57 text

Repository のテスト

Slide 58

Slide 58 text

キーワード ● TestContainers ● データベースライブラリの固有のアノテーション ○ MyBatisTest ○ DataJpaTest ○ DataRedisTest ● Flyway ● spring.test.database.replace: NONE

Slide 59

Slide 59 text

@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と同じ

Slide 60

Slide 60 text

// TestContainersのアノテーションのついたクラスを継承させる class UsersRepositoryTest extends RepositoryTestTemplate { @Autowired private UsersRepository target; @Test public void testInsert() { } } Repositoryのテスト

Slide 61

Slide 61 text

// もし、MyBatisとRedisが共存していて、それぞれ別パッケージの場合は、DI対象を絞ったら早く起動するかも // ※ 未検証 @MapperScan("kirimaru.biz.repository") class UsersRepositoryTest extends RepositoryTestTemplate { } Repositoryのテスト

Slide 62

Slide 62 text

その他 のテスト

Slide 63

Slide 63 text

キーワード ● TestContainers

Slide 64

Slide 64 text

キーワード ● TestContainers

Slide 65

Slide 65 text

E2E のテスト

Slide 66

Slide 66 text

キーワード ● Playwright ○ E2Eテストフレームワーク ■ Node.js ● エコシステムが整っていてオススメ ■ Python ■ Java ■ .NET

Slide 67

Slide 67 text

まとめ 状況によってはテストが困難であることがあるため、 必ずしも私のソースコードが参考になるとは限りません。 ただ、簡単なエッセンスだけでも覚えていただければ幸いです。

Slide 68

Slide 68 text

名前:きり丸(水上 皓登) twitter1 :nainaistar twitter2 :mizuHiroto GitHub :hirotoKirimaru ブログ :きり丸の技術日記 https://nainaistar.hatenablog.com/ 68 不明点があったら 気軽に聞いてください

Slide 69

Slide 69 text

Appendix

Slide 70

Slide 70 text

私のブログ きり丸の技術記事 https://nainaistar.hatenablog.com/

Slide 71

Slide 71 text

参考記事 構造を決定するテスト https://thinkit.co.jp/article/13737

Slide 72

Slide 72 text

話すこと / 話さないこと ● ● 話すこと 話さないこと

Slide 73

Slide 73 text

対象者 / 非対象者 ● ● 対象者 非対象者

Slide 74

Slide 74 text

登壇を見た人への期待するアクション ● アクション