本番に近い状況でクラスをテストする

初めに

kintoneチームの前田です。 kintoneはリリースから10年以上経過しSeleniumテストの数が膨大になっており、開発プロセスに重くのしかかってきています。 そこで、本物のDBや、本物のサービスと同等の振る舞いをするフェイクを使った新しいテストを最近試しています。 このテストはkintoneのサーバーを起動しないため軽量に実行できながらも、 Seleniumテストと同等の保護を備えているような感触を掴めるようになっています。 本記事ではこの新しいテストについて紹介します。

テストの書き方

本物のDBやフェイクを使ったテストは下のような姿になっています。 今回紹介するテストはこのようなJavaのクラスのテストです。 ちなみにテストフレームワークはJUnitです。

class RecordServiceTest {
    @Inject
    private FakeUserService fakeUserService;
    
    @Inject
    private AppService appService;
    
    @Inject
    private RecordService recordService

    @Test
    public void testAddRecord() {
        // Setup: アプリを準備
        // userを追加
        User user = fakeUserService.registerUser("user");
        // アプリをはじめから作成
        App app = appService.add("App")
        // アプリにフィールドを追加
        appService.addFields(app, List.of(SingleLineTextField.of("文字列__1行_")));
        // アプリをデプロイ
        appService.deploy(app);

        // Exercise: レコードを追加
        long recordId = recordService.addRecord(app, user.getId(), List.of(Field.singleLineText("文字列__1行_", "もじれつ")));

        // Verify: 追加したレコードを取得できるかを確認
        List<Record> records = recordService.listByIds(app, user.getId(), List.of(recordId));
        assertThat(records.get(0).getValue("文字列__1行_").orElseThrow()).isEqualTo("もじれつ");
    }
}

ここではRecordServiceというクラスのaddRecordというメソッドのテストを例にしています。 RecordSerivceはkintoneのアプリのレコードに関する処理を担当するクラスで、 addRecordはアプリとデータを引数にとってアプリのレコードとしてデータをDBに保存します。 addRecordのテストではテスト用のデータを使って保存を試み、その後にデータを取得して保存時のものと一致するかを確認しています。

本物のDBを使うと最初に述べた通り、データの保存では実際に本物のDBに保存しています。 データの取得では同じDBから取得しています。 テストを実行する時はMySQLを起動し、テストコードからアクセスさせています。 その他にもSetupでアプリをはじめから作成しフィールドを追加する箇所でも作成したアプリはDBに保存されています。

本物のDB以外にも、FakeUserServiceというUserServiceのフェイクを使っています。 まずUserServiceの説明をすると、ユーザーの追加やユーザー一覧の取得などのユーザーに関するサービスが別サービスとしてkintoneとは別のサーバーで稼働しています。 このサービスにアクセスするためのインターフェースがUserServiceであり、実際に問い合わせを行う実装がUserServiceImplとなっています。 FakeUserServiceUserServiceImplと同じような振る舞いをするテスト用の軽量実装で、ユーザーの追加や取得を模擬しています。 したがってUserServiceの実装としてDIで差し込むことで、UserServiceに依存していてもサーバーを起動することなくテストできます。

本物のDBやフェイクを含んだコンポーネントの依存関係を図示すると下のようになります。

コンポーネントと依存関係

kintoneチームのテストには今まで単体テスト、結合テスト (内部APIテストのみ)、Seleniumを使ったテストがあり、 ここに今回新しく本物のDBやフェイクを使ったクラスのテストが加わりました。 したがってテストを書く際にどの区分のテストで書くかという選択肢が増えたことになります。 テストの姿だけを見ると単体テストに見えますが、本物のDBと結合するので区分としては結合テストになるかと思います。 また、kintoneのサーバーを起動しなくて済むので、内部APIテストやSeleniumテストよりも軽量になります。 ここで、Seleniumテストはkintoneのサーバーや依存するミドルウェア等を起動させてブラウザからアクセスするテストで、 内部APIテストはSeleniumテストと同じくサーバーや依存するミドルウェア等を起動させますがkintoneの内部APIを呼び出してその振る舞いを確認するテストになります。

導入の背景

このような新しいテストの仕組みを導入したのは膨大な量となったSeleniumテストをなんとかするためです。 kintoneチームの開発プロセスでは新しい機能を追加するごとに、 その機能のユーザーストーリーを直接保護できるSeleniumテストを追加するようになっています。 しかし、リリースから10年以上経過し、Seleniumテストの数も大きいものになっています。 そのためテストの実行時間が長くなっており、開発プロセスに重くのしかかってきています。 そこで代替手段の一つとして、今回紹介した本物のDBやフェイクを使ったテストを調べていました。

本物のDBやフェイクを使ったテストは、 Googleのソフトウェアエンジニアリングという本を参考にしています。 「そのため我々は、本物のオブジェクトが高速で決定性である限りは、 モックオブジェクトよりも本物のオブジェクトを使うことを好む傾向がある」1をはじめ、 Googleのソフトウェアエンジニアリングには参考になるアイディアが数多く載っていました。 フェイクについても詳しく紹介されています。 この本はkintoneチームの勉強会で読んだ本で、読んだ後に有志で集まって導入を続けていました。

また単体テストの考え方/使い方という本も参考になりました。 「管理下にある依存に対しては実際のインスタンスを使うようにし、管理下にない依存に対してはモックを使うようにしましょう。」2とあるなど、 Googleのソフトウェアエンジニアリングと似たような主張がなされていました。 また「しかしながら、ほとんどのアプリケーションには、モックに置き換えるべきではないプロセス外依存が存在します。その依存とはデータベースのことです。」3という記載があったのと、本物のDBと結合した結合テストがコードレベルで書かれており、これらはkintoneでの書き方につながりました。

メリットとデメリット

本物のDBやフェイクを使ったテストのメリットとデメリットを紹介します。 一番のメリットはテストに関係ない変更で壊れにくいということです。 RecordServiceのテストを見ればわかるように、 内部実装がテストコードにまったく現れていないと言っても大丈夫に思います。 したがって、テストに関係ない内部実装のみの変更に強くなったと考えています。

一方、今までRecordSerivceのようなDBにアクセスするようなクラスをテストする時は、 下のようなモックを使う書き方しかありませんでした。

@Test
public void testAddRecord() {
    // Setup: アプリを準備
    AppRow appRow = makeDummyAppRow(1L);
    doReturn(appRow).when(appRepository.getById(1L))

    List<FieldRow> fieldRows = makeDummyFieldRows();
    doReturn(fieldRows).when(fieldRepository.listByAppId(1L))

    // Setupが続く
    // ...
    
    // Exercise: レコードを追加
    recordService.addRecord(app, user.getId(), List.of(Field.singleLineText("文字列__1行_", "もじれつ")));

    // Verify: レコードが追加されたか確認
    verify(recordRepository).insert(any(), recordCaptor.capture());
    RecordRow record = recordCaptor.getValue();
    assertThat(record.getValue("文字列__1行_")).isEqualTo("もじれつ");
}

AppRepositoryFieldRepositoryなどのテストと関係ないクラスがテストコードに現れるため、 テストに関係なくともこれらのクラスを変更すると壊れることがあります。 私もある機能追加でメソッドの引数を増やす修正をしたことでメソッド呼び出しのモックが一斉に壊れ、 これを直すためにモックにダミー引数を増やす修正を大量に行う状況になってしまったことがあります。 加えて、テストのセットアップ処理がAppRepositoryなど内部クラスを単位として書かれるせいか、テストを理解するのが難しく感じます。 例えばAppRepositorygetByIdの返り値を設定したとして、それによってどういう状況を作りたいのかを理解するのは容易ではないと思います。 本物のDBを使うと内部実装がテストコードに現れないため、このようなことは起こりません。

さらに、本物のDBやフェイクを使うテストは本番に近い状況でのテストになるため、 信頼性が十分あるのではないかと考えています。 たとえば上記のようなモックを使う場合は、このIDでこのメソッドを呼び出したらこの値を返すというように、 そのメソッドに何らかの前提を与えた上でテストをするということになります。 しかし、その前提が真であると確認されることは稀であると思いますし、 知らない間に成り立たなくなってしまう可能性もあります。

@Test
public void testAddRecord() {
    AppRow appRow = makeDummyAppRow(1L);
    // AppRepositoryがこの振る舞いをすることは確認済み?
    doReturn(appRow).when(appRepository.getById(1L));

    List<FieldRow> fieldRows = makeDummyFieldRows();
    // FieldRepositoryがこの振る舞いをすることは確認済み?
    doReturn(fieldRows).when(fieldRepository.listByAppId(1L));
 
    // ...
}

本物のDBを使う場合は特にそのような前提をおきません。 フェイクを使った場合でもフェイクのテスト (後述) によりその振る舞いは真であると確認できているので、 真かどうか不明な前提はおいていないと思います。 このように差分が少ないことから本番とテスト時で状況が近くなっており、信頼性がより高いと言えるのではないかと思います。

以上の2点のメリットから、 本物のDBやフェイクを使った新しいテストは内部APIテストやSeleniumテストの代替手段とできるように考えています。 このことについては今後さらに検証を進めたいと思います。

このほかにも個人的にいいなと感じていることは、テストされるメソッドが他の公開メソッドからも理解でき、メソッドの理解が深まりそうな点です。 本物のDBを使ったテストではaddRecordするとその後はlistByIdsできると書いてあります。 これはつまり「レコードを追加するとは追加後にlistByIdsを呼べることだ」と、 テストされるメソッドが他の公開メソッドを通じて理解できそうに思います。 一方のモックを使ったテストではaddRecordはその最中にRecordRepository#insertがあると書いてあります。 これは粒度の大きな追加メソッドが粒度の小さな追加メソッドを呼んでいるかどうかを検証しているだけになっており、 「レコードを追加するとはどういうことか」が現れてないように感じています。

デメリットとしては本物のDBを使うことによって不安定になったり、 このようなテストをただ追加するだけでは今よりも実行時間が長くなるのではないかといったことが挙げられます。 不安定になることに関しては、MySQLには同期的にアクセスすることやMySQL自体がテスト時でも安定して動いていることから、今のところは問題になっておりません。 実行時間が長くなることに関しては、このテストの導入がまだ始まったばかりなため、これから注視したいと思っています。 仮に遅くなったらH2などの軽量DBを使ったり並列化を試す、テストの構成を考え直すなどをしたいと考えています。

フェイクのテスト

前述したように、フェイクの振る舞いが本物とずれていると、クラスのテストとして不十分です。 そこで、フェイクが本物のサービスと同等に振る舞うことを確認するために、フェイクにもテストを書いています。 先ほどのFakeUserServiceにあるgetUserByIdのテストは以下のようになっています。

class FakeUserServiceTest {
    @Nested
    class GetUserById {
        @Inject
        private UserServiceImpl userServiceImpl;

        private UserV1Client client = new UserV1Client();

        @Test
        void notFake() throws Exception {
            // ユーザー管理サービスのDBの初期化
            TestDatabaseUtil.replace(init, "dump.sql");

            // ユーザー追加APIを利用してユーザーを追加
            String code = "user1";
            client.usersPost(code);

            long userId = client.usersGet(code);
            assertGetUserById(userId, userServiceImpl);
        }

        @Test
        void fake() {
            FakeUserService fakeMiddleUserService = new FakeUserService();
            User user = fakeMiddleUserService.registerUser("user1");
            assertGetUserById(user.getId(), fakeMiddleUserService);
        }

        private void assertGetUserById(long userId, UserService userService) {
            assertThat(userService.getUserById(userId).code()).isEqualTo("user1");
        }
    }
}

fakeがフェイクを使ったテスト、notFakeが実際のサービスを使ったテストになっていて、双方が同じassertGetUserByIdを呼び出しています。 assertGetUserByIdでは、ユーザーが追加されている状態で、ユーザーIDからgetUserByIdを使ってユーザーを取得できるかを確認しています。 このようにgetUserByIdを確認するメソッドを共通化し、テスト用のフェイクと本番環境で使う実装のそれぞれから呼び出すことで、両者の振る舞いが一致することを確かめています。

assertGetUserByIdで前提となっているユーザーが追加されている状態の作り方について、 notFakeでは本番環境で使うユーザー管理サービスの振る舞いの確認になるため、 テスト時に実際にサービスを立ち上げてユーザーを追加しています。 UserServiceはkintoneからサービスにアクセスするためのインターフェイスになっているため、ユーザーを追加するメソッドがありません。 そこで立ち上げたユーザー管理サービスに対してユーザー追加APIを実行し、 ユーザーが追加されている状態を作っています。 一方のfakeではFakeUserServiceにあるregisterUserメソッドによってユーザーが追加されている状態を模擬しています。

フェイクをテストする際のコンポーネントの依存関係を図示すると下のようになります。

フェイクのテスト時のコンポーネントと依存関係

フェイクのテストを書いた後で気づいたことは、UserServiceのような機能を提供する別サービスへの依存がたくさんある状態にフェイクが効果を発揮しそうだなということでした。 RecordServiceでは現状はUserServiceくらいにしか依存がありませんが、今後別サーバーで稼働するサービスが増えてそういったサービスに依存が増えるかもしれません。

// 本物のサービスを使う場合
class RecordServiceTest {
    @Inject
    private UserService userService; // kintone外にあるユーザー管理サービス
    
    @Inject
    private FooService fooService; // kintone外にある何らかのサービスfoo
    
    @Inject
    private BarService barService; // kintone外にある何らかのサービスbar
    
    @Inject
    private BazService bazService; // kintone外にある何らかのサービスbaz
    
    // ...
}

このように依存する別サービスが増えていく状況においてテストをするには、 本物のDBを使ったテストのように本物のサービスを稼働させて接続しにいく形のテストにしたりE2Eテストにする等の手段がありますが、 どちらにしても困難になると思います。 というのも、どちらのやり方もテスト実行時に依存するサーバーをすべて同時に稼働させておく必要があるためです。 依存するサービスすべてを同時に安定な状態にしておかないとテスト自体が安定しなくなることから、各サーバーのメンテナンスコストが増大すると思います。 これは依存するサービスが増えるとより深刻になります。

フェイクを使うとこのような同時性を排除でき、メンテナンスのコストを抑えられるように思います。 機能面においては、テスト対象となるクラスは本番で稼働するサービスに依存しているわけでなく、 そのサービスの振る舞いに依存していると考えられます。 フェイクは本物のサービスと同等に振る舞うことをテスト済みであり機能面からはその振る舞いに区別がつかなくなるため、本物の代わりにテストに利用することは問題ないと思います。 すると本物のサービスに依存しなくなるので、テスト時には依存する各サービスを稼働する必要はありません。 また、フェイクのテストはフェイクする各サービス毎に実行できるため、稼働や安定化を個別に行うことができます。 したがって同時性を排除でき、テストのメンテナンスコストの増大を防ぐことに繋がりそうに思います。 このことは現段階では推測の域を出ていないため、今後フェイクやフェイクを使ったテストが増えていった際には確認したいと思います。

今後の課題と展望

今後の課題としては第一に、最初に言及した通りSeleniumテストの代替として使ってみることです。 kintoneチームは今までSeleniumテストや単体テストについては進んでテストを書いていました。 しかし、今回導入したテストの区分である結合テストに関しては他のテストほど充実しておらず、ノウハウが蓄積されていないという現状です。 今回導入した本物のDBを使ったりフェイクを使ったテストも、モックを使う単体テスト以外にも書き方がありますよ、くらいの存在感しかありません。 したがって結合テストについて学習し、区分毎に効率よくテストを配分していきたいと思います。

またフェイクのあり方についても課題を感じています。 例えばフェイクのオーナーについて、フェイクを参考にしたGoogleのソフトウェアエンジニアリングではサービスを所有しているチームがフェイクも実装するとなっています。 しかし、現状ではkintoneで利用しているフェイクをメンテナンスしているのは本物のサービスのオーナーではないkintoneチームになっており、そこまで到達できていません。 これはフェイクの導入をまずkintoneチーム内だけで小さく始めたためです。 今後フェイクを使ったテストが広がり、テストの一つとして確立したなどのタイミングで、移管を考えてもいいのかもしれません。

フェイクのあり方については他にも、本物を使うかフェイクを使うかの判断がまだ手探りということもあります。 今のところはkintoneの機能実装の一部となっているものはそのまま使う、 kintoneチーム以外がメンテナンスしているサービスはフェイク、のようになっていると思います。

前述したようにkintoneが利用するDBについては本物を用いており、現状は大丈夫かなというように考えています。 kintoneの単体テストはGithub Actionsを使って動かしており、MySQLもサービスコンテナとして簡単に立ち上げることができ、テスト時においても安定稼働しております。 kintoneが利用するDBはkintoneチームで管理しておりDDLファイルがどこにあるかなども把握できています。 また、このDBはkintoneの機能実装の一部であるため、Javaとまとめてテストするのも理解はできるのかなと思います。

一方でフェイクを使ったUserServiceについては、フェイクのほうが都合がいいように思います。 アクセスする先のユーザー管理サービスは別チームによってメンテナンスされており、 仮に本物のサービスをkintoneのテストでも使う場合、自分達が詳しくないサービスを立ち上げないといけないため、 テストの環境構築が大変になるように思います。 さらにサービスを利用するユーザーの属性もkintoneと違います。 このように開発しているチームの違いや、テストの環境構築が大変になりそうな懸念、 サービスが提供している機能の違いなどからフェイクを通して分離しています。

ところで、ユーザー管理サービスのような別チームによってメンテナンスしているサービスへの依存は、 単体テストの考え方/使い方における管理下にない依存であり、モックを使うタイミングです。 今回フェイクの導入によって、このタイミングでモックの他にフェイクも選ぶことができる状況になっています。 単体テストの考え方/使い方にはフェイクについての言及はないですが、フェイクを選んでも問題ないのかなと思います。

展望としては、フェイクとコード分割とで相乗効果が生まれそうに感じています。 kintoneチームではコード分割が進んでおり、現在はアプリ設定機能がkintone内である程度独立した領域として抽出されつつあります。 その結果としてアプリ設定サービス(AppSettingsService)のようなものがkintone内にできそうです。 さらにこのフェイク(FakeAppSettingsService)を作ることでテストコードがより改善されるのではないかと考えています。

blog.cybozu.io

例えば、最初に説明したRecordServiceのテストは次のように書けるように思います。

class RecordServiceTest {
    @Inject
    private FakeUserService fakeUserService;
    
    @Inject
    private FakeAppSettingsService fakeAppSettingsService;
    
    @Inject
    private RecordService recordService

    @BeforeEach
    public void testAddRecord() {
        // Setup
        // userを追加
        User user = fakeUserService.registerUser("user");
        // アプリを追加
        App app = fakeAppSettingsService.registerApp("App", List.of(SingleLineTextField.of("文字列__1行_", "文字列 (1行)")));

        // Exercise: レコードを追加
        long recordId = recordService.addRecord(app, user.getId(), List.of(Field.singleLineText("文字列__1行_", "もじれつ")));

        // Verify: レコードを取得
        List<Record> records = recordService.listByIds(app, user.getId(), List.of(recordId));
        assertThat(records.get(0).getValue("文字列__1行_").orElseThrow()).isEqualTo("もじれつ");
    }
}

FakeAppSettingsServiceはアプリ設定機能のフェイクで、registerAppを実行することでアプリを作成することができます。 本物のDBを使っていた時ではアプリをはじめから作成し、フィールドを追加、デプロイという手順を踏んでいましたが、 フェイクを使うと一気に実行でき、さらに軽量実装なので速くおわります。 テストという観点では、アプリがどのように作られているのかは関心の外で、 つまりはアプリが存在している状態に到達しさえすれば問題ありません。 なのでそういった点からもこちらの書き方のほうが理想的ではないかと思います。 また、フェイクを使うとテストはアプリの状態にのみ依存するようになり、アプリをどう作るかというアプリ設定機能にも依存しなくなります。 このようにアプリ設定機能への依存が減ると、アプリ設定機能の機能変更もしやすくなるのでないかと思います。 なお、registerAppされた状態と、アプリをはじめから作成し、フィールドを追加、デプロイという手順を踏んだ後の状態が一致することは、 FakeAppSettingsServiceのテストで確認する想定です。 アプリの作成やフィールド追加などの更新はいろいろなメソッドと協調動作するので、 このフェイクのテストはかなり大変になることが予想されます……。

まとめ

本物のDBやフェイクを使った本番に近い状況で実行できるテストを紹介しました。 kintoneチームのテストには今まで単体テスト、内部APIテスト、Seleniumを使ったテストがあったのですが、この中に新しく加わりました。 これはGoogleのソフトウェアエンジニアリングや単体テストの考え方/使い方という本を参考にしました。 本物のDBやフェイクを使ったテストは内部実装の変更に強く、Seleniumテストと同等の保護を備えているような感触を掴めるようになっています。 またコード分割と組み合わせることで更なる改善にもつながりそうです。 このテストは最近できたばかりなので、今後も使い方を探求してきたいと思っています。


  1. Googleのソフトウェアエンジニアリング p279
  2. 単体テストの考え方/使い方 p270
  3. 単体テストの考え方/使い方 p271