Web標準のバックエンドアプリのテスト
ここで言う「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
オブジェクトを返します。
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
0 tests
と表示されますが、テストが走っています。
では、以下が最初のテストです。アプリケーションにアクセスして、返ってきたレスポンスのステータスコードが200
かどうかを試験しています。
import app from './index'
describe('Testing My App', () => {
it('Should return 200 response', () => {
const res = app.fetch()
expect(res.status).toBe(200)
})
})
app.fetch()
を実行したらResponse
オブジェクトが返るので、当然といえば当然のコードなんですが、これがベースになります。また、レスポンスの中身を試験したければ、res.text()
を使います。Promise
が返るので、it
の第二引数の関数をasync
にしてres.text()
をawait
させるのがコツです。
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を返すようにしてみましょう。
export default {
fetch: (req?: Request) => {
return Response.json({
ok: true,
message: 'Hi',
})
},
}
このJSONレスポンスを試験するには、res.text()
では値がうまく取れないので、res.json()
でとって、さらに構造体を比較するのにtoEqual()
を使っています。
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
という値をセットしています。
export default {
fetch: (req?: Request) => {
const headers = new Headers()
headers.append('X-Powered-By', 'Me')
return new Response('Hi', {
headers,
})
},
}
これを試験するには、res.headers
でレスポンス側のHeaders
オブジェクトが取れるので、その値を見ればよいです。
it('Should return 200 response', async () => {
const res = app.fetch()
expect(res.headers.get('X-Powered-By')).toBe('Me')
})
リクエストも含んだテスト
では次に、アプリの中でリクエストをハンドルして、その挙動をテストしてみましょう。
リクエストはfetch
関数の第一引数にRequest
オブジェクトとしてやってきます。例えば、リクエストのパスをテキストで返すアプリは以下のように書けます。
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
になります。
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データのパースに失敗した時のハンドリングも書いた方がいいですが、一旦これです。
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
リクエストを送っています。
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
に渡し、method
はPOST
を指定します。これで期待するリクエストが作れてるので、試験はパスします。
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
を入れた方がよい)。
const req = new Request('http://localhost/', {
method: 'POST',
body: JSON.stringify({
message: 'Hi',
}),
})
ちなみに、アプリケーション側では、req.json()
で中身を取り出せます。
const data = await req.json()
return new Response(`Message is ${data.message}`)
ここまでのまとめ
以上が、Web標準のバックエンドアプリの例とそれらをテストする方法でした。このようにRequest
、Response
オブジェクトを使ったシンプルな試験方法は、これから紹介するHonoの場合にも通用し、上記した通りHono内部の20,000行のテストコードで多用されているスタイルです。抽象化されている上に、挙動も(ランタイムごとの稀なバグがない限り)実サーバーで正しく動きます。
Honoアプリのテスト
Cloudflare Workers、Deno、Bun、また、Node.js上で動くアプリは、Webフレームワーク「Hono」を使って作るとより効率的です。そして、さきほどの素のアプリと全く同じようにテストをすることができます。
以下が最低限のアプリです。/
にGET
リクエストが来たら、Hi
というテキストのレスポンスを返します。
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hi')
})
export default app
これは上記のfetch
ハンドラを使って書いたアプリケーションがやっていることと何ら変わりません。ですので、もうおなじみの以下のテストは動くし、パスします。
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('/')
これはひとつ前のテストでやっていることと全く同じです。これが便利でスッキリ書けます。
it('Should return 200 response', async () => {
const res = await app.request('/')
expect(res.status).toBe(200)
expect(await res.text()).toBe('Hi')
})
またボディありのPOST
リクエストのハンドリングはHonoアプリでは以下のように書けます。
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です。
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の型サポートをうけることができます。
使い方はちょっとコツがあるのですが、以下のようなアプリをつくります。
import { Hono } from 'hono'
const app = new Hono().get('/foo/bar', (c) => {
return c.json({ message: 'Hi' })
})
export default app
テスト側では、hono/testing
からimportしたtestClient
でapp
をくるんであげます。
import { testClient } from 'hono/testing'
import app from './index'
//...
testClient(app)
このtestClient
が優秀で、この動画をみてもらいたいのですが、アクセスすべきパスとメソッドの補完と、返り値のデータタイプの補完が効きます!
最終的なコードはこちらです。
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
を当てると、それぞれがどのように渡ってくるかを知ることができます。
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を使ったアプリケーションを作ってみます。
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することです。
import { env } from 'cloudflare:test'
これ自体が、Bindingsそのものになります。ということは、それをそのままapp.fetch()
の第2引数に渡せば、アプリケーションに渡ることになります。
const res = await app.fetch(req, env)
テストの全体はこうなります。
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