Spring Bootのテストを実際のアプリケーションでどうやるか考えてみた
はじめに
この記事で何をするのか
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のテストに付加するアノテーションを作成します。
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest
public @interface MapperTest {
}
この状態だと、テストクラスに@SpringBootTest
を付けるとの同じです。しかし、全Mapperのテストに共通する処理を追加したい場合などに便利なので、僕はよく独自アノテーションを作成します。
そして、テストクラスを作成します。
@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の機能は使いません。
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クラスのテストと同様です。
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");
});
}
}
}
コントローラークラスの単体テスト
テストクラスに付加するアノテーションの作成
コントローラーのテストに付加するアノテーションを作成します。
@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
ロールがあるので、それぞれのアノテーションを作成します。併せて、匿名ユーザーのアノテーションも作成します。
// USERロールでテストを実行するアノテーション
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Test
@WithUserDetails(userDetailsServiceBeanName = "accountDetailsService",
value = "user@example.com")
public @interface TestWithUser {}
// ADMINロールでテストを実行するアノテーション
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Test
@WithUserDetails(userDetailsServiceBeanName = "accountDetailsService",
value = "admin@example.com")
public @interface TestWithAdmin {}
// 匿名ユーザーでテストを実行するアノテーション
@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を利用するためだったのです。
@Configuration
@Profile("web-test")
public class WebTestConfig {
@MockBean
CustomerService customerService;
}
テストクラスの作成
@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トークンをリクエストヘッダーで送信する
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("web-test")
public @interface RestControllerTest {
}
@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)
なテストクラスからSeleniumやPlaywrightを使うことになると思います。これらを使わない場合は、手動でブラウザからテストすることになります。
RESTコントローラーの結合テスト
RESTコントローラーの場合、テストの記述が簡単です。
結合テストクラスに付加するアノテーションの作成
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public @interface ApiIntegrationTest {
}
ポイントはwebEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
です。これにより、組み込みTomcatをランダムなポート番号で起動します。
結合テストクラスの作成
HTTPアクセスするためのRestClient
を作成します。
@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つのポイントは、RestClient
のdefaultStatusHandler()
です。デフォルトでは、レスポンスのステータスコードが400番台または500番台だった場合、RestClient
は例外をスローします。テストではこの挙動が不要なので、ステータスコードが何であっても例外をスローしないように変更しています。
事前のログインとCSRFトークンの取得
RestClient
でテスト対象にアクセスする前に、ログインとCSRFトークン取得をしておく必要があります。
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
クラスを利用します。
@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