機能部分をwebと分離することによるE2Eテストの削減

はじめに

kintoneチームの前田です。 kintoneでは新規機能を開発する際にユーザーストーリーを担保するE2Eテストを追加していました。 これによりある程度の品質を担保することができていたのですが、E2Eテストの数が膨大となって大きな負担となってしまい、非効率な面も目立ってきています。 この状況を改善するために、機能に関わる処理の一切をwebから分離するという内部設計の改善によって、E2Eテストを削減しようとしています。 この記事ではこの試みについて紹介します。

E2Eテストとその問題

kintoneのE2EテストにはE2E-uiテストとE2E-apiテストの二種類があります。 E2E-uiテストはSeleniumを使ってkintoneに対するユーザーの操作を模擬するテストです。 E2E-apiテストはサーバーサイドのAPIの振る舞いを検査するテストで、基本的にはREST APIのテストに使っていますが、内部APIのテストにも使っています。 E2E-uiとE2E-apiのどちらのテストも、開発環境に本番と同等の状態を作ってテストされます。 すなわち、ユーザー管理システムやElasticsearch、ジョブキューなど、kintoneに関わるコンポーネントのフルセットをデプロイします。

E2E-uiテストとE2E-apiテストのそれぞれでテストケース数が数千ケースを超えており、開発を支援するはずのテストが反って非効率さを生んでいます。 実行に時間がかかりますし、特にE2E-uiテストは製品に問題なくてもテストが失敗するといった不安定なテストになりやすいです。 仮に不具合をきちんと捕捉していたとしても、実行される範囲が広いため、どこで落ちたのかわかりづらいです。 例えば、ボタンがクリックできずにE2E-uiテストが失敗したとして、不安定なのか、CSSのミスなのか、サーバーサイド側の処理に問題があったのかすぐにはわかりません。 さらに、E2Eテストを現実的な時間で終わらせるために並列化が必要であること、そのためにコンポーネントのフルセットを並列化可能な状態でデプロイする必要があることや、リソースを大量に消費することで、何度も実行させるのが難しいです。これはリリース頻度を増やす上での妨げになります。

今までの取り組み

このようなE2Eテストを改善する取り組みは今までいくつか行われていました。 まず、QAとPGが協力し、できるだけE2E-uiテストは追加せず、E2E-apiテストとフロントエンドのテストでカバーするようにしています。 kintoneではReact化が進んでおり、モダンなツールセットによってフロントエンドに閉じたテストでも品質を担保できるようになっています。 実際、2024年ではほぼE2E-uiテストは追加されていません。 これによってSelenium起因の不安定なテストの増加は抑えることができています。

また、テストの分割によってE2Eテストの部分実行が可能になっており、E2Eテストの実行時間はある程度短縮可能な状況です。 これについては次の記事に記載されています。

blog.cybozu.io

しかし、上記の取り組みでも既存の数千ケースあるE2E-uiテストは手つかずになっていますし、E2E-apiテストは増えてしまっています。

内部設計の見直しとE2Eテストの削減

そこでkintoneの内部設計を見直すことでE2Eテストを減らす取り組みを始めています。 具体的には、サーバーサイドのAPI実装において、kintoneの機能に関する部分とwebに関する部分とを分離し、機能部分に絞って結合テストを行うというものです。 このことを図示すると次のようになります。 この図は、後に紹介するブックマーク機能での分離を表しています。

機能とwebの分離と結合テストの導入

機能に関する部分をwebと分離

kintoneのサーバーサイドにおけるAPI実装はコントローラー層やサービスクラス、リポジトリ、データクラスなどから構成されます。 これらのうち、サービスクラス、リポジトリ、データクラスは基本的にkintoneの機能に関する部分になります。 その一方でコントローラー層については送受信されるJSONのエンコードとデコード、値のバリデーションや型変換、リクエストURIからの情報取得など、webに関する処理も必要になります。 そのため、コントローラー層ではkintoneの機能に関する処理とwebに関する処理が混然一体となってしまっています。 もちろん機能としての主要な処理はサービスクラスなどに切り出されてはいるので、コントローラーはサービスクラスに処理を任せる構造にはなっています。 しかし、どこまでの処理がコントローラー層でどこからがサービスクラスなのかはあいまいで、実装者のさじ加減で決まってしまっています。

下は分離前のコードで、ブックマークを追加するAPIのコントローラーです。 JSONをデコードしたAddInputからブックマークURLを取得する部分はwebに関する処理です。 しかし、その後のDTOを生成してURLの情報をDTOにセットしたり、setAppIdを呼び出してアプリIDをセットする部分は、上述したwebに関する処理に該当していません。 これらは更新用のDTOを作るという更新処理の一部であると位置づけるのが妥当です。 そして、このコードをテストするとなると、APIテストになってしまいます。

package com.cybozu.kintone.bookmark.api;

import com.cybozu.kintone.bookmark.BookmarkService;

public class AddController {

    private final BookmarkMapper bookmarkMapper;
    private final BookmarkService bookmarkService;
    private final AppService appService;

    @RequestMapping({ "/bookmark/add.json" })
    public AddOutput run(@RequestBody AddInput input) {
        URL url = parseUrl(input.getUrl());

        BookmarkDto bookmarkDto = new BookmarkDto();
        bookmarkDto.setPath(url.getPath());
        bookmarkDto.setQuery(url.getQuery());
        bookmarkDto.setRef(url.getRef());
        bookmarkDto.setTitle(input.getTitle());

        setAppId(bookmarkDto);

        bookmarkService.add(bookmarkDto);

        BookmarkOutput bookmarkOutput = new BookmarkOutput();
        bookmarkMapper.convertToBookmarkOutput(bookmarkDto, bookmarkOutput);

        AddOutput output = new AddOutput();
        output.setBookmark(bookmarkOutput);
        return output;
    }
}

そこで、機能に関する処理の一切をコントローラー層から無くします。 コントローラーにある処理のうち、kintoneの機能に関する部分をAPI専用のインターフェイスに切り出し、このAPI用インターフェイスをコントローラーは呼び出す、という構造にします。 コントローラー層で可能な処理は、上述したwebに関する処理の他は、API用インターフェイスの呼び出しのみになります。 機能実装に関する知識が入り込まないようにするため、API用インターフェイスのメソッド呼び出しは一回だけです。

分離を完全なものにするためにクラスの依存も制限し、機能実装を内部実装としてコントローラー層から隔離します。 コントローラー層が依存する機能に関するクラスはAPI用インターフェイスを含め.exporttowebパッケージ下にあるクラスのみにします1。 API用インターフェイスにある各メソッドのリクエストモデルやレスポンスモデルも必要であれば専用に切り出します。 SpringのDIにより実装は自動で注入することができるので、コントローラー層に公開するのはAPI用インターフェイスとそれに付随するリクエスト、レスポンスモデルのみです。 その結果コントローラー層は.exporttowebにあること以外機能実装について何も知らなくなります。

ブックマーク機能に適用すると下のようになります。 コントローラーで行う処理はJSONのエンコードとデコード、型変換、API用インターフェイスの呼び出しのみとなっています。 ブックマーク機能でのAPI用インターフェイスがBookmarkApiServiceです。

package com.cybozu.kintone.bookmark.api;

import com.cybozu.kintone.bookmark.exporttoweb.BookmarkApiService;

public class AddController {

    private final BookmarkMapper bookmarkMapper;
    private final BookmarkApiService bookmarkApiService;

    @RequestMapping({ "/bookmark/add.json" })
    public AddOutput run(@RequestBody AddInput input) {
        var req = new BookmarkWebApiService.AddRequest(input.getTitle(), parseUrl(input.getUrl()));
        var resp = bookmarkApiService.add(req);
        return bookmarkMapper.convertToAddOutput(resp);
    }
}

BookmarkApiServiceは下のように定義されています。 AddRequestAddResponseがそれぞれaddメソッドのリクエストモデルとレスポンスモデルです。 この例ではメソッドを全てインターフェイスに定義し、リクエストモデルやレスポンスモデルもインターフェイスの内部クラスとしていますが、この通りでないといけないわけではありません。 メソッド毎にインターフェイスを分けてもいいですし、リクエストモデルで通常の一つのクラスとしても問題ありません。 リクエストモデルのフィールド数が少ない場合は、クラスにせずに単なるメソッドの引数リストにすることもできます。

package com.cybozu.kintone.bookmark.exporttoweb;

public interface BookmarkApiService {
    record Bookmark(long id, Long appId, String title, String url) {
    }

    record AddRequest(String title, URL url) {
    }

    record AddResponse(Bookmark bookmark) {
    }

    AddResponse add(AddRequest request);

    record ListResponse(
        List<Bookmark> app,
        List<Bookmark> search,
        List<Bookmark> other) {
    }

    ListResponse list();
}

機能とwebとを分離したことにより、ブックマーク機能としてユーザーに提供する振る舞いがAPI用のインターフェイスに全て抽出2されていることが分かります。 実際、ユーザーがブックマークを追加する時はwebを経由してBookmarkApiService#addを実行し、 追加したブックマークを閲覧する時はBookmarkApiService#listの返り値を閲覧しているという構造になっています。 機能から分離されたコントローラー層は、ユーザーと機能 (BookmarkApiService) を繋げるのが役割になっています。

結合テストの導入

こうすることで、E2E-apiテストを減らして代わりにAPI用インターフェイスの結合テストが利用できるようになります。 なぜなら、機能として提供すべき振る舞いはAPI用インターフェイスに定義され、それ以外の機能に関する実装は内部実装として隠ぺいされているためです。 API用インターフェイスのテストでカバーできない部分はwebに関する処理、すなわちJSONのエンコードとデコードなどです。 このwebの部分には機能に関する処理がまったく含まれていないため、機能をテストしたい場合にwebの部分まで含んでいるE2E-apiテストを書く必要はなくなります。

ブックマーク機能では以下のようなテストを書いています。 このテストではブックマークを追加できるかどうかを確かめています。 ブックマークの追加処理はBookmarkApiService以外には存在しないので、BookmarkApiService以外をテストする必要はありません。

public class BookmarkWebApiServiceTest {

    private BookmarkApiService sut;

    @Test
    void testBookmarkApp() {
        long appId = addApp();

        sut.add(new BookmarkApiService.AddRequest(
            "title",
            new URL("http://example.cybozu.com/k/" + appId + "/")));

        BookmarkWebApiService.ListResponse response = sut.list();
        List<BookmarkWebApiService.Bookmark> bookmarks = response.apps();
        assertThat(bookmarks).hasSize(1);
        assertThat(bookmarks.get(0).url()).isEqualTo("http://example.cybozu.com/k/" + appId + "/");
        assertThat(bookmarks.get(0).title()).isEqualTo("title");
    }
}

このテストはaddAddRequestlistといったBookmarkApiServiceの公開インターフェイスにのみ依存したテストになっていて、BookmarkDtoやリポジトリなどの内部実装についての知識がまったく出てきません。 したがって内部の変更に対して頑健なテストになっていて、それでいてkintoneの機能部分のみをテスト対象に絞れています。

E2Eテストと違い、API用インターフェイスのテストはクラスのテストとなります。 DBについては本物のDBと結合していて、ユーザー管理などその他のコンポーネントはフェイクを利用しています。 これについては以前紹介したテストについての記事と同じ構成です。

blog.cybozu.io

導入と効果、課題

導入したところはまだ少ないですが、効果は実感しています。 アプリ設定のカテゴリ機能では数十ほどあったE2E-uiテストが、ハッピーパスを検証するだけの一件のみになりました。 残った一件以外のE2EテストはAPI用インターフェイスのテストとフロントエンドのテストに置き換わりました。

この仕組みは良くも悪くも既存のコードを大きく変えないので、特別な前準備や概念の再整理などなく分離作業にすぐ取り掛かることができましたし、大きな支障なく終えることができました。 実際の分離作業は、コントローラー層にある処理のうち機能に関するものをAPI用のインターフェイスの実装に移動するというものです。 リクエストモデルやレスポンスモデルの生成といった新規に追加するクラスはありますが、処理自体はそのままです。 別の機能で分離するにしても作業自体は大きく変わらない印象です。

今回のような内部設計の変更は、コードの分割が完了しているとやりやすいように思いました。 アプリ設定のカテゴリ機能もコードが分割済みです。 機能の範囲やコードの規模、影響範囲などが小さく閉じるので、素早く完了まで持っていくことができたと思います。 さらに、問題をできるだけ起こさず、段階的に学習しながら進められるようにも思います。 なお、コード分割については次の記事で紹介されています。

blog.cybozu.io

今後も同等の改善を続けることでE2Eテストを大きく削減できると考えています。 kintoneチームは今までもE2E-uiテストを削減したいと考えていて、E2E-apiテストとフロントエンドのテストを組み合わせるなど、できるだけスコープを絞ってテストを設計するように取り組んできました。 今回作成したAPI用インターフェイスのテストもそういった流れの中にある改善の一つです。 したがって、新しく書き方や考え方を学習することはあるかもしれませんが、今ままでの考え方の延長で進めていけるのではないかと思います。

機能実装とwebが分離されたことで、E2Eテストの削減以外にコードの可読性も改善されたように思います。 例えば既存のコードでは、更新系APIの実装において、更新処理の一部が散らばっていることがあります。 分離前のブックマークを追加するAPIではsetAppIdが更新処理の一部であり、コントローラーに書かれていました。 しかしこのような処理が常にコントローラーに書かれているとは限りません。 @RequestBodyに使うクラスとDTOとをマッピングするクラスが切り出された場合、その中で単なる値の詰め替えに混じってsetAppIdのような処理が書かれることもあります。 その結果、更新用の処理をコントローラー層のどこでやっているのか、もしくはサービスクラスでやっているのか、コードを追わないと分からなくなってしまっています。 加えて、更新用の正解のDTOがどういうものなのかということも分かりづらくなっています。

分離後は散らばる範囲が内部実装に抑えられたので、分離前と比べると読みやすくなったように思います。 更新用のDTOを作る処理も機能に関する処理の一部となりwebと分離されたので、コードを読む際にwebに関する処理が混じることなくDTOの組み立てにのみに集中できるようになりました。

課題としては、似たようなクラスが増えてしまうことが挙げられます。 ブックマーク機能でAddRequestAddResponseが定義されましたが、これはAddControllerAddInputAddOutputとほぼ同じ構成です。 このように、似たような見た目のクラスが二つできてしまっています。 クラスを省略できないか試してみたものの、完全になくすのは難しそうでした。 そもそもSpring自体も@RequestBodyで名前付きのクラスを引数に取る、JSONのデコードはこの名前付きクラスで実現する、という仕組みになっているので、クラスが増えてしまうのは避けられないのかなという印象です。

今後の展望

今後の展望として、webと分離した機能部分の実装を整理したいと考えています。 先ほど述べた更新用の処理が散らばってしまうという問題は、そのような処理が内部実装として隠ぺいされることである程度解消されました。 しかし、内部実装の中では依然として散らばってしまっていますし、正解のDTOの状態がどういうものかということも分かりづらいままです。 これはDTOのようなデータクラスを中心として処理を組んできたkintoneの長年の問題です。 この問題を解決するには、機能の正しい状態のみをクラスで表現するなど、状態や情報の整合性、一貫性を担保しやすくする仕組みが必要です。

内部実装の整理にはクリーンアーキテクチャがヒントになるのではないかと考えています。 今回E2Eテストを削減するために作った構造はクリーンアーキテクチャの構造と似ています。 クリーンアーキテクチャではControllerとUse caseの間にBoundaryが挟まって依存を制限していますが、今回作成したAPI用インターフェイスはこのBoundaryと似たような役割を果たしそうに思います。 つまり、今回の内部設計の変更は、クリーンアーキテクチャにおけるControllerとBoundaryの導入に相当していると考えられます。 したがってクリーンアーキテクチャを参考に内部実装の中にUse caseやEntityを見出していけば、自然と整理されるのではないかと考えています。

このような内部実装を大きく変えるような変更をしたとしても、それは内部実装の変更なので、API用のインターフェイスは変わりません。 したがって、E2Eテストから置き換えて作った結合テストが活躍してくれるはずだと期待しています。

まとめ

機能に関わる処理をwebと分離することによるE2Eテストの削減について紹介しました。 膨大な量のE2Eテストがkintoneの開発を非効率にしていました。 そこで、内部設計を見直し、機能実装をwebから分離することで機能実装だけを対象にテストを行う仕組みを導入しました。 アプリ設定のカテゴリ設定機能などで試してみたところ、実際にE2Eテストが削減されました。 また、この仕組みによりコードの可読性も改善できそうなことも分かってきています。 この仕組みはまだ導入を始めたばかりです。今後もE2Eテストの大幅な削減を目指して続けていきたいと思います。


  1. コントローラー層が.exporttoweb以外に依存していないかの検査はコード分割の時と同様にArchUnitを利用しました。
  2. このことは、API用のインターフェイスがkintoneの機能を定義する抽象データ型になってることを意味するように思います。