Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

例示! 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 ソフトバンク株式会社

    View Slide

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

    View Slide

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

    View Slide


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

    View Slide


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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  17. APIのテスト

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  22. 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とマスタデータ

    View Slide

  23. セットアップ:

    期待値:

    APIのテスト:データの準備:XML

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  28. 件数が異なった場合には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で工夫する

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  33. 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

    View Slide

  34. 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の検証

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  41. TDD
    RED
    GREEN
    REFACTOR

    View Slide

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

    View Slide

  43. Controller
    のテスト

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  50. Service
    のテスト

    View Slide

  51. キーワード
    ● なし

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  55. // 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のテスト

    View Slide

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

    View Slide

  57. Repository
    のテスト

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  62. その他
    のテスト

    View Slide

  63. キーワード
    ● TestContainers

    View Slide

  64. キーワード
    ● TestContainers

    View Slide

  65. E2E
    のテスト

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  69. Appendix

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  73. 対象者 / 非対象者
    ● ●
    対象者 非対象者

    View Slide

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

    アクション

    View Slide