💯

Web標準のバックエンドアプリのテスト

2024/08/27に公開

ここで言う「Web標準のバックエンドアプリ」とはCloudflare Workers、Deno、BunなどのWeb標準をサポートするランタイム上で動くWebアプリです。もしくは、Node.jsでもWeb標準のAPIをサポートしているので、これから紹介するアプリとテストコード自体はNode.js上も動きます。

これらのテストを書く、実行するのは実にシンプルで、拍子抜けしてしまうほどです。ですが、現在、20,000行あるHonoのテストコードは、これらのやり方と全く同じ方法を取っています。注目すべき点は、実際のサーバーを立ち上げることがなく、抽象化されたリクエストとレスポンスのやり取りを試験するだけで、品質が保たれる点です。Honoのテストでは、ランタイムによっては一部実サーバーを立ち上げますが、コアの20,000行ではサーバーを立ち上げていません。この抽象化はPythonのWSGI、RubyのRackやPerlのPSGIでやられていることですが、JavaScriptでは「Web標準」としてそれらのAPIが提供され、ブラウザでも実行できます。そして、デザインが洗練されていると思います。

このように、Web標準のバックエンドアプリのテストがポータブルでシンプルなことはあまり知られてないかもしれません。逆にこのテストの良さを知ることで、Cloudflare WorkersやDeno、Bunのアプリを作ることに興味を持つかもしれません。

では、実際にどう書くのかを見ていきましょう。

最小限のアプリ

まず最初に、最小限のアプリを書いてみましょう。今後、fetchハンドラと呼んでいくメソッドの中で、Responseオブジェクトを返します。

src/index.ts
export default {
  fetch: (req?: Request) => {
    return new Response('Hi')
  },
}

アクセスするとHiが返ってきます。また(ランタイムによって実装が変わる可能性がありますが)以下の通りです。

  • ステータスコードは200
  • コンテンツタイプはtext/plain;charset=UTF-8相当

このアプリはCloudflare Workers、Deno、Bunで全く同じコードが動きます。各々の立ち上げ方です。

Cloudflare Workers:

wrangler dev src/index.ts

Deno:

deno serve src/index.ts

Bun:

bun run src/index.ts

「本当に」全く同じコードが3つの異なるランタイムで動くのは非常にエキサイティングです。

最小限のテスト

では、このアプリをテストしてみます。Cloudflare Workersの場合は、VitestやJestなどのNode.jsベースのもの、Deno、Bunはランタイムに付属しているテストランナーを使います。今回はBunのbun testコマンドを使ってみましょう。

src/index.test.tsという空ファイルを作って、テスト実行してみます。

bun test

Bun test

0 testsと表示されますが、テストが走っています。

では、以下が最初のテストです。アプリケーションにアクセスして、返ってきたレスポンスのステータスコードが200かどうかを試験しています。

src/index.test.ts
import app from './index'

describe('Testing My App', () => {
  it('Should return 200 response', () => {
    const res = app.fetch()
    expect(res.status).toBe(200)
  })
})

Bun test

app.fetch()を実行したらResponseオブジェクトが返るので、当然といえば当然のコードなんですが、これがベースになります。また、レスポンスの中身を試験したければ、res.text()を使います。Promiseが返るので、itの第二引数の関数をasyncにしてres.text()awaitさせるのがコツです。

src/index.test.ts
describe('Testing My App', () => {
  it('Should return 200 response', async () => {
    const res = app.fetch()
    expect(res.status).toBe(200)
    expect(await res.text()).toBe('Hi')
  })
})

JSONレスポンスのテスト

JSONレスポンスを返すのに便利なのはResponse.json()というスタティックメソッドです。先ほどのアプリでJSONを返すようにしてみましょう。

src/index.ts
export default {
  fetch: (req?: Request) => {
    return Response.json({
      ok: true,
      message: 'Hi',
    })
  },
}

このJSONレスポンスを試験するには、res.text()では値がうまく取れないので、res.json()でとって、さらに構造体を比較するのにtoEqual()を使っています。

src/index.test.ts
it('Should return 200 response', async () => {
  const res = app.fetch()
  expect(await res.json()).toEqual({
    ok: true,
    message: 'Hi',
  })
})

ヘッダのテスト

レスポンスにヘッダを追加するには、Headersオブジェクトを作って、Responseのコンストラクタに渡します。以下はX-Powered-ByというカスタムヘッダにMeという値をセットしています。

src/index.ts
export default {
  fetch: (req?: Request) => {
    const headers = new Headers()
    headers.append('X-Powered-By', 'Me')
    return new Response('Hi', {
      headers,
    })
  },
}

これを試験するには、res.headersでレスポンス側のHeadersオブジェクトが取れるので、その値を見ればよいです。

src/index.test.ts
it('Should return 200 response', async () => {
  const res = app.fetch()
  expect(res.headers.get('X-Powered-By')).toBe('Me')
})

リクエストも含んだテスト

では次に、アプリの中でリクエストをハンドルして、その挙動をテストしてみましょう。

リクエストはfetch関数の第一引数にRequestオブジェクトとしてやってきます。例えば、リクエストのパスをテキストで返すアプリは以下のように書けます。

src/index.ts
export default {
  fetch: (req: Request) => {
    const url = new URL(req.url)
    return new Response(`Path is ${url.pathname}`)
  },
}

さてテストです。app.fetch()にRequestオブジェクトを引数で渡さなくてはいけません。なので、Requestインスタンスを作りましょう。コツはコンストラクタの第一引数にアクセスするパスを渡すのですが、その際に絶対URLにすることです。以下の場合だとhttp://localhost/fooとすることで、返却値の中のパスが/fooになります。

src/index.test.ts
it('Should return 200 response', async () => {
  const req = new Request('http://localhost/foo')
  const res = app.fetch(req)
  expect(await res.text()).toBe('Path is /foo')
})

ChromeなどブラウザのRequestの実装だと、絶対URLじゃなくとも動作するのですが、CloudflareやDeno、Bun、Node.jsではパスだけだとエラーになります。なので、特にこだわりがなければ、今回のようにhttp://localhostなど適当なものを入れておきます。

ボディありのPOSTリクエストのテスト

もう少し複雑なアプリを作って、それをテストしてみましょう。

Formデータのボディを含むPOSTリクエストを受け取り、その中のmessageというキーの値を取り出し、テキストで返すアプリです。もし、POSTリクエストではなかったら、404を返すようにもしておきました。本来ならFormデータのパースに失敗した時のハンドリングも書いた方がいいですが、一旦これです。

src/index.ts
export default {
  fetch: async (req: Request) => {
    if (req.method === 'POST') {
      const data = await req.formData()
      const message = data.get('message')
      return new Response(`Message is ${message}`)
    }
    return new Response('Not Found', {
      status: 404,
    })
  },
}

req.methodでリクエストのメソッド名が取れるのでそれで分岐しています。ちなみにHonoなどのルーター機能を使えば、このようなif分による分岐が必要なくなります。

コツは、req.formData()でボディの中身を取る時に、返却値がPromiseになるので、awaitしてる点です。これは上記のテスト内で、res.text()res.json()Promiseを返すのと同じで、基本的にリクエスト、レスポンスのボディをパースして取る時には非同期になります。ですので、fetchの関数自体をasync関数にしています。整理すると、fetchハンドラの型は以下で表せます。

type FetchHandler = (req: Request) => Response | Promise<Response>

ですので、テスト内でapp.fetch()する時にはPromiseで返る可能性があるので、特に意図がなければ以下のようにawaitしておくのがいいでしょう。

const res = await app.fetch(req)

ではまず、404が返ることからテストします。これはもうお分かりですね。/GETリクエストを送っています。

src/index.test.ts
it('Should return 404 response', async () => {
  const req = new Request('http://localhost/')
  const res = await app.fetch(req)
  expect(res.status).toBe(404)
  expect(await res.text()).toBe('Not Found')
})

次に200の場合。つまり、POSTでFormデータを送るパターンです。FromDataオブジェクトを作り、Requestコンストラクタのbodyに渡し、methodPOSTを指定します。これで期待するリクエストが作れてるので、試験はパスします。

src/index.test.ts
it('Should return 200 response', async () => {
  const formData = new FormData()
  formData.append('message', 'Hi')
  const req = new Request('http://localhost/', {
    method: 'POST',
    body: formData,
  })
  const res = await app.fetch(req)
  expect(res.status).toBe(200)
  expect(await res.text()).toBe('Message is Hi')
})

ちなみに、bodyにはテキストも渡すことができるので、こんなことをすればJSONボディを送れます(本来ならリクエストヘッダにJSONを示すContent-Typeを入れた方がよい)。

src/index.test.ts
const req = new Request('http://localhost/', {
  method: 'POST',
  body: JSON.stringify({
    message: 'Hi',
  }),
})

ちなみに、アプリケーション側では、req.json()で中身を取り出せます。

src/index.ts
const data = await req.json()
return new Response(`Message is ${data.message}`)

ここまでのまとめ

以上が、Web標準のバックエンドアプリの例とそれらをテストする方法でした。このようにRequestResponseオブジェクトを使ったシンプルな試験方法は、これから紹介するHonoの場合にも通用し、上記した通りHono内部の20,000行のテストコードで多用されているスタイルです。抽象化されている上に、挙動も(ランタイムごとの稀なバグがない限り)実サーバーで正しく動きます。

Honoアプリのテスト

Cloudflare Workers、Deno、Bun、また、Node.js上で動くアプリは、Webフレームワーク「Hono」を使って作るとより効率的です。そして、さきほどの素のアプリと全く同じようにテストをすることができます。

以下が最低限のアプリです。/GETリクエストが来たら、Hiというテキストのレスポンスを返します。

src/index.ts
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => {
  return c.text('Hi')
})

export default app

これは上記のfetchハンドラを使って書いたアプリケーションがやっていることと何ら変わりません。ですので、もうおなじみの以下のテストは動くし、パスします。

src/index.test.ts
import app from './index'

describe('Testing My App', () => {
  it('Should return 200 response', async () => {
    const req = new Request('http://localhost/')
    const res = await app.fetch(req)
    expect(res.status).toBe(200)
    expect(await res.text()).toBe('Hi')
  })
})

そう、全く同じなんです。これは、Honoのアプリケーションが各ランタイムでディスパッチするfetchインターフェースと互換性があるからです。これを応用すると、素で書いてたアプリをHonoベースに移行する際、テストを全く変えずに挙動を保証しながら移行できます。

app.request()

その一方で、冗長な書き方を防ぐためにHonoのアプリケーションにはapp.request()という便利なメソッドが生えています。第一引数にリクエストパスを与えるとGETリクエストが飛び、非同期のレスポンスが返ってきます。

const res = await app.request('/')

これはひとつ前のテストでやっていることと全く同じです。これが便利でスッキリ書けます。

src/index.test.ts
it('Should return 200 response', async () => {
  const res = await app.request('/')
  expect(res.status).toBe(200)
  expect(await res.text()).toBe('Hi')
})

またボディありのPOSTリクエストのハンドリングはHonoアプリでは以下のように書けます。

src/index.ts
app.post('/', async (c) => {
  const data = await c.req.parseBody<{ message: string }>()
  return c.text(`Message is ${data.message}`)
})

それに対してapp.request()を使ったテストは第2引数に先程のRequestコンストラクタで渡したRequestInitと呼ばれる引数を指定すればOKです。

src/index.test.ts
it('Should return 200 response', async () => {
  const formData = new FormData()
  formData.append('message', 'Hi')
  const res = await app.request('/', {
    method: 'POST',
    body: formData,
  })
  expect(res.status).toBe(200)
  expect(await res.text()).toBe('Message is Hi')
})

このapp.request()を使えばサクサクとテストコードを書いていくことができるでしょう。

Testing Helper

Honoではアプリを作る上で便利なメソッド群をヘルパーという形で提供しています。その中の「Testing Helper」は非常にユニークな形でテストを書くことを助けてくれます。具体的には、Honoが持っているRPC機能のTypeScriptの型サポートをうけることができます。

使い方はちょっとコツがあるのですが、以下のようなアプリをつくります。

src/index.ts
import { Hono } from 'hono'

const app = new Hono().get('/foo/bar', (c) => {
  return c.json({ message: 'Hi' })
})

export default app

テスト側では、hono/testingからimportしたtestClientappをくるんであげます。

src/index.test.ts
import { testClient } from 'hono/testing'
import app from './index'

//...
testClient(app)

このtestClientが優秀で、この動画をみてもらいたいのですが、アクセスすべきパスとメソッドの補完と、返り値のデータタイプの補完が効きます!

testing-helper

最終的なコードはこちらです。

src/index.test.ts
import { testClient } from 'hono/testing'
import app from './index'

describe('Testing My App', () => {
  it('Should return 200 response', async () => {
    const res = await testClient(app).foo.bar.$get()
    const data = await res.json()
    expect(data.message).toBe('Hi')
  })
})

共通でテストできないもの

以上までが、一般的なWeb標準のバックエンドアプリのテストの書き方です。これさえ覚えておけば、Cloudflare Workers、Deno、Bun等に対応するアプリを試験できます。

とはいえ、ランタイムもしくはプラットフォーム固有の事柄はあります。そしてそれらをテストする必要もでてきます。

runtime_tests

例えば、Honoでは、コアがおいてあるhonojs/honoのプロジェクトにruntime_testsというディレクトリがあります。その中には、各ランタイム固有の事についてのテストがあります。共通項を20,000行のテストで試験して、残りの部分はここにあるわけですね。

yusuke $ tree -L 1 ./runtime_tests
./runtime_tests
├── bun
├── deno
├── deno-jsx
├── fastly
├── lambda
├── lambda-edge
├── node
└── workerd

Cloudflare Workersのテスト

その中でもCloudflare Workers固有のテストを見ていきましょう。

Cloudflare Workersといえば、Bindingsと呼ばれている各種CloudflareプロダクトをWorkersから扱えるのが特徴です。例えば、以下のようなものです。

  • Variables (環境変数)
  • KV
  • D1
  • R2
  • Queues
  • ...

また、固有のものとして、通称ExecutionContextと呼んでいるオブジェクトと、Requestオブジェクトに生えるRequest.cfプロパティというのがあります。

これまで使っていたfetchハンドラに対応する、Cloudflare Workers特有の型ExportedHandlerを当てると、それぞれがどのように渡ってくるかを知ることができます。

src/index.ts
interface Bindings {
  KV: KVNamespace
}

export default {
  fetch: (req, env, ctx) => {
    // req is Request
    // env is Bindings
    // ctx is ExecutionContext
    // You can access `req.cf?.country`
    return new Response('Hi')
  },
} satisfies ExportedHandler<Bindings>

DenoやBunとは違い、fetchの第2引数にenvというBindingsへのアクセサ、第3引数にExecutionContextが渡ってきています。また、req.cfからCloudflare特有のリクエストプロパティへアクセスできます。

これらは、wrangler devや、Workersへデプロイした場合に正常にアクセスできます。しかし、これまでのテストの方法だとこれらの値をテスト内で取得することは不可能です。

そこで、Cloudflareの環境をエミュレートし、envとExecutionContextを利用するために@cloudflare/vitest-pool-workersというVitestの環境を使います。設定の詳細は公式のドキュメントを参考にしていただくとして、テストコードでの使い方の一例を紹介します。

今回は、BindingsにKVを使ったアプリケーションを作ってみます。

src/index.ts
interface Bindings {
  KV: KVNamespace
}

export const KEY = 'key'

export default {
  fetch: async (req, env) => {
    if (req.method === 'PUT') {
      const data = await req.formData()
      await env.KV.put(KEY, data.get(KEY))
      return new Response('Success!')
    }
    const value = await env.KV.get(KEY)
    return new Response(`Value is ${value}`)
  },
} satisfies ExportedHandler<Bindings>

これはメソッドがPUTだった場合にKEYのフォームデータの値を取得し、KEYというキーでKVに入れます。PUT以外だった場合は、KEYの値を取得します。

今回で検証したいのは、最初のリクエスト時に、fooという値を送ったら、次のアクセス時にfooを取得できてるかです。肝はまず、cloudflare:testからenvをimportすることです。

src/index.test.ts
import { env } from 'cloudflare:test'

これ自体が、Bindingsそのものになります。ということは、それをそのままapp.fetch()の第2引数に渡せば、アプリケーションに渡ることになります。

src/index.test.ts
const res = await app.fetch(req, env)

テストの全体はこうなります。

src/index.test.ts
import app, { KEY } from './index'
import { env } from 'cloudflare:test'

describe('Testing My App', () => {
  it('Should put and get the value with KV', async () => {
    const formData = new FormData()
    formData.append(KEY, 'foo')
    let req = new Request('http://localhost', {
      method: 'PUT',
      body: formData,
    })

    let res = await app.fetch(req, env)
    expect(res.status).toBe(200)
    expect(await res.text()).toBe('Success!')

    req = new Request('http://localhost/put')
    res = await app.fetch(req, env)
    expect(res.status).toBe(200)
    expect(await res.text()).toBe('Value is foo')
  })
})

この方法を使えば、他のD1やR2といったBindingsもテストすることができます。

まとめ

以上、Cloudflare Workers、Deno、Bunなどのランタイムで動くWeb標準のアプリをテストする手法を紹介しました。繰り返す通り、Honoの20,000行のテストはこれと全く同じ手法に基づき書かれています。それで、品質を担保し、実サーバーでの動作を保証できます。このテストのポータビリティは素晴らしく、ぜひ体験してもらいたいのと、「テストがしやすい」のを理由にWeb標準でアプリケーションを作るのもありだと思います。

Discussion