大規模リファクタリングを加速するためにAPIスナップショットテストを作りました

コミューン株式会社の土佐(@whale9490)です。

私たちは現在、溜まった技術負債を一掃するため、大規模リファクタリングプロジェクトを進行しています。

大規模リファクタリングを安全かつ高速に行うには、できるだけ網羅的な自動テストがあることが望まれます。私たちのバックエンドコードは、E2Eテスト・APIテスト・ユニットテストなど様々なレイヤのテストを持ってはいましたが、それらいずれも網羅的ではありませんでした。

そこで、APIテストを網羅的に書こう、ということに決まったのですが、すべてのAPI(数百エンドポイントあります)に対して手でテストを書いていると、私たちの望む短期間では到底終わらないことがすぐに分かりました。

この問題を解決するため、APIスナップショットテストを作ることにしました。

目指した着地点

  • 手書きのコスト削減が目的であるため、可能な限り自動で作成する。
  • リファクタリングへの安全性を担保するのに十分なテストができれば良い。エッジケースを網羅したハイクオリティなテストは求めない。
  • スナップショットテストは一時的なものであり、各APIに変更を加える際に、順次手書きテストで置き換えていく。

方針

以下の方針で進めました。

  • Jestのスナップショットテスト機能を使う。
  • テストデータは、データジェネレータにより自動生成して全テストで共通のものを使う。
  • テストのインプットはHTTPリクエストとし、実際のリクエストのパターンを基に自動生成する。
  • テストのアウトプットはHTTPレスポンス・DBデータ差分・外部依存モックへのアウトプットとする。
  • テストコードは、ts-morphで自動生成する。

それぞれ、説明します。

Jestのスナップショットテスト機能を使う

Jestはスナップショットテスト機能を持っています。

スナップショットテストといえば、Reactコンポーネントなど、フロントエンドで用いるのが一般的ですが、実は任意のシリアライズ可能な値を扱うことができます

既にJestを広く用いていたこともあり(私たちのバックエンドはTypeScriptで書かれています)、この方法を採用することにしました。

テストデータは、データジェネレータにより自動生成して全テストで共通のものを使う

テストデータをどのように作るかが最初の課題でした。

実在するデータをマスクしたものを使う方法は、データ量があまりに膨大になること、マスクがあまりに大変なことから、現実的ではありません。

そこで、次のようなデータジェネレータを作成することにしました。

  • Faker.jsを使う。
  • ランダムだが常に一意の値を生成する。
  • 完全にランダムにデータを作っても実用的なデータにならないため、プロダクトのデータ構造や値の特徴を反映した構造を作成し、そこからデータ生成する。
  • 今後の運用の中でデータ構造(テーブル定義)に変更があったとき、生成されるデータは大幅には変化せず、関係のある部分だけ変化する。

さて、「ランダムだが常に一意の値を生成する」のはそれほど難しくありません。Faker.jsにシード値を与えると、常に同じデータを生成してくれます。ただ、最初に一度シード値を与えるだけでは、「今後の運用の中でデータ構造(テーブル定義)に変更があったとき、生成されるデータは大幅には変化せず、関係のある部分だけ変化する。」という条件を満たすことができません。生成するデータがどこか1件増えただけで、以降に生成するデータがすべて変わってしまうことになります。だから、copycatがやっているように、1回のランダム生成ごとにキーを渡し、同一のキーに対しては同一のデータが生成される、という方式にすることが必要でした。

解決方法はシンプルで、毎回の生成ごとにFaker.jsにシード値を渡すという、多少強引な方法で実現することができました。

作成したデータジェネレータの一部は、こんな感じです👇️

export class ExampleEntity {
  constructor(private ctx: DevDataGeneratorContext) {
    this.id = ctx.getNextIdAndIncrement('exampleEntity')
    this.dg = new DataGenerator(this.ctx, `exampleEntity:${this.id}`)
  }

  private readonly dg: DataGenerator

  readonly id: number

  toData(): Prisma.ExampleEntitiesCreateManyInput {
    const dg = this.dg

    const user = dg.user('user')
    const at = dg.datetime('at')

    return {
      id: this.id,
      userId: user.id,
      tenantId: user.tenantId,
      url: dg.url('url'),
      body: dg.sentence('body', { wordCount: 10 }),
      createdAt: at,
      updatedAt: at,
    }
  }
}

テストのインプットはHTTPリクエストとし、実際のリクエストのパターンを基に自動生成する

実際のリクエストログから、各APIに対するリクエストのパターンを割り出し、JSONファイルに落とし込む仕組みを、Python + Jupyter Notebookで作成しました。(データをいじる系はやっぱりpandasが楽ですね。)

生成されるJSONのイメージ👇️

{
  "path": "/example/{exampleId}",
  "method": "post",
  "sampling": {
    "n": 50,
    "minDatetime": "2024-09-15T00:00:00.000000+00:00",
    "maxDatetime": "2024-09-16T00:00:00.000000+00:00"
  },
  "requestPatterns": [
    {
      "pathParams": {
        "exampleId": "key:exampleId"
      },
      "queryParams": {},
      "reqBody": {
        "field1": "key-number:someId",
        "field2": "value-object:url",
        "field3": "primitive:string:100,200"
      }
    },
    ...
  ]
}

後述の「テストコードは、ts-morphで自動生成する」で、これらJSONから、実際のテストコードのインプット部分を生成します。

テストのアウトプットはHTTPレスポンス・DBデータ差分・外部依存モックへのアウトプットとする

以下の3点を、アウトプット(スナップショットとして差分比較に用いる対象)としました。

  • HTTPレスポンス
    • ステータスコードとレスポンスボディ
  • DBデータ差分
    • テスト前後のDBデータの差分
  • 外部依存モックへのアウトプット
    • Elasticsearchなどの外部依存をモック化したクラスへのメソッド呼び出し

中でも、DBデータ差分を取るのが難しかったです。

・・・話は逸れますが、テストケースごとにデータをロールバックする仕組みを作るのも大変で、その部分は磯村さんに作成いただきました。簡単に言うと、テスト中に流されたSQLから、変更のあったテーブルを割り出して、それらテーブルのみtruncateし、元データをinsertし直す仕組みです。

この「元データ」は各Jestワーカープロセスのメモリ内に保持する形なのですが、話を戻すと、このインメモリの元データがあったお陰で、DBデータ差分を取ることができました。つまり、変更のあったテーブルのみ全件selectして、元データと比較するという方法を採りました。

テストコードは、ts-morphで自動生成する

テストコードは、ts-morphで生成しました。昔、TypeScriptのコンパイラAPIを直接使って自動生成の仕組みを作ったことがあったのですが、ts-morphを使うと圧倒的に楽ですね。

生成されるテストコードは、以下のようなイメージです👇️

// @generated -- This file may be overwritten by the generator, only if this line is present at the first line. If you modify this file by hand, simply delete this line.

// imports...

const app = expressAppForApiTest(
  new ExampleController(...)
)

const fxm = new ApiTestFixtureManager()

test.each([
  {
    case: '0.0',
    requester: 4,
    pathParams: {},
    queryParams: {
      exampleId: 1,
    },
    reqBody: {
      targetId: 32,
      targetType: 'A',
    },
  },
  {
    case: '0.1',
    requester: 17,
    pathParams: {},
    queryParams: {
      exampleId: 1,
    },
    reqBody: {
      targetId: 17,
      targetType: 'A',
    },
  },
  ...
])('#$case', async ({ requester, queryParams, reqBody }) => {
  const res = await request(app)
    .post('/example')
    .query(queryParams)
    .auth(...(await fxm.auth(requester)))
    .send(reqBody)
  expect(res.status).toMatchSnapshot('res-status')
  expect(res.body).toMatchSnapshot('res-body')
  expect(await diffOfTables(sqlExecutionRecorder.mutatedTables())).toMatchSnapshot('data')
  expect(mergedMockSnapshot({})).toMatchSnapshot('mock')
})

結果

全APIの2/3程度をカバーする、スナップショットテストを作成することができました。

まだ運用開始したばかりで、どの程度役立つかは不明瞭ではありますが、現時点でもリファクタリング・機能開発に役立っているという声を複数もらっています。

たぶん、改善の余地はいくらでもあると思いますが、それはそのときに・・・。

ともあれ、これで、リファクタリングが加速することを願っています!

 

コミューンでは、私たちと一緒に働く仲間を募集しています!少しでもコミューンの開発組織や職場環境に興味をお持ちの方、ぜひカジュアル面談でお話ししましょう。

docs.google.com

また、具体的な応募意思がない場合でも、将来的にCommuneでのキャリアを検討している方や、選択肢の一つとしてお考えいただける方は、「キャリア登録」にご登録ください。

docs.google.com