Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mock Service Workerについて検証する #1491

Open
KentaHizume opened this issue Jul 23, 2024 · 16 comments
Open

Mock Service Workerについて検証する #1491

KentaHizume opened this issue Jul 23, 2024 · 16 comments
Assignees
Milestone

Comments

@KentaHizume
Copy link
Contributor

KentaHizume commented Jul 23, 2024

概要

Mock Service Worker(MSW)で既存のViteのサーバーオプションを使用したモックと同等の機能を持つモックを実装し、比較する。

詳細

#975 から派生

完了条件

  • Viteのサーバーオプションと比較したメリット、デメリットが明確になること
@KentaHizume KentaHizume added this to the v0.10 milestone Jul 23, 2024
@KentaHizume KentaHizume self-assigned this Jul 23, 2024
@KentaHizume
Copy link
Contributor Author

mswで画像をmockしたい場合の実装方法

msw2系になった際にだいぶ変わったらしい。

https://mswjs.io/docs/migrations/1.x-to-2.x
https://mswjs.io/docs/migrations/1.x-to-2.x#response-resolver-arguments

よく出てくるreq, res, ctxのパターンが非推奨になっているのでこの形のサンプルコードはうまく使えない。

rest.get('/resource', (req, res, ctx) => {})
http.get('/resource', (info) => {})

公式ドキュメント通りに画像用のハンドラーを作成したが、エラーで落ちてしまう。

https://mswjs.io/docs/recipes/responding-with-binary/#browser

import { HttpResponse, http } from 'msw';

export const assetsHandlers = [
  http.get('/api/assets/:assetCode', async ({ params }) => {
    const imageBuffer = await fetch(`/mock/images/${params.assetCode}`).then(
      (response) => response.arrayBuffer(),
    );
    return HttpResponse.arrayBuffer(imageBuffer, {
      status: 200,
      headers: {
        'Content-Type': 'image/png',
      },
    });
  }),
];
[vite] Internal server error: Failed to parse source for import analysis because the content contains invalid JS syntax. If you are using 
JSX, make sure to name the file with the .jsx or .tsx extension.
  Plugin: vite:import-analysis
  File: dressca-frontend/apps/admin/mock/images/0e557e96bc054f10bc91c27405a83e85
  • assetIncludeオプション

https://vitejs.dev/config/shared-options.html#assetsinclude
拡張子がないと自動で静的アセットとして認識してくれないようなので、vite.config.tsに下記を追加する
assetsInclude: ['./mock/images/**'],
実行時エラーは起きなくなるが、画像は表示されない。

  • 拡張子付与
    ファイル自体に拡張子を付与し、下記のように指定すると正常に画像が表示される。
    しかし理由は不明…。
const imageBuffer = await fetch(
      `/mock/images/${params.assetCode}.png`,
    ).then((response) => response.arrayBuffer());

下記を設定しておかないとモックを作っていない箇所に対して警告が大量に出るので入れておく

  worker.start({
    onUnhandledRequest: 'bypass',
  });

@KentaHizume
Copy link
Contributor Author

KentaHizume commented Jul 23, 2024

mockServiceWorker.jsを本番ではpublicフォルダに置きたくない

/public 配下にmockServiceWorker.jsを置く必要があるが、
プロダクションビルドの際は削除したほうがベター。

バンドルされるコードに関しては、
依存関係を解決する際にmodeを呼んで外すので、
こちらとは違う問題だった。

viteのpluginを使用する方法がある。

https://tech.makeshop.co.jp/entry/frontend/msw
mswjs/msw#712 (reply in thread)
https://qiita.com/NanimonoDaemon/items/26e075d20451bd2a00ae

下記の実装でそれらしき動きをするようになったが、
このフックが使用するべきフックなのかの説明がついていない。

  • resolveId の実装の必要性
  • buildStartではなくrenderStartである理由

https://rollupjs.org/plugin-development/#build-hooks

function excludeMsw(): Plugin {
  return {
    name: 'exclude-msw',
    resolveId: (source) => {
      return source === 'virtual-module' ? source : null;
    },
    renderStart() {
      const outDir = './public';
      const msWorker = path.resolve(outDir, 'mockServiceWorker.js');
      fs.rm(msWorker, () => console.log(`Deleted ${msWorker}`));
    },
  };
}

resolveIdの分岐

元々デフォルトではresolveIdがresolveIdを呼び出し無限ループになるので、
それを避けるように手を加える必要があったらしい。
しかし、Vite5(rollup 4)でskipSelfオプションがデフォルトでtrueになったのでループにならなくなった。
よってなくても問題ない。

https://zenn.dev/irico/articles/0a665d149047b3#%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%81%AEthis.resolve-skipself%E3%81%8C-%E3%83%87%E3%83%95%E3%82%A9%E3%83%AB%E3%83%88true%E3%81%AB%E5%A4%89%E6%9B%B4

buildStart⇒build-hooks
renderStart⇒output-generation-hooks
で、renderStartはビルド後、バンドル前に位置する。
https://zenn.dev/keita_hino/scraps/3c73cdc2bc446c

@KentaHizume
Copy link
Contributor Author

productionコードに含まれると判断されるので、
lintでdevDependenciesではなくdependenciesに入れなさいというエラーが出る違和感のある挙動をする。

  1:1  error  'msw' should be listed in the project's dependencies, not devDependencies  import/no-extraneous-dependencies

@KentaHizume
Copy link
Contributor Author

KentaHizume commented Aug 19, 2024

mswのワーカープロセスが立ち上がるよりも先にアプリがレンダリングされるとエラーが出てしまう。
image

ワーカーが起動するまでレンダリングを延期するように設定する必要がある?

https://mswjs.io/docs/integrations/browser#conditionally-enable-mocking

下記のように修正。
しかし公式docの書き方通りだとlintエラーが出てしまう。
きれいにする方法を探したい。

  14:3  error  Async function 'enableMocking' expected no return value  consistent-return
async function enableMocking() {
  if (import.meta.env.MODE !== 'mock') {
    return;
  }
  const { worker } = await import('../mock/browser');
  // eslint-disable-next-line consistent-return
  return worker.start({
    onUnhandledRequest: 'bypass',
  });
}

enableMocking().then(() => {
  const app = createApp(App);
  const pinia = createPinia();

  app.use(pinia);
  app.use(router);

  app.use(errorHandlerPlugin);

  authenticationGuard(router);

  app.mount('#app');
});

アドホックだが、下記でlintエラーにはならなくなる。

async function enableMocking(): Promise<ServiceWorkerRegistration | undefined> {
  if (import.meta.env.MODE !== 'mock') {
    return undefined;
  }
  const { worker } = await import('../mock/browser');
  return worker.start({
    onUnhandledRequest: 'bypass',
  });
}

@KentaHizume
Copy link
Contributor Author

Viteのサーバーオプションとの比較

ハンドラー

  • MSWのほうがかなりラク。
  • POST、GET、PUT、DELETEを実装する場合、下記のような差がある。
  • クエリパラメータの処理がやりやすい。

MSW

export const catalogItemsHandlers = [
  http.get('/api/catalog-items', () => {
    return HttpResponse.json(pagedListCatalogItem, { status: 200 });
  }),
  http.post('/api/catalog-items', () => {
    return new HttpResponse(null, { status: 201 });
  }),
  http.get<
    GetCatalogItemParams,
    never,
    never,
    '/api/catalog-items/:catalogItemId'
  >('/api/catalog-items/:catalogItemId', async ({ params }) => {
    const { catalogItemId } = params;
    const item = catalogItems.find(
      (items) => items.id === Number(catalogItemId),
    );
    return HttpResponse.json(item, { status: 200 });
  }),
  http.delete('/api/catalog-items/:catalogItemId', () => {
    return new HttpResponse(null, { status: 204 });
  }),
  http.put('/api/catalog-items/:catalogItemId', () => {
    return new HttpResponse(null, { status: 204 });
  }),
];

Vite

export const basketApiMock = (middlewares: Connect.Server) => {
  middlewares.use(`/${base}/basket-items`, (req, res) => {
    if (req.method === 'GET') {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.write(JSON.stringify(basket));
      res.end();
      return;
    }

    if (req.method === 'DELETE') {
      const catalogItemId = Number(req.url?.substring(1));
      res.writeHead(204, { 'Content-Type': 'application/json' });
      basket.basketItems = basket.basketItems?.filter(
        (item) => item.catalogItemId !== catalogItemId,
      );

      calcBasketAccount();
      res.end();
      return;
    }

    let body = '';
    req.on('data', (chunk) => {
      body += chunk;
    });
    req.on('end', () => {
      if (req.method === 'POST') {
        const dto: PostBasketItemsRequest = JSON.parse(body);
        const target = basket.basketItems?.filter(
          (item) => item.catalogItemId === dto.catalogItemId,
        );
        if (target) {
          if (target && target.length === 0) {
            const addBasketItem = mockBasketItems.find(
              (item) => item.catalogItemId === dto.catalogItemId,
            );
            if (typeof addBasketItem !== 'undefined') {
              addBasketItem.quantity = dto.addedQuantity ?? 0;
              basket.basketItems?.push(addBasketItem);
            }
          } else {
            target[0].quantity += dto.addedQuantity ?? 0;
          }
        }
        calcBasketAccount();
        res.writeHead(201, { 'Content-Type': 'application/json' });
        res.end();
        return;
      }

      if (req.method === 'PUT') {
        const dto: PutBasketItemsRequest[] = JSON.parse(body);
        dto.forEach((putBasketItem) => {
          const target = basket.basketItems?.filter(
            (item) => item.catalogItemId === putBasketItem.catalogItemId,
          );
          if (target) {
            if (target.length === 0) {
              res.writeHead(400, { 'Content-Type': 'application/json' });
              res.end();
            } else {
              target[0].quantity = putBasketItem.quantity;
            }
          }
        });

        calcBasketAccount();
        res.writeHead(204, { 'Content-Type': 'application/json' });
        res.end();
      }
    });
  });
};

クエリパラメータの読み込み

export const assetsHandlers = [
  http.get('/api/assets/:assetCode', async ({ params }) => {
    const imageBuffer = await fetch(
      `/mock/images/${params.assetCode}.png`,
    ).then((response) => response.arrayBuffer());
    return HttpResponse.arrayBuffer(imageBuffer, {
      status: 200,
      headers: {
        'Content-Type': 'image/png',
      },
    });
  }),
];

@KentaHizume
Copy link
Contributor Author

問題点

依存するパッケージが増えること以外に、下記の問題点が判明している。

mockServiceWorker.jsをprodビルド時に削除する必要がある

  • vite.config.ts に下記の処理が必要
 function excludeMsw(): Plugin {
  return {
    name: 'exclude-msw',
    resolveId: (source) => {
      return source === 'virtual-module' ? source : null;
    },
    renderStart() {
      const outDir = './public';
      const msWorker = path.resolve(outDir, 'mockServiceWorker.js');
      // eslint-disable-next-line no-console
      fs.rm(msWorker, () => console.log(`Deleted ${msWorker}`));
    },
  };
}

    plugins: mode === 'prod' ? [...plugins, excludeMsw()] : plugins,

ワーカーの起動とアプリのレンダリングの順序の制御が必要

  • main.tsに下記の処理が必要
async function enableMocking(): Promise<ServiceWorkerRegistration | undefined> {
  if (import.meta.env.MODE !== 'mock') {
    return undefined;
  }
  const { worker } = await import('../mock/browser');
  return worker.start({
    onUnhandledRequest: 'bypass',
  });
}

enableMocking().then(() => {
  const app = createApp(App);
  const pinia = createPinia();

  app.use(pinia);
  app.use(router);

  app.use(errorHandlerPlugin);

  authenticationGuard(router);

  app.mount('#app');
});

@KentaHizume
Copy link
Contributor Author

その他

Vitest2系で、experimentalだが、コンポーネントテスト用にブラウザーモードという機能が追加された。
この@vitest/browserがmswに依存関係を持っているので、今後にも期待できるはず。

@KentaHizume
Copy link
Contributor Author

VitestのVSCODE拡張機能

Vitest公式のプラグインが存在し、テストタブから操作できる。

https://marketplace.visualstudio.com/items?itemName=vitest.explorer

image

@KentaHizume
Copy link
Contributor Author

テストケース作成時の注意点

Pinia

各ケースの開始時にPiniaを初期化しておく必要がある。
vuejs/pinia#971

初期化しておかないと、エラー"getActivePinia()" was called but there was no active Pinia. が発生する。

beforeEach(() => {
    setActivePinia(createPinia())
})

vue-router

vue-routerを使う箇所をテストする場合、
appのマウント時にrouterを渡し、routerの準備ができるのを待つ必要がある。

https://test-utils.vuejs.org/guide/advanced/vue-router#Using-a-Real-Router

未対応の場合はrouterを使用する箇所でエラーになる。

[Vue warn]: Failed to resolve component: router-link
import { router } from "@/router"
    const wrapper = mount(App,{global:{plugins: [router]}});
    await router.isReady()

@KentaHizume
Copy link
Contributor Author

ブラウザーモードと併用する場合、nodeのほうを使うかbrowserのほうを使うかを動的に設定する必要がある。

@KentaHizume
Copy link
Contributor Author

KentaHizume commented Aug 27, 2024

↓のissueに起票

コンポーネント外でのstoreの使用

都度関数内でuseStoreを呼び出さないとエラーになるように見える。

Error: [🍍]: "getActivePinia()" was called but there was no active Pinia. Are you trying to use a 
store before calling "app.use(pinia)"?

app.use(pinia)によってpiniaプラグインがインストールされる前にuseStoreがコールされると、
importの順序に依存して落ちてしまう、らしい。

これが正しければ、既存のserviceクラスの実装を見直したほうがいいのかもしれない。

import { createRouter } from 'vue-router'
const router = createRouter({
  // ...
})

// ❌ Depending on the order of imports this will fail
const store = useStore()

router.beforeEach((to, from, next) => {
  // we wanted to use the store here
  if (store.isLoggedIn) next()
  else next('/login')
})

router.beforeEach((to) => {
  // ✅ This will work because the router starts its navigation after
  // the router is installed and pinia will be installed too
  const store = useStore()

  if (to.meta.requiresAuth && !store.isLoggedIn) return '/login'
})

https://pinia.vuejs.org/core-concepts/outside-component-usage.html#Single-Page-Applications
https://stackoverflow.com/questions/76267091/vue-3-component-tests-with-pinia-is-giving-error-getactivepinia-was-called-bu

動作しない

const catalogStore = useCatalogStore();

export async function fetchCategoriesAndBrands() {
  await catalogStore.fetchCategories();
  await catalogStore.fetchBrands();
}

export async function fetchItems(categoryId: number, brandsId: number) {
  await catalogStore.fetchItems(categoryId, brandsId);
}

動作する

export async function fetchCategoriesAndBrands() {
  const catalogStore = useCatalogStore();
  await catalogStore.fetchCategories();
  await catalogStore.fetchBrands();
}

export async function fetchItems(categoryId: number, brandsId: number) {
  const catalogStore = useCatalogStore();
  await catalogStore.fetchItems(categoryId, brandsId);
}

@KentaHizume KentaHizume modified the milestones: v0.10, v1.0 Aug 28, 2024
@KentaHizume
Copy link
Contributor Author

ローカルに別のcloneを行ったところ謎のエラーでviteの開発サーバーが上がらなくなったが、
msw2.4.0のリリースにバグがあるらしい?(npm ciではなくnpm installをしている弊害)
2.3.5に戻したところ起動する。

@KentaHizume
Copy link
Contributor Author

KentaHizume commented Aug 30, 2024

非同期操作

非同期操作をテストする場合にはnextTick()flushPromises()を使う必要があり、
Vueの外部の非同期操作(APIコールなど)を待つにはflushPromises()を使わないといけない。

https://test--utils-vuejs-org.translate.goog/guide/advanced/async-suspense?_x_tr_sl=en&_x_tr_tl=ja&_x_tr_hl=ja&_x_tr_pto=sc#Asynchronous-Behavior

APIコールするたびに呼ばなければ順序がおかしくなるので忘れないようにするのが結構しんどい。
何か警告してほしい。

describe('アイテム追加画面', () => {
  it('アイテムを追加できる', async () => {
    const pinia = createPinia();
    setActivePinia(pinia);
    const wrapper = mount(ItemsAddView, { global: { plugins: [pinia,router] } });
    await flushPromises()
    expect(wrapper.html()).toContain('カタログアイテム追加');
    await wrapper.find('button').trigger('click');
    await flushPromises()
    expect(wrapper.html()).toContain('カタログアイテムを追加しました。');
  });
});

@KentaHizume
Copy link
Contributor Author

KentaHizume commented Sep 10, 2024

関数単体テスト

Vitestを使用、helper関数などが対象
ロジックを責務とする関数か
UIを責務とする関数かで変わってくる、
ロジックを責務とする関数は単体テストしたほうがよいが、
UIを責務とする関数は単体テストしてもあまりうまみがない。

実装例

コンポーネント単体テストでcurrencyHelperをimportしているので、間接的にテストされている

UIコンポーネント単体テスト

Vitestを使用
UIコンポーネントには粒度がある
(最小~中粒度~ページ~アプリケーション)

実装例

  • CarouselSlider.spec.ts
  • BasketItem.spec.ts

UIコンポーネント結合テスト

Vitestを使用
router、storeといった外部プラグインに依存する処理
APIコールを必要とする処理
Grobal UI(Toastなど)のテスト
フォームのバリデーション

E2Eテスト

Cypressを使用
jsdomではテストできない観点は何か?

  • ブラウザ固有の機能(Cookie、LocalStorage)
  • データアクセス(RDB、NoSQL)
  • スクロール位置に依存する処理
  • 画面サイズに依存する処理

@KentaHizume
Copy link
Contributor Author

KentaHizume commented Sep 11, 2024

やりたいこと

最終的には、フロントエンドの自動テストのガイドラインを作りたい

  • ロジックが複雑になる傾向があるもの
    ⇒自動テストの効果あり
  • コンポーネントの粒度が粗いもの(いろんなコンポーネントと結合しているもの)
    ⇒自動テストするのにコストがかかる

ことを鑑みつつ、
各フォルダ配下について、
関数単体テスト、UIコンポーネント単体テスト、UIコンポーネント結合テスト、E2Eテスト、手動テスト
のどこで確認するのがよいのかを考える。

フォルダ 名称
components コンポーネント
router ルーター
stores ストア
services サービス
views 各ページ
shared スクリプト
config ログの設定ファイル
validation バリデーション
api-client APIクライアントの設定
/ アプリケーション(App.vue)

Atomic Designに従う場合のcomponent配下

フォルダ
atoms ボタン、ラベル
molecules 入力フォーム
organisms ヘッダー
templates ワイヤーフレーム
pages デザインカンプ

@KentaHizume
Copy link
Contributor Author

Viteset2.1の新機能

ブラウザーモードで使用することを想定したロケーターAPIなるものが追加された。

https://github.com/vitest-dev/vitest/releases/tag/v2.1.0
https://vitest.dev/guide/browser/locators.html
https://github.com/vitest-dev/vitest-browser-vue

@KentaHizume KentaHizume added 中止状態 現在着手予定がない and removed 中止状態 現在着手予定がない labels Oct 9, 2024
@KentaHizume KentaHizume modified the milestones: v1.0.0, v1.1.0 Nov 19, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant