SpringBootのテストが重くなってきた(35分)ので、改善したら4分まで実行時間を縮められました!
その過程で「こういうテストはこう書くと良い」をいろいろと知ったのでメモしておきます。
35分→4分は「遅いテストが早くなる可能性があるよ」、
逆に「テストの書き方がよくないとめっちゃ遅くなるよ」と捉えていただければと!
実行環境
ライブラリ | バージョン |
---|---|
spring-boot-starter-web | 2.4.1 |
spring-boot-starter-test | 2.4.1 |
mybatis-spring-boot-starter | 2.1.4 |
mybatis-spring-boot-starter-test | 2.1.4 |
結合テスト(@SpringBootTest
)
抽象クラスにテスト用の設定をまとめる
例えば以下の2つのテストはSpringBootが1回しか起動しません。
@SpringBootTest
class ApplicationTest {
@Test
void test() {
System.out.println("execute test");
}
}
@SpringBootTest
class ApplicationTest2 {
@Test
void test() {
System.out.println("execute test2");
}
}
しかし、ApplicationTest2
が以下のようになっているとSpringBootが2回(テストごとに)起動します。
@SpringBootTest
// SpringBootの起動条件が変わる
@TestPropertySource(properties = {"hoge=huga"})
class ApplicationTest2 {
@Test
void test() {
System.out.println("execute test2");
}
}
言われてみれば当然だが、起動するときの条件(↑でいうとproperty)が違えばSpringBootを再起動せざるを得ない…
逆に「同じ条件なら再起動なしで早い!」という発見でした!
なので、@SpringBootTest
含め、テストのためのクラスレベルのアノテーションをすべて抽象クラスに押し込んで、継承して使うとメンテが楽です!
@SpringBootTest
abstract class SpringBootTestBase {
}
class ApplicationTest extends SpringBootTestBase {
@Test
void test() {
System.out.println("execute test");
}
}
class ApplicationTest2 extends SpringBootTestBase {
@Test
void test() {
System.out.println("execute test2");
}
}
@MockBean
は極力使わない
これも上記と同じで
登録されるBeanを書き換える必要があるのでSpringBootが再起動してしまう
外部APIはWireMock(spring-cloud-contract-wiremock)を使うとか、
DBはh2dbを使うとかでできるだけ代替手段を探す。
どうしても使いたい場合は、抽象クラスに定義する方法なら再起動を防げます!
単体テスト
Controller: WebMvcTest
を使う
@WebMvcTest
を使う
サンプルとして以下のようなControllerでテストを書いてみます
@RestController
@RequiredArgsConstructor
public class SampleController {
private final UserMapper userMapper;
@GetMapping("/users")
public Object users() {
return userMapper.findAll();
}
}
@WebMvcTest(SampleController.class)
class SampleControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserMapper userMapper;
@Test
void test() throws Exception {
when(userMapper.findAll()).thenReturn(new ArrayList<>());
mockMvc.perform(MockMvcRequestBuilders.get("/users"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().string("[]"));
}
}
RestTemplate: MockRestServiceServer
を使う
以下のようなRestTemplateに依存するコンポーネントのテストについてです。
@Component
@RequiredArgsConstructor
public class HelloApiClient {
private final RestTemplate restTemplate;
public String hello() {
return restTemplate.getForObject("/hello", String.class);
}
}
- SpringBootを起動し、対象をAutowiredで取得
- リクエスト先にAPIはWireMockでスタブ化
という方法もあるのですが、MockRestServiceServer
を使えばSpringBootから切り離せます!
class HelloApiClientTest {
private HelloApiClient target;
private MockRestServiceServer server;
public HelloApiClientTest() {
// RestTemplateを生成し、ClientとServerにそれぞれ設定する
RestTemplate restTemplate = new RestTemplate();
target = new HelloApiClient(restTemplate);
server = MockRestServiceServer.bindTo(restTemplate).build();
}
@Test
void test() {
this.server.expect(requestTo("/hello"))
.andRespond(withSuccess("hello", MediaType.TEXT_PLAIN));
String actual = target.hello();
assertThat(actual).isEqualTo("hello");
}
}
WebClient:OkHttpを使う
以下のようなWebClientに依存するコンポーネントのテストについてです。
@RequiredArgsConstructor
@Component
public class SampleApiClient {
private final WebClient webClient;
public Mono<String> sample() {
return webClient.get()
.uri("/sample")
.retrieve()
.bodyToMono(SampleResponse.class)
.map(res -> res.getMessage());
}
@Data
public static class SampleResponse {
private String message;
}
}
okhttpを使用し、Mock用のHTTPサーバを起動してテストします。
dependencies {
...
// 依存を追加
testImplementation 'com.squareup.okhttp3:okhttp'
testImplementation 'com.squareup.okhttp3:mockwebserver'
}
public class SampleApiClientTest {
private static MockWebServer mockServer;
private ObjectMapper objectMapper = new ObjectMapper();
private SampleApiClient sampleApiClient;
@BeforeEach
void beforeEach() {
var webClient = WebClient.builder()
.baseUrl("http://localhost:" + mockServer.getPort())
.build();
sampleApiClient = new SampleApiClient(webClient);
}
@BeforeAll
static void startMock() throws IOException {
mockServer = new MockWebServer();
mockServer.start();
}
@AfterAll
static void shutdownMock() throws IOException {
mockServer.shutdown();
}
@Test
@SneakyThrows
void test() {
// Mockサーバのレスポンスを設定する
var responseBody = new SampleApiClient.SampleResponse();
responseBody.setMessage("Hello, world.");
var mockedResponse = new MockResponse()
.setBody(objectMapper.writeValueAsString(responseBody))
.addHeader("Content-Type", "application/json");
mockServer.enqueue(mockedResponse);
// リクエスト送信
var actual = sampleApiClient.sample();
StepVerifier.create(actual)
.expectNext("Hello, world.")
.verifyComplete();
}
}
Mybatis mapper: @MybatisTest
を使う
以下のようなMybatisのmapperクラスのテストについてです
@Mapper
@Repository
public interface UserMapper {
@Select("select * from users")
List<UserEntity> findAll();
}
テーブルはこちら
CREATE TABLE USERS (
id VARCHAR(10),
name VARCHAR(10)
);
@MybatisTest
という、Mybatisに関するコンポーネントのみで動いてくれるアノテーションを使用すればOK!
...
dependencies {
...
testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:2.1.+'
...
}
@MybatisTest
class UserMapperTest {
@Autowired
private UserMapper userMapper;
@Test
@Sql(statements = {
"INSERT INTO users VALUES ('1', 'rhirabay')"
})
void test_findAll() {
var users = userMapper.findAll();
assertThat(users).hasSize(1);
}
}
アノテーション@Sql
によって、DBの初期化も可能です。
また、@MybatisTest
で@Transactional
が自動で付与されるため、テスト実行後にロールバックもしれくれます😇
Redis Client: 組み込みRedisを使う
RedisTemplateを依存に持つ単体テストについてです
embedded-redis
というライブラリを使用すると組み込みのRedisサーバを起動できるため、
これを使ってRedisTempateをMock化せずに使用するのが個人的におすすめです。
dependencies {
...
// 依存を追加
testImplementation 'it.ozimov:embedded-redis:0.7.3'
}
// springframeworkのものではない
import redis.embedded.RedisServer;
import static org.assertj.core.api.Assertions.assertThat;
class RedisClientTest {
private static RedisServer redisServer = null;
private RedisClient redisClient;
@BeforeEach
void init() {
// 組み込みRedisに接続するRedisTemplateを生成する
var connFactory = new LettuceConnectionFactory("localhost", 6379);
connFactory.afterPropertiesSet();
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connFactory);
redisTemplate.afterPropertiesSet();
redisClient = new RedisClient(redisTemplate);
}
@BeforeAll
public static void setup() {
if (redisServer == null) {
// 組み込みRedisを起動する
redisServer = new RedisServer(6379);
redisServer.start();
}
}
@AfterAll
public static void teardown() {
if (redisServer != null) {
// 組み込みRedisを停止する
redisServer.stop();
redisServer = null;
}
}
@Test
void test() {
var value1 = redisClient.get("sample_key");
assertThat(value1).isNull();
redisClient.set("sample_key", "sample_value");
var value2 = redisClient.get("sample_key");
assertThat(value2).isEqualTo("sample_value");
}
}
AOP: AspectJProxyFactory
を使う
APIで使用したHelloApiClient
に適用されるAdviceクラスをテストします
@Slf4j
@Aspect
@Component
public class HelloApiClientAdvice {
@Before("execution(* rhirabay.infra.HelloApiClient.hello())")
public void advice() {
log.info("execute HelloApiClient.hello");
}
}
AspectJProxyFactory
を使ってproxyを取得することでAOPの動きも含めてテストできます。
AOPも含めて動作するようHelloApiClientTestを修正してみます。
class HelloApiClientTest {
private HelloApiClient target;
private MockRestServiceServer server;
private HelloApiClient proxy;
public HelloApiClientTest() {
RestTemplate restTemplate = new RestTemplate();
this.target = new HelloApiClient(restTemplate);
this.server = MockRestServiceServer.bindTo(restTemplate).build();
AspectJProxyFactory factory = new AspectJProxyFactory(target);
factory.addAspect(new HelloApiClientAdvice());
this.proxy = factory.getProxy();
}
@Test
void test() {
this.server.expect(requestTo("/hello"))
.andRespond(withSuccess("hello", MediaType.TEXT_PLAIN));
String actual = this.proxy.hello();
assertThat(actual).isEqualTo("hello");
}
}
その他: @InjectMocks
と@Mock
を使う
@InjectMocks
と@Mock
を使って単体で試験
番外編
外部のリソースを改善する
結合試験時に、テスト用のDBを参照していたのですが、そのDBのリソースが枯渇し遅くなっていました。
同様にテストでDBを参照している場合や、稼働しているAPIにリクエストを投げる場合には、外部リソース起因で遅くなっていないか確認してみるといいかもしれません。