🧪

スタートアップなのにフロントエンドのテストカバレッジが90%を超えている話 | Resilire Tech Blog

2024/07/11に公開

はじめに

サプライチェーンリスク管理クラウドサービスResilireでエンジニアをしている奥村@arashigaoka3 です。
Resilireでは、1歳の子の育児に悪戦苦闘しながら、フロントエンド、サーバサイドを問わずプロダクトエンジニアをやっています。

ResilireはシリーズAを迎えたばかりのアーリースタートアップでありながら、フロントエンドのテストカバレッジが90%を超えており、必要なケースについてはほぼテストが網羅されています。
CodeCovにて、フロントエンドが90%以上のカバレッジとなっている画像
私は今年の4月に入社したばかりですが、すでにテストカバレッジの高さに助けられ、不具合の混入をせずに済んだことが何度もあります。
今日は、Resilireのフロントエンドのテスト戦略とカバレッジの高さの理由についてお伝えしたいと思います。

スタートアップとテスト

冒頭にもお伝えした通りResilireはアーリースタートアップです。エンジニアリングに求められる内容について、より大きく成熟した企業と比較した場合に以下のような特性があります。

  • まだまだ成長途中のプロダクトのため、成長に合わせて柔軟に機能のエンハンス・作り直しを行う必要がある
  • リソースが限られているため、一人一人がこなす役割が多くなりがちである

その一方でResilireはサプライチェーンのデータを扱うSaaSであり、お客様はいわゆるエンタープライズな企業であることも多く一定以上の高い品質が求められます。

まとめると、機能開発に伴うリグレッションの発生はできるだけ防ぎたいが作成コストやメンテナンスコストは極力抑えたい、というのがResilireに求められるテストと考えています。

Integration Testに軸足を置いたテスト戦略

以前の記事でも記載しましたが、Resilireでは技術的な意思決定はADRに残す文化があります。ご多分に漏れずフロントエンドのテスト戦略についてもADRが残されているため、ここでかいつまんでご紹介します。

Testing Trophyに従い、アプリケーション利用者目線によるテストを優先する。
…(中略)…
実際に行うテストの内訳と優先度は下記の通りとする。

  • E2E Test(優先度低)
    • 利用者目線で特にクリティカルな機能導線について記述する
  • Integration Test(優先度高)
    • 機能付きコンポーネントに対して、正常系のテストケースを必ず記述する
    • モックは極力APIレスポンスだけ行う。Componentが利用するパッケージやモジュールはモックしない
  • Visual Regression Test(優先度低)
    • 現時点ではデザインの正規化を行う前なので実施しない
  • Unit Test(優先度低)
    • 複雑なフロントエンド独自のビジネスロジックが発生した場合に記述する
  • Static Test(優先度高)
    • tscによる型チェック、ESLintを実施する

-- 007-frontend-testing-policy.mdから一部抜粋

このように、Testing Trophyをベースにテスト戦略が決定されており、ResilireではIntegration Testを中心としたテストが実装されています。なかでも正常系のケースについては必ず実装するルールとなっており、低いコストで高いカバレッジが実現されています。

もちろん単体テストが必要な場面では単体テストも実装されており、特にutil関数については基本的にUnit Testが実装されています。
実装されたテストはPull Requestの際にCIにて実行され、テストに合格した場合のみマージできます。

テスト文化とカバレッジを保てた理由

テストカバレッジを上げるために最も必要なのは、テストを書く文化を育てる部分だと思います。特にスタートアップにおいては、スケジュールが非常にタイトなことも多く、テストが後回しになるケースも多いです。
Resilireが高いカバレッジを保つことができた理由について振り返ると、

  • 初期段階からCodeCovを設定しカバレッジの変更閾値を設けることで、テストを無視した実装を抑制していたこと
  • 十分なテストが書かれた状態がキープされていて、新規参画メンバーも暗黙のプレッシャーを感じながら実装できたこと
  • 次に述べるような工夫により、テストの実装コストを必要最小限に抑えたこと

が大きかったように思います。 次に、実装コストを必要最小限に抑える点についてご説明します。

テストの実装コストを下げる工夫

Integration Testを書く上でコストがかかる部分の1つが、APIのMockを実装するところかなと思います。これについてResilireでは、APIの定義から自動生成されたfactory関数を活用しています。

こちらについてもADRが残されているので、一部抜粋してご紹介します。

以下の方針に伴ってOrvalのMockと自作Mock factoryを使い分ける。

OrvalのMock

  • 特段レスポンスの値にこだわりがない場合、OrvalのMockをそのまま利用する
    • 主にStorybookなど、UIの確認を目的とする場合など
      …(中略)…

自作Mock factory

  • レスポンスの値にこだわりがある場合、自作Mock factoryを利用する
    • テストコードを書く際に、特定の値を持つMockを生成したい場合など
  • OpenAPI Schemaのモデルに対して、型付のMock factoryを書く

-- 011-mock-factory.mdから一部抜粋

このようにfactory関数を活用することで、テストしたいスコープに焦点を当てたデータの用意が簡単にできるようになっています。

上記のほかにも、テンプレート的に処理できる部分についてはtest-util関数として提供することで、実装コストを最小限に抑えられています。文字で書いても伝わりにくいかと思いますので、実際のテストコードをお見せしたいと思います。

具体的なコードの紹介

ここでは一例として会社詳細画面のテストの一部を、サンプルとしてコメント付きでご紹介します。ポイントとしては下記のような点を見ていただけると良いと思います。

  • factory関数でモックの記述管理コストを減らしている
  • 頻出する処理はtest-utilとして提供されている
  • テストコードの記述自体もコードベース内である程度パターン化されている

テストコードの例

Resilireのフロントエンドでテストコードに利用している技術スタックにはvitest、testing-library、mswなどが含まれます。

describe('CompanyInfo', () => {
  // mockの定義
  beforeEach(() => {
    server.use(
      rest.get('api-path', (req, res, ctx) => {
        return res(
          ctx.status(200),
          ctx.json(
            // factory関数で、テストしたいフィールドだけを上書きする。他は適当な値が入る。
            aCompany({
              company_name: 'レジリア株式会社',
            })
          )
        )
      })
    )
  })
  // test内で使用する関数の定義
  const renderCompanyInfo = () => {
    // renderWithAppはtest-util関数で、Providerやmockなど処理する
    const utils = renderWithApp(
      <Routes>
        <Route
          path="画面のPath"
          element={<CompanyInfo />}
        />
      </Routes>,
      undefined,
      `画面のPath`
    )
    // 各テストで呼び出す関数を定義
    const waitForLoading = () =>
      waitForElementToBeRemoved(screen.queryAllByRole('presentation'), {
        timeout: 5000,
      })
    const getEditModalElement = () =>
      screen.findByRole('dialog', {
        name: '会社を編集',
      })
    const openEditModal = () =>
      utils.user.click(screen.getByRole('button', { name: '編集' }))
    return {
      openEditModal,
      getEditModalElement,
      waitForLoading,
    }
  }
  it('ユーザーは基本情報の変更に進むことができること', async () => {
    const { openEditModal, getEditModalElement, waitForLoading } =
      renderCompanyInfo()

    await waitForLoading()
    await openEditModal()

    const modalElement = await getEditModalElement()

    expect(modalElement).toBeInTheDocument()
  })
})

Resilireのフロントエンドでは、上記のようなフォーマットでテストを書くことで、必要最小限のコストでテストを作成、メンテナンスできています。

今後の展望

その一方で当然ながら残された課題もあり、今後検討していく必要があると思っています。
具体的には、

  • サプライチェーン構造をツリー方式で可視化する画面でcanvasを使った描画をしているが、このテスト品質をどのように高めていくか
  • 今後デザインの改善を予定しているなか、将来的にVisual Regression Testなどを導入すべきか
  • E2Eテストの自動化にどの程度取り組むべきか

などが課題となっています。
引き続きチームで課題を議論しながら改善していき、スタートアップとしてベストなテスト環境を目指していきたいと思っています。

まとめ

株式会社 Resilire (レジリア) では、サプライチェーンリスク管理クラウドサービスResilireの開発メンバーを募集中です。

https://recruit.resilire.jp/for-engineers

一緒にスピード感と品質の両立したフロントエンド開発を実現したい方をお待ちしております!

https://youtrust.jp/recruitment_posts/a7be7a12e041c05b0eb65c67376d18f6

Discussion