Micronaut ことはじめ - テストを書いて歩こう (5)

f:id:nemuzuka:20200611200142p:plain

前回 までで一般的な Web アプリケーションを作るのに必要な Controller 部分のサンプルが溜まってきたと思います。あとは UseCase とか Repository が残っていますが、こいつらは Micronaut 依存が少ないので興味がある方は引き続きやっていただければと思います*1。 今回はテスト周りをやってみたいと思います。

コードはこちら (本文のコードは色々端折ってるのでコードをご確認ください)

Form のテスト

ここでは、Web ブラウザからのリクエストを受ける class を Form と呼んでいます。 Bean Validation で validation を行うのであれば、少なくともアノテーション(と設定)を正しく付与していることをテストする必要があります。

Validator

テストクラスはこんな感じです。

@MicronautTest // 1
class TaskFormTest {

  @Inject Validator validator; // 2

  @Test
  @DisplayName("validate でエラーが起きないケース")
  void testValidate() {
    // setup
    var sut = TaskForm.builder().taskName("name_0001").content("content_001").build();

    // exercise
    var actual = validator.validate(sut);

    // verify
    assertThat(actual).isEmpty();
  }

  @Test
  @DisplayName("null を渡した時")
  void testValidate_NullValue() {
    // setup
    var sut = new TaskForm();

    // exercise
    var actual = validator.validate(sut); // 3

    // verify
    var messages =
        actual
            .stream()
            .map(value -> value.getPropertyPath() + ":" + value.getMessage())
            .collect(Collectors.toList());
    assertThat(messages).containsOnly("taskName:must not be null", "content:must not be null");
  }
}
  1. @MicronautTest をつけることで、@Singleton をつけた class をテスト側で @Inject して使用することができます
  2. Validator を Inject します
  3. エラーがあると Validator#validate の戻り値に設定するので verify します

@Inject が spring で言う @Autowired です*2。 Validation の他に必要なテストがあれば追加していきます。

Controller の UnitTest

UnitTest では 依存する UseCase 等は Mock に置き換えて、メソッド呼び出し時を観点としたテストを行います。 Mock ライブラリとしてMockito を使っています。

テストクラスはこんな感じです。

@ExtendWith(MockitoExtension.class)  // 1
class AddControllerTest {

  @Mock private TaskUseCase taskUseCase; // 2

  @Mock private ConstraintViolationMessageConverter constraintViolationMessageConverter;

  @Mock private HttpRequest<Map<String, Object>> request;

  @InjectMocks AddController sut; // 3

  @Test
  @DisplayName("addTask のテスト.")
  void testAddTask() {
    // setup
    doNothing().when(taskUseCase).createTask(any());
    var taskForm = TaskForm.builder().taskName("タスク名").content("内容").build();

    // exercise
    var actual = sut.addTask(taskForm);

    // verify
    assertThat(actual)
        .isInstanceOfSatisfying(
            NettyMutableHttpResponse.class,
            response -> {
              assertThat(response.getHeaders().get(HttpHeaders.LOCATION)).isEqualTo("/tasks");
              assertThat(response)
                  .returns(HttpStatus.MOVED_PERMANENTLY, NettyMutableHttpResponse::getStatus);
            });
    verify(taskUseCase).createTask(taskForm.toCreateTaskUseCaseRequest()); // 4
  }
}
  1. @MicronautTest の代わりに @ExtendWith をつけます
  2. テスト対象 class が Inject している class を @Mock で mock 化します
  3. @InjectMocks がついていると @Mock を付けた class を Inject してインスタンスを生成します
    • 今回は AddController のコンストラクタの引数に @Mock を付けた class のインスタンスを受け取るようにしています
  4. mock 使ったら呼び出し回数や引数の verify をしましょう。そうしないとテストの意味が激減します

アクセス権やログイン済みか?の状態のテストはここでは行いません。Java のロジックとして想定通りかの観点でテストを行います。Micronaut はあまり関係ないです。

IntegrationTest

画面が絡んでくるとどこまでやるべきか悩ましいです。 ひとまず雑に要素の存在チェックをすることにしました。E2E のテストは別でやると思ってます。

@MicronautTest // 1
class AddControllerIntegrationTest {

  @Inject IntegrationTestHelper integrationTestHelper; // 2

  @Inject TaskUseCase taskUseCase;

  @Test
  @DisplayName("GET /tasks/add のテスト")
  void testIndex() throws Exception {
    // setup
    var client = integrationTestHelper.login(); // 3
    var request = integrationTestHelper.buildGetRequest("/tasks/add");

    // exercise
    var response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));

    // verify
    assertResponseWithBody(
        response,
        HttpStatus.OK,
        html -> {
          assertElementExists(html, "input[name='csrfToken']"); // jsoup でドキュメントの要素を verify
          assertTextEquals(html, "h3.title.is-3", "Task 登録");
        });
  }

  @Test
  @DisplayName("POST /tasks/add のテスト")
  void testAdd() throws Exception {
    assertThat(taskUseCase.allTask()).isEmpty();

    // setup
    var client = integrationTestHelper.login();
    var csrfToken = integrationTestHelper.getCsrfToken(client, "/tasks/add"); // 4

    var postParameter =
        "csrfToken=" + csrfToken + "&taskName=task_name_001&content=task_content_001";
    var request = integrationTestHelper.buildPostRequest("/tasks/add", postParameter);

    // exercise
    var response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));

    // verify
    assertResponseWithLocation(
        response,
        HttpStatus.MOVED_PERMANENTLY,
        location -> assertThat(location).isEqualTo("/tasks"));

    // 永続化していること
    var actual = taskUseCase.allTask();
    assertThat(actual).hasSize(1);
    var actualTask = actual.get(0);
    assertThat(actualTask.getTaskId()).isNotNull();
    assertThat(actualTask)
        .returns("task_name_001", TaskUseCaseResult::getTaskName)
        .returns("task_content_001", TaskUseCaseResult::getContent);
  }
}
  1. @MicronautTest をつけます。netty が立ち上がります
  2. テスト用に使う Helper を用意しておきます
  3. login 状態にした client を取得してアクセスします
  4. CSRF トークンを取得してアクセスします

テスト用に起動した内部サーバとの通信を Running the Embedded Server のように

@Inject
@Client("/")
HttpClient client; 

でやりたかったのですが、今回のサンプルは Session Authentication を組み込んでいるので、ログイン済みの状態を示すために Cookie が必要でした。 なので、io.micronaut.http.client.HttpClient でなく、java.net.http.HttpClient を使用することにしました。 また、ログイン成功時の Cookie を保持した HttpClient や CSRF トークンを取得する処理を Helper として定義しています。

IntegrationTest ではブラウザからのリクエストを受けた時の振る舞いを確認します。 なので、

  • ログインはしているが、操作する権限を持っていないユーザがリクエストした
  • エンドポイント呼び出し時に RDBMS に登録しているか
  • リクエストパラメータの Validation でエラーになった時

等もこのテストでの確認ポイントになります。

例えば AWSSDK を mock して...と言う時は、Using Mockito Mocks を参考に Mock にすることができます*3。 テスト用に設定を変えたい時は @Property@MicronautTest の propertySources を指定すれば良さそうです。

まとめ

どこまでテスト書くかは時間との兼ね合いかもしれませんが、テストがあればデグレしてない安心感を得られます。 フレームワーク依存の部分もテストがあれば version up する時に気付きやすくもなります。面倒かもだけどテスト大事です。

こうしてみると Spring でできたことができないケースは少ないと思います。 テストの起動も Spring よりは早い印象を受けます。ちょっとした API サーバはもちろんですが、Micronaut で大体いける気がしてきました。

*1:私が Repository に使うなら doma2 ですかね

*2:spring でも @Inject 使えるんですね...

*3:ちょっと面倒ですけどね