🤖

VitestとMSW v2系を使ってSSEのレスポンスをテストする

2024/09/12に公開

はじめに

生成AI関連のチャットアプリケーションでは、SSE (Server-Sent Events) によってサーバーからクライアントに対してリアルタイムでイベントを送信されることが多いかと思います。

MSW (Mock Service Worker) では、v2.0.0 よりStreaming形式のMockを作成できるようになりました。
https://mswjs.io/docs/recipes/streaming/
これにより、SSEによってリアルタイムで受信するレスポンスのMockを作成し、テストを行えるようになっています。

JestではなくVitestを採用している理由については、下記のスクラップより。
2024/01時点で、JestではStreamingのテストを行おうとするとテストが終了しない、といった状態になってしまう模様。
https://zenn.dev/link/comments/9284452e169b96

この記事では、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
https://www.npmjs.com/package/@microsoft/fetch-event-source

上記のライブラリを用いてリクエストを行い、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によって区切られている必要があるとのことでした。
https://ja.javascript.info/server-sent-events#ref-13

そのため、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

見事にテストがパスしました!

今回は単純にメッセージが返却された際の挙動のみをテストしましたが、レスポンス返却中にエラーが発生した際のクライアントでのメッセージ表示など、さまざまなテストが捗りそうです。

参考

https://mswjs.io/docs/recipes/streaming/

https://zenn.dev/keitakn/scraps/2ca70305a71847

https://alexocallaghan.com/mock-sse-with-msw

Arsaga Developers Blog

Discussion