🐈

メディカルフォースにおけるE2Eテスト自動化の取り組みと工夫

2024/05/17に公開

はじめに

メディカルフォースではバックエンドにおいては多くの機能にテストが書かれているものの、フロントエンドやアプリケーション全体のテストは手動でのテストに頼っている状態です。
その問題を解決するために現在E2Eテストの実装に力を入れようとしています。
まだ取り組みを始めて日が浅いため手探りなところはありますが、E2Eテストの実装にあたって工夫していることなどを紹介したいと思います。

なぜE2Eテストを書くか

いきなり本筋とは少しズレますが、フロントエンドに閉じたテストよりE2Eテストに力を入れようとしているかについてこれまでの取り組みを踏まえつつ書いていきます。

これまでのフロントエンドテストの取り組み

これまでの取り組みとして、Jest/Storybook/React Testing Libraryを用いたコンポーネントの単体テストや、Storybook/storycap/reg-suitを用いたVRT(ビジュアルリグレッションテスト)の仕組みを整備し、それぞれのテストがCIで実行されるようにしました。
バグやリグレッションを早い段階で発見することを目的としてこれらの仕組みを作りましたが、結果としてはチーム内であまり浸透することはなく自分でもあまり書くことはありませんでした。

原因

モックを書かないといけない

フロントエンドに閉じたテストなのでAPIからデータを取得するコンポーネントではAPIのモックが必要になり、mswを使用してAPIをモックしていました。
書く際の負荷を減らすためにAPIエンドポイントの型定義と組み合わせて以下のようにAPIモックを定義してmswに登録できるユーティリティも作成しましたが、モックを使う以上実際のAPIからは想定と違うデータが返されることもあるという点でリターンが小さくなってしまうという側面は依然としてありました。

registerResolver(
    'users._userId.$get', // GET /users/{userId} に対応
    (req, res, ctx) => res(ctx.status(200), userFactory.build())
)
余談: factoryについて

factoryの定義も面倒なので以下のようなユーティリティを作成しました。
機能としては

  • zod-likeなAPIで生成されるデータが型と整合しているかチェックされる、
  • faker.jsと組み合わせられていてランダムだがそれっぽいデータを生成できる
  • VRTでも使いたかったためテストの実行毎に生成されるデータが変わらない

といったものがあります。

type User = {
    id: string
    created_at: string
    first_name: string | null
    last_name: string | null
    gender: 'MALE' | 'FEMALE' | 'OTHER'
    tags: Tag[]
}

const userFactory = factory<User>('User', {
    id: string('string.uuid'),
    created_at: string('date.past'),
    first_name: nullable(string('person.firstName'), 0.1),
    last_name: nullable(string('person.lastName'), 0.1),
    gender: union('MALE', 'FEMALE', 'OTHER'),
    tags: array(tagFactory),
})

これを実装するための型パズルは楽しかったです

テストで出力を保証するのが難しい

UIコンポーネントにおける出力は単純な文字列やDOMではなく見た目であったり操作に対する反応といったものがありますが、これらを表面的ではなく本質的に保証するのが難しかったです。
例えば「◯◯」という文字が表示されることを保証するのは難しくないですが、意図通りのデザインになっているかや操作に対して見た目がどう変化するかを保証するのが難しいです。
見た目については通常のテストでは保証しにくくVRTを使うことになりますがそういったテストを網羅的に書くのはコストがかかり、実際に触って確認した方が速くアニメーションなども含め多くのことを確認できるという側面がありました。
こういった点はバックエンドのテストが入力に対して直接的な出力や副作用(データ永続化やメール配信など)を確認すれば十分なのと対象的でフロントエンドのテストの難しい点だと感じました。

その他

その他にも以下の要因があったと思います

  • storyファイルとtestファイルをそれぞれ作成するのが手間
  • storybookにはinteractionsという機能があるがこれと組み合わせた単体テストやVRTがあまり安定しなかった
  • コンポーネントがテスト容易性を意識した設計になっていない
  • 基本的にUIコンポーネントはライブラリから提供されるコンポーネントやそれを少しラップしたコンポーネントが多いのでそれ自体がデグレることが少ない

まとめ

うまくいかなかった原因としては主には以上の要因でフロントエンドでテストを書く費用対効果が低かったことかなと感じました。
こうしてフロントエンドテストも重要だと思いつつ、もっと費用対効果の高いところへ時間をかけたいという気持ちが強くなってしまいました。

よかったこと

よくなかったことばかりではなく、よかった点も挙げておきます。
まず、元々テストが無かったところにテストを書きたくなったら書けるという状態にできたのはよかったところで、純粋な関数に対しては積極的にテストを書いてます。
他にもAPIモックの仕組みを作ったことにより、バックエンドが実装されていない状態でもフロントエンドの実装を進めやすくなったのはよかったです。

方針の転換

そんな中、リグレッションテストに時間がかかってしまうことによる問題が顕在化してきてQAの側面でE2Eテストを自動化するニーズが強くなり、E2Eテストの導入が始まりました。
そしてE2EテストツールにはAutify, Mablなども検討したがPlaywrightを使用することが決まり、導入が始まり自分も関わるようになりました。

まとめ

以上の点を踏まえて総合的にE2Eテストに力を入れていくのがいいと考え、E2Eテストに力を入れていくことにしました。

E2Eテストの実践

前提

フロントエンドの実装にはReactとNext.jsを使っており、サポートしているブラウザはChromeとiPad Chrome(実質はsafari)です。

工夫した点

データの管理

E2Eテストの実装にあたって一つ大きな課題がテストの前提としているデータをどう管理するかでした。
データが特定の状態であることを前提にすると、テストが途中で失敗したときのハンドリングが難しかったり別の環境で動かすのが大変になってしまいます。
逆にテストの中で毎回作成するようにするとテストを実行するたびにデータが際限なく増えていき、アプリケーションの想定よりも多くのデータが存在する状態になってしまうので、テストのクリーンアップで毎回削除するなどの対応が必要ですが普通に実装するとこれが手間になり書き忘れも発生してしまいます。
またデータを毎回作成するとなったときにUIを操作して行うか、APIを直接叩いて行うかという問題もありました。

対応としては、まずデータは毎回作成して削除することにしました。テストの安定性を重視して保守性を上げたかったたためです。
また、データの作成の方法としてはAPIを直接叩いて行うことにしました。UIを操作するのは実装コストや実行コストが高くなってしまうためです。
作成したデータを少ない手間で削除する方法については、当初あまりいい案が思いつかなかったですがあるときいい方法があることに気づきました。それがTypescript5.2で導入されたusingキーワードです。using自体の説明は割愛しますが以下のようなユーティリティを作っておくことでテストではusingを使って変数を宣言するだけで終了時にデータが削除されるようになります。

const createUser = async (data) => {
    const { id: userId } = await api.users.post(data)
    return {
        async [Symbol.asyncDispose]() {
            await api.users.userId(userId).delete()
        }
    }
}

test("usingの例", async () => {
    await using user = await createUser({ name: 'hoge' })

    // スコープを抜けるときに削除処理が実行される。
})

こうすることで低いコストでデータのクリーンアップまでできるようになりました。
ただチェックがないとusingではなくconstで宣言してしまうことあると思うので、usingが漏れていないかチェックするeslint ruleも作成しました(型情報が必要なので少し大変でした)。

APIクライアント

APIを叩くに当たってやはり型つきのAPIクライアントが欲しいです。
これについてはバックエンドに元々openapiスキーマを出力する機能があったためopenapi-typescriptを使って型を生成し、それをもとにAPIクライアントを作成しました。
Playwrightのfixtures機能を用いて各テストで以下のように認証付きのAPIクライアントを取得できるようになっています

test("client", async ({ getClient }) => {
    const client = await getClient()
    await client['POST /staffs']({
        // 型がつく
        body: { name: 'hoge' }
    })
})

要素を特定しやすいようにtest idが自動で付与されるようにした

本来はariaロールなどを活用してセマンティックな方法で要素を特定するのが望ましいですが、今まであまりテスタビリティやアクセシビリティを意識した開発をしてこなかったこともあり、UI要素に対応したDOM要素を取得するのが困難でした。
とはいえ、今あるコンポーネント1つ1つを改修していくのはかなり骨が折れる作業になってしまいます。
そこで、Next.jsが使用しているトランスパイラであるSWCのプラグイン機能を利用してコンポーネント名がtest idとして設定されるようなプラグインをRustで書きました。色々なパターンのコンポーネントがあるので完全ではありませんが、多くのUIコンポーネントに対してtest idが設定されるようになりテストが書きやすくなりました。

タグを使ってテストの分類

Playwrightではテストケース名に@で始まる文字列を含めることでタグをつけることができます。これとplaywrightのproject設定のgrepやgrepIgnoreを用いて基本的にはテストはデスクトップサイズのchromeでのみ実行するが、@tabletというタグがついてたらiPadに近い環境でも実行するといった制御をしています。

PlaywrightによるE2Eテストの実装を始めて感じた強み

バックエンドも含めてテストができる

E2Eなので当然ですが、バックエンドも含めたシステム全体を対象としてテストができます。

クロスブラウザでテストできる

フロントエンドテストではjsdomというnode.js上で動くDOMのモックのようなものが主に使われますが、PlaywrightはChromeとWebkitに対応していて実際の使われるブラウザにより近い環境で動作確認できるのは強みだと感じました

API設計が優れていて書きやすい

Playwrightの話にはなってしまいますが、Auto-waitingという仕組みがあるおかげで書きやすかったです。
例えば、React Testing Libraryを使っている場合はボタンをクリックする操作を以下のように書きますが、このscreen.getByRole('button')を呼んだ段階で読込中などでまだボタンが表示されていなかった場合は失敗してしまいます。

await userEvent.click(screen.getByRole('button'))

一方playwrightの場合以下のように書くとボタンが押せるようになるまでtimeoutの時間だけ自動的に待ってくれます。

await page.getByRole('button').click()

PlaywrightのVRTが使いやすい

Playwrightの場合、VRTの撮影を直前の撮影結果と変わらなくなるまで何回か繰り返し行うという機能があり、安定しやすいです。
また、maskという機能を使うことでテストの対象にしたくない部分を除外することができます。

書き方のポイント

codegen機能の活用

Playwrightにはcodegenという機能が用意されていて実行するとブラウザが立ち上がり、そのブラウザを操作することで自動的にテストが作成されます。
ですが、そのままでは毎回ログインが必要になってしまうため少しラップしてログインした状態で立ち上がるようにしたスクリプトを作成し、npm run codegenで実行されるようにしました。

page object modelの活用

画面ごとにpage object modelを定義することでその画面内で特定の要素を取得したり操作を行ったりする処理を共通化してテストの実装コストを減らすことができます

APIレスポンスの置き換え

ここまでで意図通りのデータがある状態を安定して作り出せるようにしてきましたが、特にVRTにおいてはテスト実行時データが過去と同等であることに関してシビアになることがあります。
そういったときにPlaywrightの機能を利用してAPIレスポンスを置き換えをすることで(少し冗長にはなってしまいますが)対策ができます。

// 一覧取得時のレスポンスを特定の1件に絞る例
await page.route(/\/rooms(?!\/)/, async (route) => {
  await using res = await route.fetch();
  const body = await res.json();
  await route.fulfill({
    json: {
      items: body.items.filter(
        (i: any) => i.id === room.id,
      ),
    },
  });
});

補足

さて、一般的によく見るテストピラミッドでは単体テスト、統合テスト、E2Eテストの順で多くすべきとされていると思います。
この点について僕の考えを述べておくと、

  • E2Eテストが一番ブラックボックス的なので実装のテスタビリティの高低にあまり依存せずかけるため、テストがないプロジェクトに導入しやすい(逆に現状で統合テストを導入しても実装の変更に弱くなってしまう)
  • バックエンドのコアの部分に対しては多くの単体テストや統合テストが書かれているため、フロントエンドに閉じたテストが少なかったとしてもシステム全体としてはバランスは悪くない

といった点で少なくとも現時点ではE2Eテストに比重を置くのがいいと考えています。

今後の課題

コンポーネントの変更で壊れやすい

現状だとコンポーネント名を使ったtest idに依存しているためコンポーネントの構造の変化だったり、コンポーネント名の変更によって壊れてしまいます。
対策としては、より実装時にセマンティックなHTMLを書くようにしていきたいと思っています。

CIにおけるテストの実行時間が長い

CI環境ではテスト実行を並列化していないため実行に長く時間がかかってしまいます。
これについてはsharding機能を利用するした並列化を考えています。

さいごに

長くなりましたが、お読みいただきありがとうございました。
弊社におけるE2Eテストの事例を紹介してきましたが、もし参考になるところがあれば幸いです。
テストケースはまだまだ充実していないので今後もっと充実させて品質保証していけたらいいなと思っております。

Discussion