はじめに
この記事で何をするのか
Spring/Spring Bootはテスト関連の機能がとても充実しています。これらの機能を、実際のアプリケーションにどのようにつかっていくかを考えました。
あくまで僕個人の案なので、「いや、ここはこうしたほうがいいよ!」とか「自分はこうしてるよ!」とかありましたら是非コメントください。コメントは「優しく」お願いします。
Spring/Spring Bootのテスト関連機能
この記事では、テスト関連機能の基礎は解説しません。代わりに@shindo_ryoさんの資料をご覧ください。とても素晴らしいまとめです。
spring-security-testについては@opengl_8080さんのブログをご覧ください。
@MybatisTest
については@kazuki43zooさんのブログをご覧ください。
サンプルアプリの技術構成
- Spring Boot 2.4.5
- Spring MVC
- Thymeleaf
- Spring Security
- MyBatis
ソースコードはGitHubに置いてあります。
MyBatis Mapperインタフェースの単体テスト
@MybatisTest
を利用します。
このアノテーションは、デフォルトで組み込みDBを利用します。そうではなくMySQLなどのデータベースを利用したい場合は @AutoConfigureTestDatabase
も付加して Replace.NONE
を指定する必要があります。
@MybatisTest
を利用するには、mybatis-spring-boot-starter-testを依存性に含める必要があります。Mavenの場合は👇のように設定します。(バージョン番号はmybatis-spring-boot-starterと同じにしてください)
<dependencies>
...
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter-test</artifactId>
<version>2.1.4</version>
<scope>test</scope>
</dependency>
...
<dependencies>
CI環境などでデータベースの準備を楽にするためには、Testcontainersを使うといいかもしれません。詳しくは@kazuki43zooさんのブログ(Testcontainersを利用したSpring Bootアプリのテスト)をご確認ください。
@MybatisTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class CustomerMapperTest {
@Autowired
CustomerMapper customerMapper;
@Autowired
JdbcTemplate jdbcTemplate;
@Test
void findAllメソッドで5件取得できる() {
List<Customer> customerList = customerMapper.findAll();
assertEquals(5, customerList.size());
}
@Test
void saveメソッドで1件追加できる() {
Customer newCustomer = new Customer("天", "山﨑", "tyamasaki@sakura.com", LocalDate.of(2005, 9, 28));
customerMapper.save(newCustomer);
assertEquals(6, JdbcTestUtils.countRowsInTable(jdbcTemplate, "customer"));
}
}
Serviceクラスの単体テスト
ServiceクラスはMapperを使っているので、MockitoでMapperをモック化します。特にSpring Testの機能は使いません。
public class CustomerServiceImplTest {
CustomerService customerService;
CustomerMapper customerMapper;
@BeforeEach
void setUp() {
// CustomerMapperのモックを作成
customerMapper = mock(CustomerMapper.class);
// CustomerMapperのモックを利用してCustomerServiceImplインスタンスを作成
customerService = new CustomerServiceImpl(customerMapper);
}
@Test
void findAllメソッドで5件取得できる() {
// CustomerMapperのfindAll()に仮の戻り値を設定
when(customerMapper.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)),
new Customer(3, "美玖", "金村", "mkanemura@hinata.com", LocalDate.of(2002, 9, 10))
));
// テスト対象のメソッドを実行
List<Customer> customerList = customerService.findAll();
// テスト対象の戻り値を検証
assertEquals(3, customerList.size());
// CustomerMapperのfindAll()が1回呼ばれていることをチェック
verify(customerMapper, times(1)).findAll();
}
@Test
void saveメソッドで1件追加できる() {
// テスト対象メソッドに与える引数
Customer newCustomer = new Customer("天", "山﨑", "tyamasaki@sakura.com", LocalDate.of(2005, 9, 28));
// CustomerMapperのsave()が実行されたら何も行わないよう設定
doNothing().when(customerMapper).save(newCustomer);
// テスト対象のメソッドを実行
customerService.save(newCustomer);
// CustomerMapperのsave()が1回呼ばれていることをチェック
verify(customerMapper, times(1)).save(newCustomer);
}
}
UserDetailsServiceのテスト
Spring Securityで利用する UserDetailsService
実装クラスのテストは、Serviceクラスのテストと同様です。
public class AccountDetailsServiceTest {
AccountDetailsService accountDetailsService;
AccountMapper accountMapper;
@BeforeEach
void setUp() {
accountMapper = mock(AccountMapper.class);
accountDetailsService = new AccountDetailsService(accountMapper);
}
@Test
void 存在するユーザー名でAccountDetailsが返る() {
String email = "user@example.com";
Account account = new Account(1, "user", email, "user");
List<String> roleList = List.of("ROLE_USER");
when(accountMapper.findByEmail(email))
.thenReturn(account);
when(accountMapper.findAuthoritiesByEmail(email))
.thenReturn(roleList);
AccountDetails accountDetails = (AccountDetails) accountDetailsService.loadUserByUsername(email);
assertAll(
() -> assertNotNull(accountDetails.getAccount()),
() -> assertEquals(account.getEmail(), accountDetails.getUsername()),
() -> assertEquals(account.getPassword(), accountDetails.getPassword()),
() -> assertEquals(1, accountDetails.getAuthorities().size()),
() -> assertTrue(accountDetails.isAccountNonExpired()),
() -> assertTrue(accountDetails.isAccountNonLocked()),
() -> assertTrue(accountDetails.isCredentialsNonExpired()),
() -> assertTrue(accountDetails.isEnabled())
);
}
@Test
void 存在しないユーザー名でUsernameNotFoundExceptionが発生() {
String email = "user@example.com";
when(accountMapper.findByEmail(email))
.thenReturn(new Account(1, "user", email, "user"));
when(accountMapper.findAuthoritiesByEmail(email))
.thenReturn(List.of("ROLE_USER"));
assertThrows(UsernameNotFoundException.class, () -> {
accountDetailsService.loadUserByUsername("hoge@example.com");
});
}
}
コントローラークラスの単体テスト
@WebMvcTest
を利用します。このアノテーションはSpring Securityも有効化しますので、セキュリティも考慮した上でのテストを行います。
@WithUserDetails
では UserDetailsService
実装クラスを利用します。そのクラスをコンポーネントスキャン対象にするために @WebMvcTest
に includeFilter
要素を追加しています。
さらに、今回のサンプルでは UserDetailsService
実装クラス内でMyBatis Mapperを利用しているため、 @AutoConfigureMybatis
も追加します。
こんな感じで独自アノテーションを作っておくと、同じことを何回も書かなくていいので便利です。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Test
@WithUserDetails(userDetailsServiceBeanName = "accountDetailsService",
value = "user@example.com")
@interface TestWithUser {}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Test
@WithUserDetails(userDetailsServiceBeanName = "accountDetailsService",
value = "admin@example.com")
@interface TestWithAdmin {}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Test
@WithAnonymousUser
@interface TestWithAnonymous {}
ServiceはMockitoでモック化します。
Controllerクラスの場合
@WebMvcTest(includeFilters = @ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = {AccountDetailsService.class}
))
@AutoConfigureMybatis
public class CustomerControllerTest {
@Autowired
MockMvc mvc;
// CustomerServiceをMockitoでモック化する
@MockBean
CustomerService customerService;
@Nested
class トップ画面へのアクセス {
final MockHttpServletRequestBuilder request = get("/")
.accept(MediaType.TEXT_HTML);
@TestWithUser
void userはOK() throws Exception {
mvc.perform(request)
.andExpect(status().isOk())
.andExpect(view().name("index"));
}
@TestWithAdmin
void adminはOK() throws Exception {
mvc.perform(request)
.andExpect(status().isOk())
.andExpect(view().name("index"));
}
@TestWithAnonymous
void 匿名はNG_ログイン画面にリダイレクトされる() throws Exception {
mvc.perform(request)
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern("**/login"));
}
}
@Nested
class 追加画面へのアクセス {
final MockHttpServletRequestBuilder request = get("/insertMain")
.accept(MediaType.TEXT_HTML);
@TestWithUser
void userはNG() throws Exception {
mvc.perform(request)
.andExpect(status().isForbidden());
}
@TestWithAdmin
void adminはOK() throws Exception {
mvc.perform(request)
.andExpect(status().isOk())
.andExpect(view().name("insertMain"));
}
@TestWithAnonymous
void 匿名はNG_ログイン画面にリダイレクトされる() throws Exception {
mvc.perform(request)
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern("**/login"));
}
}
@Nested
class 追加の実行 {
final MultiValueMap<String, String> validData =
new LinkedMultiValueMap<>() {{
add("firstName", "天");
add("lastName", "山﨑");
add("email", "tyamasaki@sakura.com");
add("birthday", "2005-09-28");
}};
MockHttpServletRequestBuilder createRequest(MultiValueMap<String, String> formData) {
return post("/insertComplete")
.params(formData)
.with(csrf())
.accept(MediaType.TEXT_HTML);
}
@TestWithUser
void userはNG() throws Exception {
mvc.perform(createRequest(validData))
.andExpect(status().isForbidden());
}
@TestWithAdmin
void adminはOK() throws Exception {
mvc.perform(createRequest(validData))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/"));
}
@TestWithAdmin
void 不正なデータを登録しようとするとバリデーションエラーで入力画面に戻る() throws Exception {
MultiValueMap<String, String> invalidData =
new LinkedMultiValueMap<>() {{
add("firstName", "");
add("lastName", "");
add("email", "");
add("birthday", "");
}};
mvc.perform(createRequest(invalidData))
.andExpect(status().isOk())
.andExpect(view().name("insertMain"));
}
@TestWithAnonymous
void 匿名はNG_ログイン画面にリダイレクトされる() throws Exception {
mvc.perform(createRequest(validData))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern("**/login"));
}
}
}
.with(csrf())
が無かった場合、レスポンスが403 Forbiddenになってしまうので忘れないよう注意してください。
RestControllerクラスの場合
ほぼControllerクラスと同じなのですが、次の点が異なります。
-
andExpect(content().json(expectedJson))
のように返ってきたJSONを比較する -
with(csrf().asHeader())
としてCSRFトークンをリクエストヘッダーで送信する
@WebMvcTest(includeFilters = @ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = {AccountDetailsService.class}
))
@AutoConfigureMybatis
public class CustomerRestControllerTest {
@Autowired
MockMvc mvc;
// CustomerServiceをMockitoでモック化する
@MockBean
CustomerService customerService;
@Nested
class 全顧客の取得 {
final MockHttpServletRequestBuilder request = get("/api/customers")
.accept(MediaType.APPLICATION_JSON);
final String expectedJson = "[{\"id\":1,\"firstName\":\"友香\",\"lastName\":\"菅井\",\"email\":\"ysugai@sakura.com\",\"birthday\":\"1995-11-29\"}]";
@BeforeEach
void setUp() {
when(customerService.findAll()).thenReturn(List.of(
new Customer(1, "友香", "菅井", "ysugai@sakura.com", LocalDate.of(1995, 11, 29))
)
);
}
@TestWithUser
void userはOK() throws Exception {
mvc.perform(request)
.andExpect(status().isOk())
.andExpect(content().json(expectedJson));
}
@TestWithAdmin
void adminはOK() throws Exception {
mvc.perform(request)
.andExpect(status().isOk())
.andExpect(content().json(expectedJson));
}
@TestWithAnonymous
void 匿名はNG() throws Exception {
mvc.perform(request)
.andExpect(status().isUnauthorized());
}
}
@Nested
class 顧客の登録 {
final MockHttpServletRequestBuilder request = post("/api/customers")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"天\",\"lastName\":\"山﨑\",\"email\":\"tyamasaki@sakura.com\",\"birthday\":\"2005-09-28\"}")
.with(csrf().asHeader());
@BeforeEach
void setUp() {
doAnswer(invocation -> {
Customer customer = invocation.getArgument(0);
customer.setId(999);
return null;
}).when(customerService).save(any(Customer.class));
}
@TestWithUser
void userはNG() throws Exception {
mvc.perform(request)
.andExpect(status().isForbidden());
}
@TestWithAdmin
void adminはOK() throws Exception {
mvc.perform(request)
.andExpect(status().isCreated())
.andExpect(header().string("location", "http://localhost/api/customers/999"));
}
@TestWithAnonymous
void 匿名はNG() throws Exception {
mvc.perform(request)
.andExpect(status().isUnauthorized());
}
}
}
セキュリティを無視したい場合
とにかく今はルーティングだけテストがしたいのでSpring Securityは無効化したいという場合は、こんな感じにします。
@Profile("!disable-security") // プロファイルが"disable-security"でない場合のみこのBeanは有効化される
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
}
@ActiveProfiles("disable-security") // テスト実行時のプロファイルを"disable-security"に指定
@WebMvcTest(excludeAutoConfiguration = { // Spring Security関連のAuto Configurationクラスを無効化
SecurityAutoConfiguration.class,
SecurityFilterAutoConfiguration.class
})
public class CustomerControllerTest {
...
}
結合テスト
@SpringBootTest
なテストクラスからSeleniumを使うことになると思います。Seleniumを使わない場合は手動テストということになるのかな?
HtmlUnitというのもあるんですが、 @WebMvcTest
の時のみ WebClient
がBean定義されるらしく、結合テスト(= @SpringBootTest
)で使うものではなさそうです。あと、@yusukeさんからこういうご意見もいただきました。
HtmlUnitは本物のブラウザと何かと挙動が変わってくるのでテストではお勧めしないです。やっぱりChromeかFirefoxが無難ですね
— ユースケ (山本裕介) (@yusuke) April 23, 2021
RESTの場合はTestRestTemplate
(リファレンス) を使うと良いでしょう。