Closed5

Cloudflare Workersのテストの書き方を考える(2024/3/23更新)

mr_ozinmr_ozin

2024/3/23 追記

Vitestでのテストが改善されたためテストの書き方が変わりました。下記を参考にテストを作成することを推奨します。

https://developers.cloudflare.com/workers/testing/vitest-integration/get-started/write-your-first-test/

古いAPIであるunstable_dev を使っていた場合は下記を参考に書き換えができます。

https://developers.cloudflare.com/workers/testing/vitest-integration/get-started/migrate-from-unstable-dev/


2023/10/23 追記

下記でまとめました。

https://zenn.dev/mr_ozin/articles/0d48f7c16ba0ae


Cloudflare Workers前提

  • 実行環境はNode.jsではなく、workerdというJavaScriptランタイムで実行されている
    • そのため、ちゃんとテストするにはその環境下で動くように書く必要がある
  • WranglerというCLIツールで開発、デプロイ出来ます

テストの難点

  • とにかく情報がない(公式含め)
  • 見つけてもAPIがUnstableなことが多い
  • 以前はMiniflareというシミュレータとvitest-environment-miniflare でUnit Testが書けていたが、Miniflare v3になりAPIが変わった
  • Miniflare v3になってからの情報があまりないので、モックしたりシミュレートして動かすには手探りが必要。

手探りされている例

https://zenn.dev/mizchi/articles/d1-prisma-kysely#おまけ%3A-ローカルの-node-環境で-d1-をテストする

公式のドキュメント

https://github.com/cloudflare/miniflare/blob/v3.20230918.0/packages/miniflare/

※2023/10/23 追記

言及されている箇所

https://developers.cloudflare.com/workers/observability/local-development-and-testing/#test-workers

mr_ozinmr_ozin

統合っぽいテスト

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 で雛形を作成するとこのテストは標準で用意されなくなっており、自分で作成する必要がある。

https://developers.cloudflare.com/workers/get-started/guide/#5-write-tests

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でテストを書いている模様。

https://github.com/honojs/hono/blob/main/runtime_tests/wrangler/index.test.ts

mr_ozinmr_ozin

KVのテスト

Miniflare 3でもドキュメント記載のAPIは使える模様。

https://miniflare.dev/storage/kv

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'],
  })
mr_ozinmr_ozin

R2のテスト

公式サンプル

https://miniflare.dev/storage/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')
})
mr_ozinmr_ozin

D1のテスト

基本的に他と変わらない。公式通り初期データ用のschemaを定義して、それを実行する。

https://developers.cloudflare.com/d1/get-started/

schema.sql
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をそのまま実行しようとするとエラーになる。

そのため、空の改行でステートメントを分割し、改行を取り除いて実行する。

src/d1.test.ts
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.

https://developers.cloudflare.com/d1/platform/client-api/#await-dbexec

このスクラップは2023/10/24にクローズされました