Cloudflare Workersのテストの書き方
はじめに
本稿は、筆者が実際に業務で採用している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つでした。
- vitestの起動前にDockerコマンドでDBを立ち上げてマイグレーション
- 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>>
}
処理の流れとしては、次のようになります。
- testcontainersのenvironmentでPostgresを起動
- drizzleでマイグレーション(
setupMigration
関数) - 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がやや複雑にはなったものの、型のサポートを受けられるようになった点がとても気に入っています。
Discussion