LoginSignup
86
79

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

Last updated at Posted at 2021-04-22

はじめに

この記事で何をするのか

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と同じにしてください)

pom.xml
	<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 実装クラスを利用します。そのクラスをコンポーネントスキャン対象にするために @WebMvcTestincludeFilter 要素を追加しています。

さらに、今回のサンプルでは 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は無効化したいという場合は、こんな感じにします。

セキュリティのJava_Configクラス
@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さんからこういうご意見もいただきました。

RESTの場合はTestRestTemplate(リファレンス) を使うと良いでしょう。

86
79
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
86
79