Closed12

MSW(Mock Service Worker)をv2.0.0にアップグレードする

keitaknkeitakn

概要

MSW(Mock Service Worker) のバージョンを2系の最新に更新する。

今回のアップグレードで個人的に一番大きいのは下記の機能でStreaming形式のMockを作成できるようになった事。

最近生成AIのレスポンスを扱う機会が多いのだが、これでフロント側でのテストが非常に捗るようになるので以前からずっとこの機能を待っていた。今回は v2.0.0 へのアップグレードとStreaming形式のMockを作成する事をゴールとする。

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

keitaknkeitakn

公式のマイグレーションガイドを確認

以下のページを参考にする。

https://mswjs.io/docs/migrations/1.x-to-2.x

今回のメジャーバージョンアップはAPIに破壊的な変更がある。

大きめな内容を2つほど紹介する。

setupWorker のimport元の変更

公式そのままだが以下の通り。

Before:

import { setupWorker } from 'msw'

After:

import { setupWorker } from 'msw/browser'

ちなみに自分は setupServer だけしか使っていなかったので影響はなかった。

resthttp

REST APIの場合、以前は以下のように rest を使っていた。

rest.get('/resource', (req) => {
  const productId = req.url.searchParams.get('id')
})

今回からは http を使う事になる。

import { http } from 'msw'
 
http.get('/resource', ({ request }) => {
  const url = new URL(request.url)
  const productId = url.searchParams.get('id')
})

以下に新APIの使い方が載っているのでこちらも確認しておく。

https://mswjs.io/docs/api/http

https://mswjs.io/docs/api/graphql

keitaknkeitakn

以下のように定義されていたMockが

import { sleep } from '@/utils';
import {
  type MockedRequest,
  type ResponseResolver,
  type restContext,
} from 'msw';

export const mockGenerateCatMessageTooManyRequestsErrorResponseBody: ResponseResolver<
  MockedRequest,
  typeof restContext
> = async (req, res, ctx) => {
  await sleep();

  return await res(
    ctx.status(429),
    ctx.json({
      type: 'TOO_MANY_REQUESTS', title: 'Too Many Requests'
    }),
  );
};

以下のように変更される。

import { HttpResponse, type ResponseResolver } from 'msw';

export const mockGenerateCatMessageTooManyRequestsErrorResponseBody: ResponseResolver =
  () => {
    return HttpResponse.json(
      { type: 'TOO_MANY_REQUESTS', title: 'Too Many Requests' },
      { status: 429, statusText: 'Too Many Requests' },
    );
  };
keitaknkeitakn

Streamingでレスポンスを返す為のMock

一番楽しみにしていたStreaming対応のMockを作ってみる。

最近よくあるSSE(Server-Sent Events)でLLMの応答を生成する部分のMockを作ってみる。

以下のようになる。

import { sleep } from '@/utils';
import { HttpResponse, type ResponseResolver } from 'msw';

const encoder = new TextEncoder();

export const mockGenerateCatMessage: ResponseResolver = () => {
  const stream = new ReadableStream({
    start: async (controller) => {
      await sleep();

      controller.enqueue(
        encoder.encode(
          'data: {"conversationId": "7fe730ac-5ea9-d01d-0629-568b21f72982", "message": "こんにちは🐱"}',
        ),
      );

      await sleep(0.5);

      controller.enqueue(
        encoder.encode(
          'data: {"conversationId": "7fe730ac-5ea9-d01d-0629-568b21f72982", "message": "もこだにゃん🐱"}',
        ),
      );

      await sleep(0.5);

      controller.enqueue(
        encoder.encode(
          'data: {"conversationId": "7fe730ac-5ea9-d01d-0629-568b21f72982", "message": "お話しようにゃん🐱"}',
        ),
      );

      await sleep(0.5);

      controller.enqueue(
        encoder.encode(
          'data: {"conversationId": "7fe730ac-5ea9-d01d-0629-568b21f72982", "message": "🐱🐱🐱"}',
        ),
      );
      controller.close();
    },
  });

  return new HttpResponse(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
    },
  });
};

ちなみに sleep の中身は↓のような感じ、これは実際の非同期処理の体感に近づける為の機能。

const millisecond = 1000;

const defaultWaitSeconds = 1;

export const sleep = async (
  waitSeconds: number = defaultWaitSeconds,
): Promise<void> => {
  await new Promise<void>((resolve) => {
    setTimeout(() => {
      resolve();
    }, waitSeconds * millisecond);
  });
};
keitaknkeitakn

Storybook上でSSE(Server-Sent Events)のMockを使ったテストを行う

msw-storybook-addon を利用する。

まだ正式には v2.0.0 に対応していないが こちらのコメント の通り npm install msw-storybook-addon@2.0.0--canary.122.b3ed3b1.0 を実行すると現時点でもStorybook上で動作させる事が可能。

現在開発中の個人サービス AI Meow Cat で動作確認を実施した。

https://www.ai-meow-cat.com/

このGifアニメだと見にくいので以下のPR内に貼ってある動画を見たほうが良いかも。

https://github.com/nekochans/ai-cat-frontend/pull/68

正式版ではないが、特に問題なく動作している。

keitaknkeitakn

Jestで動作確認を実施する

LLMからの応答メッセージを生成する generateCatMessage のテストコードで動作確認を実施する。

テストコード

/**
 * @jest-environment node
 */
import { generateCatMessage } from '@/api/client/generateCatMessage';
import { TooManyRequestsError } from '@/api/errors';
import {
  isGenerateCatMessageResponse,
  type GenerateCatMessageResponse,
} from '@/features';
import { createInternalApiUrl } from '@/features/url';
import {
  mockGenerateCatMessage,
  mockGenerateCatMessageTooManyRequestsErrorResponseBody,
} from '@/mocks';
import { http } from 'msw';
import { setupServer } from 'msw/node';

const mockHandlers = [
  http.post(createInternalApiUrl('generateCatMessage'), mockGenerateCatMessage),
];

const mockServer = setupServer(...mockHandlers);

// eslint-disable-next-line
describe('src/api/client/generateCatMessage.ts generateCatMessage TestCases', () => {
  beforeAll(() => {
    mockServer.listen();
  });

  afterEach(() => {
    mockServer.resetHandlers();
  });

  afterAll(() => {
    mockServer.close();
  });

  it('should be able to generated CatMessage', async () => {
    const generatedResponse = await generateCatMessage({
      catId: 'moko',
      userId: 'userId1234567890',
      message: 'こんにちは!',
    });

    expect(generatedResponse.body).toBeInstanceOf(ReadableStream);

    const expected = [
      {
        conversationId: '7fe730ac-5ea9-d01d-0629-568b21f72982',
        message: 'こんにちは🐱',
      },
      {
        conversationId: '7fe730ac-5ea9-d01d-0629-568b21f72982',
        message: 'もこだにゃん🐱',
      },
      {
        conversationId: '7fe730ac-5ea9-d01d-0629-568b21f72982',
        message: 'お話しようにゃん🐱',
      },
      {
        conversationId: '7fe730ac-5ea9-d01d-0629-568b21f72982',
        message: '🐱🐱🐱',
      },
    ];

    const reader =
      generatedResponse.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
    const decoder = new TextDecoder();

    let index = 0;

    const readStream = async (): Promise<undefined> => {
      const { done, value } = await reader.read();

      if (done) {
        return;
      }

      const objects = decoder
        .decode(value)
        .split('\n\n')
        .map((line) => {
          const jsonString = line.trim().split('data: ')[1];
          try {
            const parsedJson = JSON.parse(jsonString) as unknown;

            return isGenerateCatMessageResponse(parsedJson) ? parsedJson : null;
          } catch {
            return null;
          }
        })
        .filter(Boolean) as GenerateCatMessageResponse[];

      for (const object of objects) {
        expect(object).toStrictEqual(expected[index]);
        index++;
      }

      await readStream();
    };

    await readStream();

    reader.releaseLock();
  }, 10000);

  it('should TooManyRequestsError Throw, because unexpected response body', async () => {
    mockServer.use(
      http.post(
        createInternalApiUrl('generateCatMessage'),
        mockGenerateCatMessageTooManyRequestsErrorResponseBody,
      ),
    );

    const dto = {
      catId: 'moko',
      userId: 'userId1234567890',
      message: 'ねこ!',
    } as const;

    await expect(generateCatMessage(dto)).rejects.toThrow(TooManyRequestsError);
  });
});

結論、Jestでは上手く動作しなかった。

以下はJestの実行結果だが A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them. という警告メッセージが出ている。

テストコードのアサーションは問題ないがJestが終了しないという状態になってしまっていてGitHubActions上ではタイムアウト扱いになってしまう。

> ai-cat-frontend@0.1.0 test
> jest

 PASS  src/features/__tests__/cat/isCatId.spec.ts
 PASS  src/features/__tests__/cat/extractCatNameById.spec.ts
 PASS  src/app/chat/_components/ChatContent/__tests__/ChatHeader.spec.tsx
 PASS  src/api/client/__tests__/generateCatMessage.spec.ts
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.

Test Suites: 4 passed, 4 total
Tests:       9 passed, 9 total
Snapshots:   0 total
Time:        3.723 s, estimated 4 s
Ran all test suites
keitaknkeitakn

ちなみに beforeAll() 内で実行している mockServer.listen(); をコメントアウトして2つ目のテストをスキップするようにしたら無事テストが成功する。

その為、現在は以下のような形で一時的に異常系のテストと beforeAll を無効化している。

/**
 * @jest-environment node
 */
import { generateCatMessage } from '@/api/client/generateCatMessage';
import { TooManyRequestsError } from '@/api/errors';
import {
  isGenerateCatMessageResponse,
  type GenerateCatMessageResponse,
} from '@/features';
import { createInternalApiUrl } from '@/features/url';
import {
  mockGenerateCatMessage,
  mockGenerateCatMessageTooManyRequestsErrorResponseBody,
} from '@/mocks';
import { http } from 'msw';
import { setupServer } from 'msw/node';

const mockHandlers = [
  http.post(createInternalApiUrl('generateCatMessage'), mockGenerateCatMessage),
];

const mockServer = setupServer(...mockHandlers);

// eslint-disable-next-line
describe('src/api/client/generateCatMessage.ts generateCatMessage TestCases', () => {
  // TODO これがあるとJestが正常終了しない問題があるので解決するまでコメントアウト
  // beforeAll(() => {
  //   mockServer.listen();
  // });

  afterEach(() => {
    mockServer.resetHandlers();
  });

  afterAll(() => {
    mockServer.close();
  });

  it('should be able to generated CatMessage', async () => {
    const generatedResponse = await generateCatMessage({
      catId: 'moko',
      userId: 'userId1234567890',
      message: 'こんにちは!',
    });

    expect(generatedResponse.body).toBeInstanceOf(ReadableStream);

    const expected = [
      {
        conversationId: '7fe730ac-5ea9-d01d-0629-568b21f72982',
        message: 'こんにちは🐱',
      },
      {
        conversationId: '7fe730ac-5ea9-d01d-0629-568b21f72982',
        message: 'もこだにゃん🐱',
      },
      {
        conversationId: '7fe730ac-5ea9-d01d-0629-568b21f72982',
        message: 'お話しようにゃん🐱',
      },
      {
        conversationId: '7fe730ac-5ea9-d01d-0629-568b21f72982',
        message: '🐱🐱🐱',
      },
    ];

    const reader =
      generatedResponse.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
    const decoder = new TextDecoder();

    let index = 0;

    const readStream = async (): Promise<undefined> => {
      const { done, value } = await reader.read();

      if (done) {
        return;
      }

      const objects = decoder
        .decode(value)
        .split('\n\n')
        .map((line) => {
          const jsonString = line.trim().split('data: ')[1];
          try {
            const parsedJson = JSON.parse(jsonString) as unknown;

            return isGenerateCatMessageResponse(parsedJson) ? parsedJson : null;
          } catch {
            return null;
          }
        })
        .filter(Boolean) as GenerateCatMessageResponse[];

      for (const object of objects) {
        expect(object).toStrictEqual(expected[index]);
        index++;
      }

      await readStream();
    };

    await readStream();

    reader.releaseLock();
  }, 10000);

  // TODO テストが通るがJestが正常終了しない問題があるので解決するまでスキップ
  it.skip('should TooManyRequestsError Throw, because unexpected response body', async () => {
    mockServer.use(
      http.post(
        createInternalApiUrl('generateCatMessage'),
        mockGenerateCatMessageTooManyRequestsErrorResponseBody,
      ),
    );

    const dto = {
      catId: 'moko',
      userId: 'userId1234567890',
      message: 'ねこ!',
    } as const;

    await expect(generateCatMessage(dto)).rejects.toThrow(TooManyRequestsError);
  });
});
keitaknkeitakn

現時点(2023年11月11日)での結論

https://zenn.dev/link/comments/8fee3e5e89ae6d の問題があるのでJest内でStreamingを使ったテストが上手く動作しない。

しかしStorybook上や普通にJSONを返すだけのAPIのテストは問題なく動作する。

私の書き方が悪い可能性もあるがドキュメントを見る限り listen(), .resetHandlers(), .close() あたりのAPIは使い方が変わっていないように思える。

また時間がある時に調査する。

https://mswjs.io/docs/api/setup-server/

keitaknkeitakn

msw の作者から返信が来た。

https://github.com/mswjs/msw/issues/1952#issuecomment-1902610493

解決策を提案して頂いたが、この通りやっても動作しなかった。

コメントの最後のほうにあるこの部分。

I understand your frustration with things not working as you expect. MSW by itself doesn't do anything with streams. It doesn't do anything with fetch, requests, or responses. All those are standard APIs used by you and your test/development environment. It so happens that some tools are rather archaic and rely on polyfills for things that have been standard and shipping in both browser and Node.js for years. Those tools bring you down. Migrate from those tools, please.

参考リンク等を合わせて読むと要するにJestからVitestに乗り換えたほうが良いという話。

確かにJestの設定は複雑化してトラブルも多いのでこの機会にVitestに乗り換える事にする。

keitaknkeitakn

Vitestに乗り換え(問題解決)

Vitestへの乗り換えは結構簡単だった。

詳しくは以下のPRを参照。

https://github.com/nekochans/ai-cat-frontend/pull/78

これでテストが正常終了しない問題も解決。したので無事MSW(Mock Service Worker)のアップグレードは完了。

このスクラップは3ヶ月前にクローズされました