🧪

Cloudflare Workersのテストの書き方

2024/07/21に公開

はじめに

本稿は、筆者が実際に業務で採用しているCloudflare Workersのテストの書き方を紹介するものです。第一に想定する読者は、「Cloudflare Workersの利用を検討しているが、テストについてはまだ調査が進んでいない状態の方」となります。同時に、筆者としてもまだ手探り状態ではあるので、すでにテストを書いて運用している方からの感想・フィードバックも歓迎しています。
また、終盤にHono RPCについて触れるので、もし不案内な方がいらっしゃれば作者の方による紹介記事を先にご覧いただくとよいと思います。[1]

ユニットテスト

ここで厳密な定義問題に関わるのは本意ではないため、さしあたり「DBアクセスやinbound/outboundのHTTPリクエスト」などがモックされたテストだと考えてもらえると幸いです。

このケースでは、Cloudflareが公式で提供している@cloudflare/vitest-pool-workersをそのまま使えばよいです。Vitestのテスト実行環境をworkerd[2]に置き換えるもので、これによって本番に近い環境でのテストが可能になります。

テストコード自体の変更は全く必要なく、vitest.config.tsを以下のように変更するだけです。

import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";

export default defineWorkersConfig({
  test: {
    poolOptions: {
      workers: {
        wrangler: {
          configPath: "./wrangler.toml"
        },
      },
    },
  },
});
執筆時点での制約

2024/07/21時点において、@cloudflare/vitest-pool-workersはvitest > 1.6.0では動作しません。vitest-pool-workersはvitestの一部もworkerdで動かしているのですが、vitest@1.6からworkerdが実装していないnode:fsに依存するコードがそこに追加されたため動かなくなってしまいました。vitest@2をサポートするPRがドラフトで立っているので、これがマージされれば動くと思われます。

インテグレーションテスト

ユニットテストと同様にこの場だけの定義を導入すると、「何もモックせずにHTTPをしゃべるテスト」を指すことにします。アプリケーションが/fooというエンドポイントを提供してたら、実際に/fooにHTTPリクエストを送り、そのレスポンスを検証するテストです。

@cloudflare/vitest-pool-workersはインテグレーションテストもサポートしていますが、筆者は別のアプローチを取っています。まずvitest-pool-workersでの書き方を簡単に説明したのち、筆者が採用している方法を紹介します。

vitest-pool-workersでの書き方と問題点

vitest-pool-workersでテストを実行すると、cloudflare:testというモジュールにアクセスできます。このモジュールがexportしているSELFというメンバーは、wrangler.tomlで指定されたentrypointをbindしています。すると、このようにHTTPリクエストを送ることができます。

import { SELF } from "cloudflare:test";

it("dispatches fetch event", async () => {
  const response = await SELF.fetch("https://example.com");
  expect(await response.text()).toMatchInlineSnapshot(...);
});

SELFを使ったテストは非常にシンプルではあるのですが、DBマイグレーションの扱いがネックになりました。テストの実行前にDBのマイグレーションを走らせたいのですが、その実現方法として浮かんだのは次の2つでした。

  1. vitestの起動前にDockerコマンドでDBを立ち上げてマイグレーション
  2. vitestの起動後にtestcontainersでDBを立ち上げてマイグレーション

1のパターンは以前にもやったことがあるのですが、シェルをごにょごにょしなければならずあまり好みではありませんでした。一方、2の場合はシングルコマンドで済む上に、マイグレーションとの相性もよさそうでした。というのも、ORMにdrizzleを使っており、drizzleのマイグレーションはプログラミングAPIも提供されているので、testcontainersでDBを起動したあとにちょろっとコードを書けばマイグレーションが可能であることが期待できたからです。こうした理由から2の方で進むことにしました。

testcontainersとの繋ぎ込み

テスト全体のsetup時にDBが立ち上がり、終了時に落ちると嬉しいです。そのためにはVitestのenvironmentを使うとよさそうで、実際にサードパーティ製のvitest-environment-testcontainersも存在しました。ところが、vitest-pool-workersはenvironmentの変更を許可していないことがわかりました。

となると、vitest-pool-workersに固執するならば1ということになりますが、前述の2のメリットを享受したいので、2でなんとかする方法を模索することにしました。

unstable_dev APIを使う

workerd runtimeを使う方法はvitest-pool-workersだけではありません。Cloudflare WorkersはNode.js向けにunstable_dev APIも提供しており、これを使うことでworkerdの上でHTTPサーバーを起動することができます。

DB起動と同様に、サーバーもすべてのテスト実行前に一度だけ立ち上がって欲しいので、environmentを使うことにしました。testcontainersのenvironmentも使いたいので、自分でカスタムenvironmentを定義しました。以下はその抜粋です。

import dotenv from 'dotenv'
import { drizzle } from 'drizzle-orm/postgres-js'
import { migrate } from 'drizzle-orm/postgres-js/migrator'
import { hc } from 'hono/client'
import postgres from 'postgres'
import { type Environment, type EnvironmentReturn } from 'vitest'
import { type EnvironmentOptions, default as testContainersEnv } from 'vitest-environment-testcontainers'
import { unstable_dev } from 'wrangler'
import type { AppType } from '~/type.js'

const POSTGRES_USER = 'postgres',
  POSTGRES_PASSWORD = 'postgres',
  POSTGRES_DB = 'postgres',
  POSTGRES_PORT = 5432

const environmentOptions: EnvironmentOptions = {
  testcontainers: {
    containers: [
      {
        name: 'database',
        image: 'postgres:latest',
        ports: [{ container: 5432, host: POSTGRES_PORT }],
        environment: {
          POSTGRES_USER,
          POSTGRES_PASSWORD,
          POSTGRES_DB
        },
        wait: {
          type: 'PORT'
        }
      }
    ]
  }
}

const connectionString = `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT}/${POSTGRES_DB}`

const setupApiClient = async (): Promise<EnvironmentReturn> => {
  process.env['WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE'] = connectionString
  const worker = await unstable_dev('./src/index.ts', {
    logLevel: 'info',
    experimental: { disableExperimentalWarning: true }
  })
  globalThis.testApiClient = hc<AppType>('', { fetch: worker.fetch.bind(worker) })
  return {
    teardown: async () => {
      await worker.stop()
    }
  }
}

const setupMigration = async (): Promise<EnvironmentReturn> => {
  const client = postgres(connectionString)
  await migrate(drizzle(client), { migrationsFolder: '<your-migration-folder>' })
  return { teardown: async () => {} }
}

export default (<Environment>{
  ...testContainersEnv,
  name: 'custom-environment',
  async setup(global_) {
    dotenv.config({ path: '.dev.vars' })
    const teardownFuncs = [
      await testContainersEnv.setup(global_, environmentOptions),
      await setupMigration(),
      await setupApiClient()
    ]

    return {
      teardown: async global_ => {
        for (const { teardown } of teardownFuncs.toReversed()) {
          await teardown(global_)
        }
      }
    }
  }
})

declare global {
  const testApiClient: ReturnType<typeof hc<AppType>>
}

処理の流れとしては、次のようになります。

  1. testcontainersのenvironmentでPostgresを起動
  2. drizzleでマイグレーション(setupMigration関数)
  3. unstable_devでサーバー起動(setupApiClient関数)

ただし、setupApiClientはその名の通り、サーバーの起動以上の仕事をこなしています。unstable_devはサーバーを起動するとともに、戻り値としてfetch APIを実装したfetchメソッドを持ったオブジェクトを返します。そのため、Hono RPCと組み合わせることができ、テストコードからは型のサポートを受けつつHTTPリクエストを送ることが可能になります。こんな具合です。

describe('POST /foo', () => {
  it('returns 200', async () => {
    const response = await testApiClient.foo.$post({
        json: {
            foo: 1 // typed!
        }
    })
    expect(response.status).toBe(200)
  })
})

おわりに

紹介は以上になります。インテグレーションテストについては、environmentがやや複雑にはなったものの、型のサポートを受けられるようになった点がとても気に入っています。

脚注
  1. 筆者の最近の記事を読まれているという高徳な方は「またか」と思われるかもしれませんが、Hono RPCは本当に開発者体験を爆上げしてくれるので至るところで擦っていきます。 ↩︎

  2. あくまでエミュレーション環境ではありますが、この先も特に断りも入れずにworkerdと呼ぶことにします。 ↩︎

Discussion