【35分→4分!】SpringBootアプリケーションのテスト高速化

SpringBootのテストが重くなってきた(35分)ので、改善したら4分まで実行時間を縮められました!

その過程で「こういうテストはこう書くと良い」をいろいろと知ったのでメモしておきます。

ひらべー
ひらべー

35分→4分は「遅いテストが早くなる可能性があるよ」、

逆に「テストの書き方がよくないとめっちゃ遅くなるよ」と捉えていただければと!

実行環境

ライブラリバージョン
spring-boot-starter-web2.4.1
spring-boot-starter-test2.4.1
mybatis-spring-boot-starter2.1.4
mybatis-spring-boot-starter-test2.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);
    }
}
  1. SpringBootを起動し、対象をAutowiredで取得
  2. リクエスト先に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にリクエストを投げる場合には、外部リソース起因で遅くなっていないか確認してみるといいかもしれません。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です