Posts for: #Testing

wiremock を使った kubernetes モックサーバーのテスト拡張

2時に寝て7時に起きて9時までだらだらしてた。

Kubernetes Clients のライブラリ実装

昨日の続き 。ある程度、クライアントの振る舞いを確認できたので自前のライブラリを作ることにした。ライブラリなのでテストをちゃんと書きたいと思って単体テストのやり方を調べてたら Kubernetes Clients のリポジトリにも単体テストはほとんどなくて、どうも e2e テストの方を重視しているようにもみえた。github issues を検索してみたら次の issue をみつけた。

なにかしら単体テストの仕組みを作った方がいいんじゃないかという提案と一緒に issue の作者?かどうかはわからんけど、wiremock を使ったテストのサンプルコードをあげていた。名前だけは聞いたことがあったけど、過去に使ったこともなく、どういうものか全くわかってない。ドキュメントを軽く読んでみたら http モックサーバーらしい。issue の内容を参考にしながら wiremock のドキュメントをみて junit5 のテスト拡張を書いてみた。これが適切な実装かはあまり自信がないけど、こんな感じでモックサーバーとモッククライアントの junit5 のテスト拡張を実装した。これは static なモックサーバーの設定になるので wiremock 自体の起動コストは速く感じた。ライブラリのテストとしては申し分ない。いまのところは自画自賛。

@Documented
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface KubernetesApiClient {
}
public class SetupKubernetesWireMock implements BeforeAllCallback, BeforeEachCallback, ExtensionContext.Store.CloseableResource {
    private static final Logger logger = LogManager.getLogger(SetupKubernetesWireMock.class.getName());
    private static int PORT = 8384;
    private static WireMockServer wireMockServer = new WireMockServer(options().port(PORT));
    private static boolean started = false;

    private ApiClient apiClient;

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        if (!started) {
            wireMockServer.start();
            started = true;
            this.configureMockClient();
            var basePath = String.format("http://localhost:%d", wireMockServer.port());
            this.apiClient = ClientBuilder.standard().setBasePath(basePath).build();
            logger.info("started kubernetes wiremock: {}", basePath);
            context.getRoot().getStore(GLOBAL).put("SetupKubernetesWireMock", this);
        }
    }

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        for (var instance : context.getRequiredTestInstances().getAllInstances()) {
            for (var field : instance.getClass().getDeclaredFields()) {
                if (field.isAnnotationPresent(KubernetesApiClient.class)) {
                    field.setAccessible(true);
                    field.set(instance, this.apiClient);
                }
            }
        }
    }

    @Override
    public void close() throws Throwable {
        wireMockServer.stop();
    }

    private void configureMockClient() throws IOException {
        // add static stubs for mock client
        configureFor("localhost", PORT);
        this.stubForBatchGetCronjobs();
        this.stubForBatchGetSingleCronJob("my-job1");
        this.stubForBatchGetSingleCronJob("my-job2");
        this.stubForBatchGetSingleCronJob("my-job3");
        this.stubForBatchPostJob();
        this.stubForBatchGetJob();
    }

    private byte[] getContents(String name) throws IOException {
        var json = new File(this.getClass().getResource(name).getPath());
        return Files.readAllBytes(Paths.get(json.getPath()));
    }

    private void stubForBatchGetCronjobs() throws IOException {
        var contents = this.getContents("/fixtures/kubernetes/batch/cronjobs.json");
        var path = urlPathEqualTo("/apis/batch/v1/namespaces/default/cronjobs");
        stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(contents)));
    }

    private void stubForBatchGetSingleCronJob(String jobName) throws IOException {
        var contents = this.getContents(String.format("/fixtures/kubernetes/batch/%s.json", jobName));
        var url = String.format("/apis/batch/v1/namespaces/default/cronjobs/%s", jobName);
        var path = urlPathEqualTo(url);
        stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(contents)));
    }

    private void stubForBatchPostJob() throws IOException {
        var name = "/fixtures/kubernetes/batch/post-my-job.json";
        var contents = this.getContents(name);
        var url = "/apis/batch/v1/namespaces/default/jobs";
        var path = urlPathEqualTo(url);
        stubFor(post(path).willReturn(aResponse().withStatus(200).withBody(contents)));
    }

    private void stubForBatchGetJob() throws IOException {
        var name = "/fixtures/kubernetes/batch/get-my-job.json";
        var contents = this.getContents(name);
        var url = "/apis/batch/v1/namespaces/default/jobs/my-job";
        var path = urlPathEqualTo(url);
        stubFor(get(urlPathEqualTo(url)).willReturn(aResponse().withStatus(200).withBody(contents)));
    }
}

assertj を使ってみた

0時に寝て6時に起きた。

assertDeepEquals を作った

AssertJ というアサーションライブラリを使って assertDeepEquals を実装した。

junit4 では hamcrest という matcher が使われていて、それが assertDeepEquals 相当の機能を提供していたが、それが junit5 では提供されなくなったので自分で実装するか、アサーションライブラリを別途使う必要がある。

現時点でJUnit5ではHamcrestのMatcherは提供せず、使用者が自由に選択する方針で進んでいます。そうなった場合、標準でサポートされるassertTrueやassertEquelsなどだけでは、ちょっと頼りなく車輪の再発明になりそうなので、候補になりそうなHamcrestとAssertJのよく使いそうなメソッド比較表を作りました。

JUnitのアサーションライブラリHamcrest,AssertJ比較

2.4.2. Third-party Assertion Libraries によると、junit は基本的なアサーション機能を提供し、より強力なアサーションはサードパーティ製の好きなライブラリを使ってくれみたいなことが書いてある。軽く github でソースコード検索しても、みんな自前で作っているんやなということも分かる。

hamcrest はもう保守されていないようにみえるので assertj を使うことにした。assertj の機能を使うと assertDeepEquals を次のように実装できる。直接 assertj を使ってもよいのだけど、assertXxx という名前で使えた方が junit ベースのテストのアサートの統合性があるし、いまお手伝い先では myapp-test のような、テスト向けの共通ライブラリを提供していて、すべてのプロジェクトで既に使っているので assertj の依存関係を追加しなくてもすぐに使えるというぐらいの利便性を提供するだけのユーティリティになる。

public class Assertions {

    public static final void assertDeepEquals(Object expected, Object actual) {
        assertThat(expected).usingRecursiveComparison().isEqualTo(actual);
    }

    public static final void assertDeepEquals(Object expected, Object actual, String... fields) {
        assertThat(expected).usingRecursiveComparison().comparingOnlyFields(fields).isEqualTo(actual);
    }

    public static final void assertDeepEqualsIgnoringFields(Object expected, Object actual, String... fields) {
        assertThat(expected).usingRecursiveComparison().ignoringFields(fields).isEqualTo(actual);
    }
}

データベースを介したテストではまった話し

1時に寝て7時に起きた。帰りにふらっと仲のよい焼き鳥屋さんに寄ったらちょっとしたハプニングがあって長居してしまった。他に来ていたお客さんのカップルが別れ話を始め、こじれてややこしい状況になって、この騒動が一段落しないと席を立てない空気になってしまって終わるのを待ってた。マスターの知り合いらしくて、そのお客さんが帰ってから当事者たちの背景を聞いたりしてた。人生いろいろあるよなぁ。

spring の Transactional アノテーション

spring フレームワークには Transactional というアノテーションがある。SpringBootTest を使ったテストのときに使うと、テストメソッドの終了時に自動的にデータベースへの書き込みがロールバックされて便利なことを テストコードのリファクタリング をしていたときに気付いた。

スレッドプールを使ってマルチスレッドで並行実行する処理を書いてそのテストを書いてみたら意図した結果にならない。なんでだろう?と2時間ほどはまってデバッグしていた。テストデータの書き込みが、実際にはデータベースにコミットされていないので、テストを実行しているスレッド以外のワーカースレッドからデータベースにアクセスしてもテストデータを参照できないからだと気付いた。データベースのトランザクションに細工すると、こういうはまりどころがあるなぁと気付いて Transactional を使わずに普通にテストを書いた。その分、自分でテストメソッドが呼ばれてコミットされたテストデータを削除する必要がある。調べていたときに他にも副作用がいろいろあるよという記事もみつけた。

Mockito を触ってみた

0時に寝て4時に起きて6時に起きた。6時過ぎに slack でインフラ担当者から作業の報告があってその対応してた。

Mockito のモック作成

Spring 5 WebClient のテストコードを書いてみた。Mockito というモックライブラリを使っているのをみかけたのでそれを使うことにした。当初は WebClient そのもののモックを用意して、どんなメソッドを呼び出しても Null オブジェクトのように無視すればいいんじゃないかと思ってたんだけど、Mockito はそういう用途に使うものではなく、それぞれのメソッドごとにモックを返すような設定ができる。次のような WebClient のメソッドチェーンでリクエストするようなモックを考える。

var response = this.client
        .get()
        .uri(uriBuilder -> uriBuilder
                .path(path)
                .queryParam("param", param)
                .build())
        .retrieve()
        .bodyToMono(MyResponse.class)
        .block();

他にもっとよいやり方があるかもしれないけど、私がよくわかってなくてこんなやり方しかできなかった。最終的には block() を呼び出したときに任意のレスポンスを取得できればよいのだけど、メソッド単位にモックを呼び出していかないと型チェックやら実行時エラーやらで意図したようにテストできなかった。これだけをみたらメソッドチェーンのモック作りは面倒にみえる。Mockito がどうやってこれを実現しているのかわからないけど、すごい仕組みだなとは思った。

    @MockBean
    WebClient client;
    @Mock
    WebClient.RequestHeadersUriSpec requestHeadersUriSpec;
    @Mock
    WebClient.RequestHeadersSpec requestHeadersSpec;
    @Mock
    WebClient.ResponseSpec responseSpec;
    @Mock
    Mono<MyResponse> mono;

    private void mockWebClientMethodChain(MyResponse response) {
        Mockito.when(client.get()).thenReturn(requestHeadersUriSpec);
        Mockito.when(requestHeadersUriSpec.uri((Function<UriBuilder, URI>) Mockito.any())).thenReturn(requestHeadersSpec);
        Mockito.when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
        Mockito.when(responseSpec.bodyToMono(MyResponse.class)).thenReturn(mono);
        Mockito.when(mono.block()).thenReturn(response);
    }

テストコードのリファクタリング

0時に寝て6時に起きた。今日は7時半から23時過ぎまで集中してコードを書いてた。最近は19-20時には帰って、晩ご飯食べて、ドラクエタクトやったり漫画読んだりだらだらしている。そんな暇あったら積ん読の本読めって感じだ。

テストコードのリファクタリング

業務機能の開発をするにあたって、既存のテストコードをみていて、@BeforeEach というテストメソッド単位に呼ばれるメソッドでテストデータの削除と postgresql の sequence のリセット処理をしていた。こんなの共通処理ですべてのテーブルの truncate と sequence のリセット処理をすればいいやんとか思って、いろいろ調べて2つのリファクタリングの PR を作成した。先日 JUnit5 の拡張 を調べたばかりだから、テストの共通化のノウハウが溜まっている。Testcontainers Postgres Module と連携して、postgresql コンテナに接続して sequence のリセット処理を汎用のテスト拡張として実装した。テストを実装する開発者は、次のように @ExtendWith(DatabaseInitializer.class) をアノテーションに付与すれば、自分で sequence のリセット処理を @BeforeEach のメソッドに実装する必要がなくなる。

@SpringBootTest
@Transactional
@ExtendWith(SetupDatabaseContainer.class)
@ExtendWith(DatabaseInitializer.class)
class MyTest {
    ...
}

この作業の過程で spring boot の @Transactional はデフォルトでテストメソッドの実行後にロールバックする機能が提供されていて、いままで @BeforeEach のメソッドで明示的にテーブルのデータを削除する必要はなかったんやと気付いた。じゃあ、なぜ削除するコードを書いてたかと言うと、テストの外部で初期データを作成する仕組みがあるから、初期データを削除する目的でそうしていたことが判明した。そして、一部のコードはそこで作った外部の初期データに依存して実装されていた。テストコードの一部が外部のデータに依存しつつ、テストメソッドでは外部のデータに依存しないように削除のコードが書いてある。書いていて何を言っているのかわからないと思うけど、私も調べてて訳がわからんくて、PR に「いまの状況はかなりややこしい」と前置きしつつ、無駄なコードや仕組みを取り除くための修正を行った。本当は機能開発やらないといけないのにテストコードのリファクタリングするのに大きな時間をかけるわけにはいかないだろうという意図で、半日掛けてリファクタリングして23時過ぎまで作業して、既存のテストコードも含めて全部直した。このリファクタリングで数十のテストケースの約300行ぐらいの初期化コードをなくせた。

JUnit5 のテスト拡張

1時に寝て5時に起きて2度寝して9時に起きた。前日呑んでたのであまり眠れなくて体調よくない。

JUnit5 的なロガーのテスト

お仕事でログ管理の機能開発をしている。カスタムロガーを使って出力するメッセージを加工している。設計が固まってきて機能も作り込むようになってきたので出力内容が意図した構造化ログになっているかをテストしたい。JUnit5 の機能と log4j の機能を組み合わせてカスタムロガーのテストの仕組みを作ってみた。

まずログ出力した内容を取得するオブジェクトを特定するためのアノテーションを定義する。

@Documented
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoggerTestWriter {
}

JUnit5 の Declarative Extension Registration の仕組みを使って、テストケース非依存な setup/teadown のメソッドを定義する。ExtensionContext から拡張するテストケースのインスタンスを取得できる。テストケースインスタンスに定義されている @LoggerTestWriter アノテーションがついたオブジェクトを lgo4j の Appender としてインジェクションするようなコードを setup/teardown (beforeEach/afterEach メソッド) で定義する。Appender のインジェクション周りは Log4j 2でログ出力をテストするサンプルソース の記事を参考にした。

public class SetupLogAppender implements BeforeEachCallback, AfterEachCallback {
    private static String APPENDER_NAME = "logger-test-appender";

    private Optional<Writer> getWriter(ExtensionContext context) throws IllegalAccessException {
        var testInstance = context.getRequiredTestInstance();
        for (var field : testInstance.getClass().getDeclaredFields()) {
            if (field.isAnnotationPresent(LoggerTestWriter.class)) {
                return Optional.of((Writer) field.get(testInstance));
            }
        }
        return Optional.empty();
    }

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        var writer = this.getWriter(context).orElseThrow(() ->
                new IllegalStateException("@LoggerTestWriter のアノテーションをもつ Writer を定義してください"));
        addAppender(writer, APPENDER_NAME);
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        var writer = this.getWriter(context).orElseThrow(IllegalStateException::new);
        removeAppender(APPENDER_NAME);
        if (writer instanceof StringWriter) {
            var stringWriter = (StringWriter) writer;
            stringWriter.getBuffer().delete(0, stringWriter.getBuffer().length());
        }
    }

実際にテストを書くテストクラスは次のようになる。

@ExtendWith(SetupLogAppender.class)
public class MyLoggerTest {
    private static final MyLogger logger = new MyLogger(MyLoggerTest.class.getName());

    @LoggerTestWriter
    StringWriter writer = new StringWriter();

    @Test
    void testDebugMap() {
        logger.debug("my-message")
        assertEquals("my-message" writer.toString());
    }

@ExtendWith で指定した SetupLogAppender クラスの beforeEach や afterEach がそれぞれのテストメソッドごとに呼ばれて、Appender のインジェクションが @LoggerTestWriter のアノテーションをもつ writer を使って行われる。この writer にはログ出力した文字列が記録されるようになる。これで、テストメソッドで logger に対して出力したメッセージを writer から取得できるので意図したメッセージが出力されていることをテストできる。カスタムロガーのテストケースごとに再利用可能な拡張をきれいに実装できた。