Cloudflare Workersのテストの書き方を考える(2024/3/23更新)
2024/3/23 追記
Vitestでのテストが改善されたためテストの書き方が変わりました。下記を参考にテストを作成することを推奨します。
古いAPIであるunstable_dev
を使っていた場合は下記を参考に書き換えができます。
2023/10/23 追記
下記でまとめました。
Cloudflare Workers前提
- 実行環境はNode.jsではなく、workerdというJavaScriptランタイムで実行されている
- そのため、ちゃんとテストするにはその環境下で動くように書く必要がある
- WranglerというCLIツールで開発、デプロイ出来ます
テストの難点
- とにかく情報がない(公式含め)
- 見つけてもAPIがUnstableなことが多い
- 以前はMiniflareというシミュレータと
vitest-environment-miniflare
でUnit Testが書けていたが、Miniflare v3になりAPIが変わった - Miniflare v3になってからの情報があまりないので、モックしたりシミュレートして動かすには手探りが必要。
手探りされている例
公式のドキュメント
※2023/10/23 追記
言及されている箇所
統合っぽいテスト
Cloudflare WorkersのフレームワークとしてHonoを使う。
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello Hono!'))
export default app
テストを書いていく。Vitestの前提。
Honoにはapp.request('/')
でリクエストを送れるので、それを利用する。
また、unstable_dev
というちょっと不安になるメソッドを利用することでもテストができる。Honoを使わないでWorkersの雛形を作成すると、このメソッドを使ったテストが出てくるので、一応公式の手段。
2023/10/23 追記
公式ドキュメントにもunstable_dev
への言及あり。
また、新しい雛形作成コマンドであるpnpm create cloudflare@latest
で雛形を作成するとこのテストは標準で用意されなくなっており、自分で作成する必要がある。
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import type { UnstableDevWorker } from 'wrangler'
import { unstable_dev } from 'wrangler'
import app from './index'
// simple version
describe('Hono API version', () => {
test('GET /', async () => {
const res = await app.request('/')
expect(res.status).toBe(200)
expect(await res.text()).toBe('Hello Hono!')
})
})
// unstable_dev version
describe('Wrangler', () => {
let worker: UnstableDevWorker
beforeAll(async () => {
// relative path from project root to app file
worker = await unstable_dev('./src/index.ts', {
experimental: { disableExperimentalWarning: true },
})
})
afterAll(async () => {
await worker.stop()
})
test('GET /', async () => {
const res = await worker.fetch('/')
expect(res.status).toBe(200)
expect(await res.text()).toBe('Hello Hono!')
})
})
Honoもunstable_devでテストを書いている模様。
KVのテスト
Miniflare 3でもドキュメント記載のAPIは使える模様。
pnpm i -D miniflare
Miniflareインスタンスを作成してgetKVNamespace
でKVを取り出すことができる。
インスタンス作成時にWorkerの処理を書くところがあるが、中身はなんでもいい模様。
kvPersist: true
オプションを有効にすると永続化され、ファイルに書き込まれる。
import { Miniflare } from 'miniflare'
import { expect, test } from 'vitest'
test('KV', async () => {
// create miniflare instance
const mf = new Miniflare({
modules: true,
script: `export default {
async fetch(req, env, ctx) {
return new Response('foo');
}
}`,
kvNamespaces: ['TEST_NAMESPACE'],
// kvPersist: true save to ./.mf/kv/TEST_NAMESPACE
})
const kv = await mf.getKVNamespace('TEST_NAMESPACE')
await kv.put('count', '1')
expect(await kv.get('count')).toBe('1')
await mf.dispose()
})
余談だがMiniflareインスタンス作成時の引数にscript: ''
を指定しても動く模様。
// create miniflare instance
const mf = new Miniflare({
modules: true,
script: '',
kvNamespaces: ['TEST_NAMESPACE'],
})
R2のテスト
公式サンプル
正直このサンプルだと、ファイルアップロードをテストできているか微妙なのでもうちょっとファイルっぽい書き方でテストを書いた。
File APIを使いたかったが、File is not defined
となる。@cloudflare/workers-types
には定義されているようなのだが、エラーになってしまう。Blob APIは使えるのでそれで書く。
が、await bucket.put
でまた型エラーになるので無視するようにする。
import { Miniflare } from 'miniflare'
import { expect, test } from 'vitest'
test('R2', async () => {
// create miniflare instance
const mf = new Miniflare({
modules: true,
script: `export default {
async fetch(req, env, ctx) {
const object = await env.BUCKET.get("dummy");
if(object){
const file = await object.text()
return new Response(file)
} else {
return new Response("not found", {
status: 404
});
}
}
}`,
r2Buckets: ['BUCKET'],
})
// empty bucket
const resError = await mf.dispatchFetch('http://localhost:8787/')
expect(resError.status).toBe(404)
expect(await resError.text()).toBe('not found')
const bucket = await mf.getR2Bucket('BUCKET')
// create dummy file with JavaScript API
const dummy = new Blob(['hello'], {
type: 'text/plain',
})
// @ts-expect-error
await bucket.put('dummy', dummy)
// check bucket
const fileFromBucket = await bucket.get('dummy')
expect(await fileFromBucket?.text()).toBe('hello')
// check worker response
const resSuccess = await mf.dispatchFetch('http://localhost:8787/')
expect(resSuccess.status).toBe(200)
expect(await resSuccess.text()).toBe('hello')
})
D1のテスト
基本的に他と変わらない。公式通り初期データ用のschemaを定義して、それを実行する。
DROP TABLE IF EXISTS Customers;
CREATE TABLE IF NOT EXISTS Customers (
CustomerId INTEGER PRIMARY KEY,
CompanyName TEXT,
ContactName TEXT
);
INSERT INTO
Customers (CustomerID, CompanyName, ContactName)
VALUES
(1, 'Alfreds Futterkiste', 'Maria Anders'),
(4, 'Around the Horn', 'Thomas Hardy'),
(11, 'Bs Beverages', 'Victoria Ashworth'),
(13, 'Bs Beverages', 'Random Name');
await db.exec()
の注意点
注意点としてawait db.exec()
で受け付けるのは改行を含まない1ステートメントらしく、schema.sql
をそのまま実行しようとするとエラーになる。
そのため、空の改行でステートメントを分割し、改行を取り除いて実行する。
import { Miniflare } from 'miniflare'
import { expect, test } from 'vitest'
import schema from '../schema.sql?raw'
test('D1', async () => {
// create miniflare instance
const mf = new Miniflare({
modules: true,
script: `export default {
async fetch(req, env, ctx) {
return new Response("ok")
}
}`,
d1Databases: ['DB'],
})
const d1 = await mf.getD1Database('DB')
// split empty line
for (const s of schema.split('\n\n')) {
// trim new line
await d1.exec(s.replaceAll('\n', ''))
}
const result = await d1
.prepare('SELECT * FROM Customers WHERE CustomerId = ?')
.bind(1)
.first()
expect(result).toEqual({
CustomerId: 1,
CompanyName: 'Alfreds Futterkiste',
ContactName: 'Maria Anders',
})
await mf.dispose()
})
なお公式ドキュメントには下記の記載がある。
The input can be one or multiple queries separated by \n.