SpringBoot(WebFlux)とJUnit5とWireMock

2020-03-07T17:35:15

SpringBoot(WebFlux)とJUnit5とWireMock

概要

前回の調査で、今自分の環境で一番有効に機能しそうと思ったのがWireMockを使った結合試験だった。それを掲題の環境で動くところまで持っていったメモ。

http://wiremock.org/docs/extending-wiremock/

ただ、公式にはまだJUnit5に対応できてないので色々と手を入れないと動かなかった…。
ちなみに、SpringBootとの組み合わせとしては、CDCに対応したパッケージもある。

http://wiremock.org/docs/extending-wiremock/

CDCは今の組織には重すぎるので今回は触れない!

参考サイト

いずれも海外サイトを見ながらなんとか動かした。国内にはこの組み合わせの事例があまり、記事としては見かけられない。

https://www.swtestacademy.com/wiremock-junit-5-rest-assured/
https://rieckpil.de/spring-boot-integration-tests-with-wiremock-and-junit-5/

環境

mavenを使用しているのでそれベースでいくつか記載する。

まず、spring-boot-starter-testから不要パッケージを除外する。
これをしないと警告まみれになる。

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
      <exclusions>
        <exclusion>
          <!-- JUnit5を使うのでJUnit4を除外 -->
          <groupId>junit</groupId>
          <artifactId>junit</artifactId>
        </exclusion>
        <exclusion>
          <groupId>org.junit.vintage</groupId>
          <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
      </exclusions>
    </dependency>

次にWiremockを入れる。
reactor-testはWebFlux用なので、Webとかの人はいれなくて良い。

    <dependency>
      <groupId>com.github.tomakehurst</groupId>
      <artifactId>wiremock</artifactId>
      <version>2.26.1</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>io.projectreactor</groupId>
      <artifactId>reactor-test</artifactId>
      <scope>test</scope>
      <version>3.2.3.RELEASE</version>
    </dependency>

テストツールはmockitとJUnit5 を利用している。

テストコード例

今回テスト対象のサンプルとして選んだのはドメイン駆動におけるアプリケーションサービス層である。
これを、従来はmockitなどでMockしながら頑張って結合試験を行っていた。

なので、テストコードに何を書いているのか、2週間も経てば読むのに苦労する有様だったのだ。

正常系

この正常系のコードは、従来、10個近くのMockを作成し、FormApplicationServiceに渡さないことには動かなかった。

しかし、WireMockであれば、Mock化するのは自社製のログインライブラリ絡みの認証を扱うLoginCookieクラスだけですんでいる。

import com.github.tomakehurst.wiremock.WireMockServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.net.InetAddress;
import java.util.Objects;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;

@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ContextConfiguration(initializers = {WireMockInitializer.class})
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
@DisplayName("FormApplicationServiceImpl:inputForm の統合テスト")
public class FormApplicationServiceTests {
  @Autowired
  private WireMockServer wireMockServer;

  @AfterEach
  public void afterEach() {
    this.wireMockServer.resetAll();
  }

  @Autowired
  FormApplicationService formApplicationService;

  @Test
  public void 正常系_テストサンプル() {
    String tenpoId = "400000200";
    String date = "2030-03-03T19:00:00";

    // mock/stub
    LoginCookie loginCookieMock = LoginCookieMockFactory.buildGuestCookie();

    // wiremock standby
    // --> mappingsで対応

    // test target
    ReservationUserRequest reservationUserRequest
      = ReservationUserRequestForTestsFactory.buildGuestRequest(tenpoId, date, loginCookieMock);

    Optional<InetAddress> inetAddress = Optional.of(InetAddress.getByName("127.0.0.1"));

    Mono<FormResponse> formResponse = formApplicationService.inputFrom(
    reservationUserRequest,
    Device.SP,
    inetAddress
    );

    // StepVerifierがReactorTest.
    StepVerifier.create(formResponse)
    // 特に確認したいパラメータについてテストを行う
    // expectNextMatchesはストリームに対する処理なので、Monoで2つ重ねることは出来ない
    .expectNextMatches(response -> {
        // tenpo
        if(Objects.isNull(response.getTenpo())) return false;
        if(!response.getTenpo().getId().equals(tenpoId)) return false;
        return true;
    })
    .expectComplete()
    .verify();
  }
}
  • ActiveProfilesがあるのは、test用Resourcesを設定したかったからである。
  • MockサーバのURLをlocalhostで起動させるので、外部APIのURLをほとんどすべてMockサーバに向けたかった。
  • SpringBootTestがあるのは、resourcesの読み込みやDIなどの機能を動かしたかったからである。
  • ContextConfigurationは後ほど説明する。

なぜ、明示的に外部APIのスタブを作らなくても、テストが可能になるのか。それは、WireMockのMapping機能による。

http://wiremock.org/docs/stubbing/

これは、src/test/java/resources/mappings ディレクトリに配置した、下記のようなマッピングファイルによってスタブサーバが自動で設定されているためだ。

{
  "name": "画像取得API",
  "request": {
    "urlPattern": "/v1/hoge/s[0-9]+/images",
    "method": "GET"
  },
  "response": {
    "status": 200,
    "bodyFileName": "200/hoge_image.json",
    "headers": {
      "Content-Type": "application/json; charset=UTF-8"
    }
  }
}

mappingファイルは、主にrequest/responseの定義で構成される。
requestはスタブサーバが待ち受けるURLを示しており、固定値はもちろん、正規表現を扱うこともできる。
多種多様のマッチング方法があるので、興味があれば下記をリファレンスを。
http://wiremock.org/docs/request-matching/

responseはスタブサーバの応答を示しており、Httpステータス、ヘッダー、Bodyなどを設定できる。
ここでは、BodyにJsonファイルを指定している。

Jsonファイルのデフォルトrootは、resources/__filesになっている。

異常系

異常系のコードもほとんど正常系と同じだが、1点だけ異なる。
下記のようなスニペットをテストコードに追加している。

    JsonNode tenpoJson = ResponseForTestsFactory.buildContract("INVALID_DATA_TYPE");
    wireMockServer.stubFor(
      get("/v1/tenpos/s400000200")
      .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
        .withJsonBody(tenpoJson))
    );

stubForはWireMockが提供する、JavaコードからStubを登録するメソッドである。
また、このメソッドで登録したスタブは、マッピングファイルのものよりも優先度が高くなる。

上記は、これを利用して本来正常系のデータを返すスタブを、異常なデータを返すよう上書きしているのである。

ResponseForTestsFactoryは、正常系のjsonファイルをJsonNodeで読み取り、予め決めておいた値を引数で上書きして、JsonNodeを返すクラスである。
やや無理矢理なのは否めないが、現状これ以上に良さそうな方法を自分は見つけられていない。。

初期化処理

WireMockの初期化は、テストクラスにContextConfigurationで紐付けているイニシャライザが担当している。

import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import com.github.tomakehurst.wiremock.extension.responsetemplating.ResponseTemplateTransformer;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.event.ContextClosedEvent;

public class WireMockInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

  @Override
  public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
    WireMockServer wireMockServer =
      new WireMockServer(
        new WireMockConfiguration()
          .port(8088)
          .extensions(new ResponseTemplateTransformer(true)));  // レスポンステンプレートの有効化
    wireMockServer.start();

    configurableApplicationContext.getBeanFactory().registerSingleton("wireMockServer", wireMockServer);

    configurableApplicationContext.addApplicationListener(applicationEvent -> {
      if (applicationEvent instanceof ContextClosedEvent) {
        wireMockServer.stop();
      }
    });
  }
}

WireMockの拡張機能である、ResponseTemplateなどを利用する場合もここで設定できる。(ResponseTemplateTransformerがそれである)
http://wiremock.org/docs/response-templating/

ここでポート8088 でスタブサーバを設定しておき、テスト用アプリケーションプロファイルにおいて、使用している外部APIのURIをlocalhost:8088にむけているのである。

所感

  • 正常系、異常系のテストどちらもが、Mockを使いまくるよりずっと良さそう
  • Jsonファイルを管理するコストはあるが、Mockの返り値を管理するよりずっといい。