VitestとMSW v2系を使ってSSEのレスポンスをテストする
はじめに
生成AI関連のチャットアプリケーションでは、SSE (Server-Sent Events) によってサーバーからクライアントに対してリアルタイムでイベントを送信されることが多いかと思います。
MSW (Mock Service Worker) では、v2.0.0
よりStreaming形式のMockを作成できるようになりました。
これにより、SSEによってリアルタイムで受信するレスポンスのMockを作成し、テストを行えるようになっています。
JestではなくVitestを採用している理由については、下記のスクラップより。
2024/01時点で、JestではStreamingのテストを行おうとするとテストが終了しない、といった状態になってしまう模様。
この記事では、fetchEventSourceを使用してサーバーへチャットメッセージのリクエストを行っている処理に対してレスポンスをMockし、Vitestによってテストを行えるようにしたいと思います。
この記事内で記載しないこと
- Vitestに関する解説
- MSWに関する解説
- SSEに関する解説
下準備
テストを実装するアプリケーションについて
"react": "^18",
"react-dom": "^18",
"next": "13.5.6",
"volta": {
"node": "18.17.0"
},
packageのインストール
以下のパッケージをインストールする
"@next/env": "^14.2.5",
"@testing-library/react": "^16.0.0",
"@vitejs/plugin-react": "^4.3.1",
"jsdom": "^24.1.1",
"msw": "^2.3.5",
"@testing-library/jest-dom": "^6.5.0",
インストールコマンド (npmの場合)
npm i -D @next/env @testing-library/react @testing-library/jest-dom @vitejs/plugin-react jsdom msw
上記コマンドでは最新のバージョンがインストールされるので、バージョン指定を行う場合は適宜設定を行なってください。
configの設定
vitest.config.ts
/// <reference types="vitest" />
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts']
},
resolve: {
alias: {
'@': `${__dirname}/src`
}
}
})
testで globals: true,
を設定することで、global APIとして各ファイルでモジュールのimportなしで扱えるようになります。
ただこのままだとtsエラーとなるので、tsconfig.jsonの方も修正します。
"compilerOptions": {
....,
....,
"types": ["vitest/globals"] // こちらを追加
}
vitest.config.tsに記載しているセットアップファイルも作成
vitest.setup.ts
import '@testing-library/jest-dom/vitest'
import { loadEnvConfig } from '@next/env'
loadEnvConfig(process.cwd())
MSWのセットアップ
publicディレクトリにworkerのスクリプトを作成します。
npx msw init ./public --save
以下のような形で、package.jsonにmswの設定が追加され、mockServiceWorker.js
がpublicディレクトリ下に追加されます。
"msw": {
"workerDirectory": [
"public"
]
}
src (もしくはルート) 下に__mocks__
ディレクトリを作成し、handler.tsを作成します。
import { http, HttpResponse, type ResponseResolver } from 'msw'
import { mockGenerateChatMessage } from './response/chatbot'
// 環境変数で設定した対象となるAPIのURL
const apiUrl = process.env.NEXT_PUBLIC_API_URL
const mockTest: ResponseResolver = () => {
return HttpResponse.json({
text: 'test'
})
}
export const handlers = [
http.get(`${apiUrl}/test`, mockTest),
http.post(`${apiUrl}/chatbot`, mockGenerateChatMessage)
]
__mocks__
直下にresponseディレクトリを作成し、mockGenerateChatMessage.tsを作成します。
APIから返却されるresponseは以下のような形で、dataにmessageを含んだオブジェクトが返却されるようになっています (ここは各アプリケーションの仕様に沿って定義してください)
`data: {"answerId": bc7aef48-8b7b-906c-e666-198861c83793, "message": "こんにちわ!", "threadId": 195, "chatBotLogId": 230}
mockGenerateChatMessage.ts
import { HttpResponse, ResponseResolver } from 'msw'
const encoder = new TextEncoder()
export const mockGenerateChatMessage: ResponseResolver = () => {
console.log('-------mock test')
const stream = new ReadableStream({
start: async (controller) => {
controller.enqueue(
encoder.encode(
'data: {"answerId": bc7aef48-8b7b-906c-e666-198861c83793, "message": "こんにちわ!", "threadId": 195, "chatBotLogId": 230}'
)
)
controller.enqueue(
encoder.encode(
'data: {"answerId": bc7aef48-8b7b-906c-e666-198861c83793, "message": "お元気ですか?", "threadId": 195, "chatBotLogId": 230}'
)
)
controller.enqueue(
encoder.encode(
'data: {"answerId": bc7aef48-8b7b-906c-e666-198861c83793, "message": "私は元気です!", "threadId": 195, "chatBotLogId": 230}'
)
)
controller.close()
}
})
console.log('--------mockend', stream)
return new HttpResponse(stream, {
headers: {
'Content-Type': 'text/event-stream'
}
})
}
テストの実装
今回テストを実装するアプリケーションは、useSendChatというhook内でfeatchEventSouceを用いてリクエストを送信し、受け取ったレスポンスを随時stateにセットする、という処理が行われていました。
fetchEventSource
上記のライブラリを用いてリクエストを行い、onmessage内で取得したレスポンスからmessageをstateにセットします。リクエストを行っている実装部分としては以下のような形です。
最終的には chatBotResponse
というstateに返却されたメッセージが随時追加されていくような実装となります。
await fetchEventSource(apiUrl, {
method: 'POST',
headers,
body,
credentials: 'include',
signal: abortController.current?.signal,
openWhenHidden: true,
onmessage: (msg: EventSourceMessage) => {
...ここでレスポンスに対してsetStateなどの処理を行う
実際に、hook内のリクエスト部分に対して、MSWでリクエストのインターセプトが行われ、記載したレスポンスが返却されるかのテストを行います。
今回はsrcディレクトリ直下に__tests__
というディレクトリと、その下にuseSendChat.test.tsxを作成しました。
useSendChat.test.tsx
import { act, renderHook } from '@testing-library/react'
import { setupServer } from 'msw/node'
import { handlers } from '@/__mocks__/handlers'
import { useSendChat } from '@/utils/hooks'
const mockServer = setupServer(...handlers)
describe('チャット送信時のテスト', async () => {
beforeAll(() => {
mockServer.listen()
})
afterEach(() => {
mockServer.resetHandlers()
})
afterAll(() => {
mockServer.close()
})
test('send chat message', async () => {
const { result } = renderHook(() => useSendChat())
await act(async () => {
await result.current.handleSubmitChat('chat', 'こんにちわ!')
})
const expected = 'こんにちわ!お元気ですか?私は元気です!'
expect(result.current.chatBotResponse).toEqual(expected)
})
})
まずはこちらでリクエストを送信してみます。
が、返却されたのは以下の通りnull
+ Received:
null
なぜかと思い原因を探ってみたところ、SSEのレスポンスは\n\n
によって区切られている必要があるとのことでした。
そのため、Mockとして定義した各レスポンスに対して、末尾に\n\n
を追加します。
controller.enqueue(
encoder.encode(
'data: {"answerId": "bc7aef48-8b7b-906c-e666-198861c83793", "message": "こんにちわ!", "threadId": 195, "chatBotLogId": 230}\n\n'
)
)
こちらで再度テストを実行すると……
✓ src/__tests__/hooks/useSendChat.test.tsx (1)
✓ チャット送信時のテスト (1)
✓ send chat message
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 12:07:49
Duration 250ms
PASS Waiting for file changes...
press h to show help, press q to quit
見事にテストがパスしました!
今回は単純にメッセージが返却された際の挙動のみをテストしましたが、レスポンス返却中にエラーが発生した際のクライアントでのメッセージ表示など、さまざまなテストが捗りそうです。
参考
アルサーガパートナーズ株式会社のエンジニアによるテックブログです。11月14(木)20時〜 エンジニア向けセミナーを開催!詳細とご応募は👉️ arsaga.jp/news/pressrelease-cheer-up-project-november-20241114/
Discussion