🦁

Spring Bootのテストを実際のアプリケーションでどうやるか考えてみた

2024/08/30に公開

はじめに

この記事で何をするのか

Spring/Spring Bootはテスト関連の機能がとても充実しています。これらの機能を、実際のアプリケーションにどのようにつかっていくかを考えました。

あくまで僕個人の案なので、「いや、ここはこうしたほうがいいよ!」とか「自分はこうしてるよ!」とかありましたら是非コメントください。コメントは「優しく」お願いします。

読む際の注意

この記事は「Spring Bootのテスト」に主眼をおいて解説しています。そのため、テストの網羅性については解説していません(同値分割、境界値分析、テストデータなど)。そのあたりは、プロジェクトに合わせてご自分で考える必要があります。

Spring/Spring Bootのテスト関連機能

この記事では、テスト関連機能の基礎は解説しません。代わりに@shindo_ryoさんの資料をご覧ください。とても素晴らしいまとめです。

spring-security-testについては@opengl_8080さんのブログをご覧ください。

サンプルアプリの技術構成

  • Spring Boot 3.3
  • Spring MVC
  • Thymeleaf
  • Spring Security
  • MyBatis

ソースコードはGitHubに置いてあります。

MyBatis Mapperインタフェースの単体テスト

Mapperのテストに付加するアノテーションを作成します。

MapperTest.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest
public @interface MapperTest {
}

この状態だと、テストクラスに@SpringBootTestを付けるとの同じです。しかし、全Mapperのテストに共通する処理を追加したい場合などに便利なので、僕はよく独自アノテーションを作成します。

そして、テストクラスを作成します。

CustomerMapperTest.java
@MapperTest
@Sql({"classpath:schema.sql", "classpath:test-data.sql"})
public class CustomerMapperTest {

    @Autowired
    CustomerMapper customerMapper;

    @Autowired
    JdbcTemplate jdbcTemplate;

    @Nested
    @DisplayName("selectAll()")
    class SelectAll {
        @Test
        @DisplayName("全件取得できる")
        void success() {
            List<Customer> actual = customerMapper.selectAll();
            assertAll(
                    () -> assertEquals(2, actual.size()),
                    () -> assertEquals(new Customer(1, "友香", "菅井", "ysugai@sakura.com", LocalDate.of(1995, 11, 29)), actual.get(0))
            );
        }
    }

    @Nested
    @DisplayName("insert()")
    class Insert {
        @Test
        @DisplayName("1件追加できる")
        void success() {
            int actual = customerMapper.insert(new Customer("天", "山﨑", "tyamasaki@sakura.com", LocalDate.of(2005, 9, 28)));
            assertAll(
                    () -> assertEquals(1, actual),
                    () -> assertEquals(1, JdbcTestUtils.countRowsInTableWhere(jdbcTemplate, "customer", """
                            id = 3
                            AND first_name = '天'
                            AND last_name = '山﨑'
                            AND mail_address = 'tyamasaki@sakura.com'
                            AND birthday = '2005-09-28'
                            """))
            );
        }
    }
}

クラスに@Sqlを付加すると、全テストメソッドの直前に指定したSQLファイルが実行されます。@Sqlは、個別のテストメソッドに付加したり(この場合はそのテストメソッドの直前のみSQLファイルが実行される)、Nestedクラスに付加したり(この場合はそのNestedクラス内の全テストメソッドの直前にSQLファイルが実行される)することもできます。

Serviceクラスの単体テスト

ServiceクラスはMapperを使っているので、MockitoでMapperをモック化します。特にSpring Testの機能は使いません。

CustomerServiceTest.java
public class CustomerServiceTest {

    CustomerService customerService;

    CustomerMapper customerMapper;

    @BeforeEach
    void setUp() {
        // CustomerMapperのモックを作成
        customerMapper = mock(CustomerMapper.class);
        // CustomerMapperのモックを利用してCustomerServiceImplインスタンスを作成
        customerService = new CustomerService(customerMapper);
    }

    @Nested
    @DisplayName("findAll()")
    class FindAllTest {
        @Test
        @DisplayName("全件取得できる")
        void success () {
            // CustomerMapperのfindAll()に仮の戻り値を設定
            when(customerMapper.selectAll()).thenReturn(List.of(
                    new Customer(1, "友香", "菅井", "ysugai@sakura.com", LocalDate.of(1995, 11, 29)),
                    new Customer(2, "久美", "佐々木", "ksasaki@hinata.com", LocalDate.of(1996, 1, 22))
            ));
            // テスト対象のメソッドを実行
            List<Customer> actual = customerService.findAll();
            // テスト対象の戻り値を検証
            assertAll(
                    () -> assertEquals(2, actual.size()),
                    () -> assertEquals(new Customer(1, "友香", "菅井", "ysugai@sakura.com", LocalDate.of(1995, 11, 29)), actual.get(0))
            );
        }
    }

    @Nested
    @DisplayName("save()")
    class Save {
        @Test
        void success() {
            // テスト対象メソッドに与える引数
            Customer newCustomer = new Customer("天", "山﨑", "tyamasaki@sakura.com", LocalDate.of(2005, 9, 28));
            // CustomerMapperのsave()が実行されたら1を返すように設定
            when(customerMapper.insert(any())).thenReturn(1);
            // テスト対象のメソッドを実行
            customerService.save(newCustomer);
            // CustomerMapperのsave()が1回呼ばれていることをチェック
            verify(customerMapper, times(1)).insert(newCustomer);
        }
    }
}

UserDetailsServiceのテスト

Spring Securityで利用する UserDetailsService 実装クラスのテストは、Serviceクラスのテストと同様です。

AccountDetailsServiceTest.java
public class AccountDetailsServiceTest {

    AccountDetailsService accountDetailsService;

    AccountMapper accountMapper;

    @BeforeEach
    void beforeEach() {
        accountMapper = mock(AccountMapper.class);
        accountDetailsService = new AccountDetailsService(accountMapper);
    }

    @Nested
    @DisplayName("loadUserByUsername()")
    class LoadUserByUsername {
        @Test
        @DisplayName("存在するメールアドレスを指定すると、AccountDetailsが返る")
        void success() {
            when(accountMapper.selectByMailAddress(any()))
                    .thenReturn(Optional.of(new Account(1, "user", "user@example.com", "user")));
            when(accountMapper.selectAuthoritiesByMailAddress("user@example.com"))
                    .thenReturn(List.of("ROLE_USER"));
            AccountDetails actual = (AccountDetails) accountDetailsService.loadUserByUsername("user@example.com");
            assertEquals(new AccountDetails(new Account(1, "user", "user@example.com", "user"),
                    AuthorityUtils.createAuthorityList(List.of("ROLE_USER"))), actual);
        }

        @Test
        @DisplayName("存在しないメールアドレスを指定すると、UsernameNotFoundExceptionが発生")
        void error() {
            when(accountMapper.selectByMailAddress(any()))
                    .thenReturn(Optional.empty());
            when(accountMapper.selectAuthoritiesByMailAddress(any()))
                    .thenReturn(List.of());
            assertThrows(UsernameNotFoundException.class, () -> {
                accountDetailsService.loadUserByUsername("hoge@example.com");
            });
        }
    }
}

コントローラークラスの単体テスト

テストクラスに付加するアノテーションの作成

コントローラーのテストに付加するアノテーションを作成します。

ControllerTest.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("web-test")
public @interface ControllerTest {
}

ポイントは@AutoConfigureMockMvc@ActiveProfiles("web-test")です。@AutoConfigureMockMvcはその名の通り、テストクラスで利用するMockMvcをBean定義してくれます。@ActiveProfiles("web-test")は、このアノテーションを付加したテストをweb-testプロファイルで実行します(これがなぜ必要なのかは後ほど説明します)。

テストメソッドに付加するアノテーションの作成

spring-security-testで用意されている@WithUserDetailsアノテーションは、テストメソッドに付加すると指定したユーザーとしてテストを実行できます。userDetailsServiceBeanName要素にはUserDetailsService実装クラスのBean IDを、value属性にはloadByUsername()メソッドに与える引数を指定します。

今回のサンプルアプリではUSERロールとADMINロールがあるので、それぞれのアノテーションを作成します。併せて、匿名ユーザーのアノテーションも作成します。

TestWithUser.java
// USERロールでテストを実行するアノテーション
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Test
@WithUserDetails(userDetailsServiceBeanName = "accountDetailsService",
        value = "user@example.com")
public @interface TestWithUser {}
TestWithAdmin.java
// ADMINロールでテストを実行するアノテーション
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Test
@WithUserDetails(userDetailsServiceBeanName = "accountDetailsService",
        value = "admin@example.com")
public @interface TestWithAdmin {}
TestWithAnonymous.java
// 匿名ユーザーでテストを実行するアノテーション
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Test
@WithAnonymousUser
public @interface TestWithAnonymous {}

Serviceクラスをモック化するJava Configの作成

Controllerの単体テストの場合、Serviceクラスをモック化します。@MockBeanアノテーションは、Mockitoでのモック化+そのモックをBean定義してくれます。次のコードの場合、CustomerServiceをモック化+Bean定義します。そして、このモックがコントローラーにDIされます。

モックがコントローラー以外のテストで利用されないように、このJava Configに適当なプロファイルを指定します(今回はweb-testとしました)。先ほどControllerTestアノテーションに@ActiveProfiles("web-test")を付加していたのは、このJava Configを利用するためだったのです。

WebTestConfig.java
@Configuration
@Profile("web-test")
public class WebTestConfig {
    @MockBean
    CustomerService customerService;
}

テストクラスの作成

CustomerControllerTest.java
@ControllerTest
public class CustomerControllerTest {

    @Autowired
    MockMvc mvc;

    @Nested
    @DisplayName("トップ画面へのアクセス")
    class TopPage {
        final MockHttpServletRequestBuilder request = get("/")
                .accept(MediaType.TEXT_HTML);

        @TestWithUser
        @DisplayName("userはOK")
        void userOk() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isOk())
                    .andExpect(view().name("index"));
        }

        @TestWithAdmin
        @DisplayName("adminはOK")
        void adminOk() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isOk())
                    .andExpect(view().name("index"));
        }

        @TestWithAnonymous
        @DisplayName("匿名はログイン画面にリダイレクトされる")
        void anonymousNg() throws Exception {
            mvc.perform(request)
                    .andExpect(status().is3xxRedirection())
                    .andExpect(redirectedUrlPattern("**/login"));
        }
    }

    @Nested
    @DisplayName("追加画面へのアクセス")
    class SaveMain {
        final MockHttpServletRequestBuilder request = get("/saveMain")
                .accept(MediaType.TEXT_HTML);

        @TestWithUser
        @DisplayName("userはNG")
        void userNg() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isForbidden());
        }

        @TestWithAdmin
        @DisplayName("adminはOK")
        void adminOk() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isOk())
                    .andExpect(view().name("saveMain"));
        }

        @TestWithAnonymous
        @DisplayName("匿名はログイン画面にリダイレクトされる")
        void anonymousNg() throws Exception {
            mvc.perform(request)
                    .andExpect(status().is3xxRedirection())
                    .andExpect(redirectedUrlPattern("**/login"));
        }
    }

    @Nested
    @DisplayName("追加の実行")
    class SaveComplete {
        final MultiValueMap<String, String> validData =
                new LinkedMultiValueMap<>() {{
                    add("firstName", "天");
                    add("lastName", "山﨑");
                    add("mailAddress", "tyamasaki@sakura.com");
                    add("birthday", "2005-09-28");
                }};

        MockHttpServletRequestBuilder createRequest(MultiValueMap<String, String> formData) {
            return post("/saveComplete")
                    .params(formData)
                    .with(csrf())
                    .accept(MediaType.TEXT_HTML);
        }

        @TestWithUser
        @DisplayName("userはNG")
        void userNg() throws Exception {
            mvc.perform(createRequest(validData))
                    .andExpect(status().isForbidden());
        }

        @TestWithAdmin
        @DisplayName("adminはOK")
        void adminOk() throws Exception {
            mvc.perform(createRequest(validData))
                    .andExpect(status().is3xxRedirection())
                    .andExpect(redirectedUrl("/"));
        }

        @TestWithAdmin
        @DisplayName("adminで不正なデータを登録しようとすると、バリデーションエラーで入力画面に戻る")
        void adminInvalid() throws Exception {
            MultiValueMap<String, String> invalidData =
                    new LinkedMultiValueMap<>() {{
                        add("firstName", "");
                        add("lastName", "");
                        add("mailAddress", "");
                        add("birthday", "");
                    }};
            mvc.perform(createRequest(invalidData))
                    .andExpect(status().isOk())
                    .andExpect(view().name("saveMain"));
        }

        @TestWithAnonymous
        @DisplayName("匿名はログイン画面にリダイレクトされる")
        void anonymousNg() throws Exception {
            mvc.perform(createRequest(validData))
                    .andExpect(status().is3xxRedirection())
                    .andExpect(redirectedUrlPattern("**/login"));
        }
    }
}

.with(csrf()) が無かった場合、レスポンスが403 Forbiddenになってしまうので忘れないよう注意してください。

RESTコントローラークラスの単体テスト

ほぼControllerクラスと同じなのですが、次の点が異なります。

  • andExpect(content().json(expectedJson)) のように返ってきたJSONを比較する
  • with(csrf().asHeader()) としてCSRFトークンをリクエストヘッダーで送信する
RestControllerTest.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("web-test")
public @interface RestControllerTest {
}
CustomerRestControllerTest.java
@RestControllerTest
public class CustomerRestControllerTest {

    @Autowired
    MockMvc mvc;

    // モックのCustomerServiceをDI
    @Autowired
    CustomerService customerService;

    @Nested
    @DisplayName("全顧客の取得")
    class GetCustomers {
        final MockHttpServletRequestBuilder request = get("/api/customers")
                .accept(MediaType.APPLICATION_JSON);
        final String expectedJson = """
                [
                    {
                        "id": 1,
                        "firstName": "友香",
                        "lastName": "菅井",
                        "mailAddress": "ysugai@sakura.com",
                        "birthday": "1995-11-29"
                    },
                    {
                        "id": 2,
                        "firstName": "久美",
                        "lastName": "佐々木",
                        "mailAddress": "ksasaki@hinata.com",
                        "birthday": "1996-01-22"
                    }
                ]
                """;

        @BeforeEach
        void beforeEach() {
            when(customerService.findAll()).thenReturn(List.of(
                    new Customer(1, "友香", "菅井", "ysugai@sakura.com", LocalDate.of(1995, 11, 29)),
                    new Customer(2, "久美", "佐々木", "ksasaki@hinata.com", LocalDate.of(1996, 1, 22))
            ));
        }

        @TestWithUser
        @DisplayName("userはOK")
        void userOk() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isOk())
                    .andExpect(content().json(expectedJson));
        }

        @TestWithAdmin
        @DisplayName("adminはOK")
        void adminOk() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isOk())
                    .andExpect(content().json(expectedJson));
        }

        @TestWithAnonymous
        @DisplayName("匿名はNG")
        void anonymousNg() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isUnauthorized());
        }
    }

    @Nested
    @DisplayName("顧客の登録")
    class PostCustomer {
        final String validJson = """
                        {
                            "firstName":"天",
                            "lastName":"山﨑",
                            "mailAddress":"tyamasaki@sakura.com",
                            "birthday":"2005-09-28"
                        }
                        """;

        MockHttpServletRequestBuilder createRequest(String json) {
            return post("/api/customers")
                    .with(csrf().asHeader())
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(json);
        }

        @BeforeEach
        void beforeEach() {
            doAnswer(invocation -> {
                Customer customer = invocation.getArgument(0);
                customer.setId(999);
                return null;
            }).when(customerService).save(any(Customer.class));
        }

        @TestWithUser
        @DisplayName("userはNG")
        void userNg() throws Exception {
            mvc.perform(createRequest(validJson))
                    .andExpect(status().isForbidden());
        }

        @TestWithAdmin
        @DisplayName("adminはOK")
        void adminOk() throws Exception {
            mvc.perform(createRequest(validJson))
                    .andExpect(status().isCreated())
                    .andExpect(header().string("location", "/api/customers/999"));
        }

        @TestWithAdmin
        @DisplayName("adminで不正なデータを登録しようとすると、400 Bad Request")
        void adminInvalid() throws Exception {
            mvc.perform(createRequest("""
                        {
                            "firstName":"",
                            "lastName":"",
                            "mailAddress":"",
                            "birthday":""
                        }
                        """))
                    .andExpect(status().isBadRequest())
                    .andExpect(content().json("""
                            {
                                "type":"about:blank",
                                "title":"バリデーションエラー",
                                "status":400,
                                "detail":"不正な入力です",
                                "instance":"/api/customers",
                                "messages": [
                                    "名は1文字以上32文字以下です",
                                    "姓は1文字以上32文字以下です",
                                    "メールアドレスはxxx@xxx形式です",
                                    "誕生日はyyyy-MM-dd形式です"
                                ]
                            }
                            """));
        }

        @TestWithAnonymous
        @DisplayName("匿名はNG")
        void anonymousNg() throws Exception {
            mvc.perform(createRequest(validJson))
                    .andExpect(status().isUnauthorized());
        }
    }
}

コントローラーの結合テスト

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)なテストクラスからSeleniumPlaywrightを使うことになると思います。これらを使わない場合は、手動でブラウザからテストすることになります。

RESTコントローラーの結合テスト

RESTコントローラーの場合、テストの記述が簡単です。

結合テストクラスに付加するアノテーションの作成

ApiIntegrationTest.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public @interface ApiIntegrationTest {
}

ポイントはwebEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORTです。これにより、組み込みTomcatをランダムなポート番号で起動します。

結合テストクラスの作成

HTTPアクセスするためのRestClientを作成します。

CustomerApiIntegrationTest.java
@ApiIntegrationTest
@Sql({"classpath:schema.sql", "classpath:test-data.sql"})
public class CustomerApiIntegrationTest {
    RestClient restClient;

    @Autowired
    JdbcTemplate jdbcTemplate;

    @BeforeEach
    void beforeEach(@Autowired RestClient.Builder restClientBuilder, @LocalServerPort int port) {
        restClient = restClientBuilder
                .baseUrl("http://localhost:" + port)
                .defaultStatusHandler(status -> true, (request, response) -> {
                    // ステータスコードが4xx・5xxであっても何もしない
                })
                .build();
    }

    // 後に続く

@LocalServerPortアノテーションを利用すると、組み込みTomcatが起動しているランダムなポート番号を取得できます。

もう1つのポイントは、RestClientdefaultStatusHandler()です。デフォルトでは、レスポンスのステータスコードが400番台または500番台だった場合、RestClientは例外をスローします。テストではこの挙動が不要なので、ステータスコードが何であっても例外をスローしないように変更しています。

事前のログインとCSRFトークンの取得

RestClientでテスト対象にアクセスする前に、ログインとCSRFトークン取得をしておく必要があります。

CustomerApiIntegrationTest.java
    SessionTokenPair login() {
        // 初回CSRFトークン取得
        SessionTokenPair pair1 = getCsrfToken(null);

        // ADMIN権限でログイン
        ResponseEntity<Void> loginResponse = restClient.post()
                .uri("/login")
                .body("username=admin@example.com&password=admin&_csrf=" + pair1.csrfToken())  // CSRFトークンをリクエストパラメーターに指定
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .header("Cookie", pair1.sessionId())
                .retrieve()
                .toBodilessEntity();
        String newSessionCookie = loginResponse.getHeaders().get(HttpHeaders.SET_COOKIE).getFirst();
        String newSessionId = Arrays.stream(newSessionCookie.split(";"))
                .filter(element -> element.startsWith("JSESSIONID="))
                .findFirst()
                .get();

        // 2回目のCSRFトークン取得(ログイン成功時にCSRFトークンが変更されているため)
        // See https://github.com/spring-projects/spring-security/blob/main/web/src/main/java/org/springframework/security/web/csrf/CsrfAuthenticationStrategy.java#L70
        SessionTokenPair pair2 = getCsrfToken(newSessionId);
        return pair2;
    }

    SessionTokenPair getCsrfToken(String sessionId) {
        // CSRFトークンを取得
        ResponseEntity<DefaultCsrfToken> csrfTokenResponse = restClient.get()
                .uri("/api/csrf")
                .header("Cookie", sessionId != null ? sessionId : "foo=bar" /* セッションがまだ無い場合は適当なCookieを設定 */)
                .retrieve()
                .toEntity(DefaultCsrfToken.class);
        String csrfToken = csrfTokenResponse.getBody().getToken();
        // Set-Cookieヘッダーを取得
        List<String> setCookieHeader = csrfTokenResponse.getHeaders().getOrEmpty("Set-Cookie");
        // Set-Cookieヘッダーが無い場合は引数のsessionIdを返す
        if (setCookieHeader.isEmpty()) {
            return new SessionTokenPair(sessionId, csrfToken);
        }
        // Set-Cookieヘッダーがある場合は新しいセッションIDを返す
        String sessionCookie = setCookieHeader.getFirst();
        String newSessionId = Arrays.stream(sessionCookie.split(";"))
                .filter(element -> element.startsWith("JSESSIONID="))
                .findFirst()
                .get();
        return new SessionTokenPair(newSessionId, csrfToken);
    }

テスト実行

テスト実行前に、ログインとCSRFトークン取得を行います。

レスポンスボディのJSONの検証には、JSONAssertクラスを利用します。

CustomerApiIntegrationTest.java
    @Nested
    @DisplayName("全顧客の取得")
    class GetCustomers {
        @Test
        @DisplayName("ADMIN権限で全顧客を取得できる")
        void success() {
            SessionTokenPair sessionTokenPair = login();
            ResponseEntity<String> responseEntity = restClient.get()
                    .uri("/api/customers")
                    .header("Cookie", sessionTokenPair.sessionId())
                    .retrieve()
                    .toEntity(String.class);
            assertAll(
                    () -> assertEquals(HttpStatus.OK, responseEntity.getStatusCode()),
                    () -> JSONAssert.assertEquals("""
                            [
                                {
                                    "id": 1,
                                    "firstName": "友香",
                                    "lastName": "菅井",
                                    "mailAddress": "ysugai@sakura.com",
                                    "birthday": "1995-11-29"
                                },
                                {
                                    "id": 2,
                                    "firstName": "久美",
                                    "lastName": "佐々木",
                                    "mailAddress": "ksasaki@hinata.com",
                                    "birthday": "1996-01-22"
                                }
                            ]
                            """, responseEntity.getBody(), false)
            );
        }
    }

    @Nested
    @DisplayName("顧客の登録")
    class PostCustomer {
        @Test
        @DisplayName("ADMIN権限で顧客登録できる")
        void success() {
            SessionTokenPair sessionTokenPair = login();
            ResponseEntity<Void> responseEntity = restClient.post()
                    .uri("/api/customers")
                    .contentType(MediaType.APPLICATION_JSON)
                    .header("Cookie", sessionTokenPair.sessionId())
                    .header("X-CSRF-TOKEN", sessionTokenPair.csrfToken())
                    .body("""
                            {
                                "firstName":"天",
                                "lastName":"山﨑",
                                "mailAddress":"tyamasaki@sakura.com",
                                "birthday":"2005-09-28"
                            }
                            """)
                    .retrieve()
                    .toBodilessEntity();
            assertAll(
                    () -> assertEquals(HttpStatus.CREATED, responseEntity.getStatusCode()),
                    () -> assertEquals(URI.create("/api/customers/3"), responseEntity.getHeaders().getLocation()),
                    () -> assertEquals(1, JdbcTestUtils.countRowsInTableWhere(jdbcTemplate, "customer", """
                            id = 3
                            AND first_name = '天'
                            AND last_name = '山﨑'
                            AND mail_address = 'tyamasaki@sakura.com'
                            AND birthday = '2005-09-28'
                            """))
            );
        }
    }

Discussion