技術メモ

日々学んだことのメモを残します。

Vue3 + TypeScript + MSWの試しメモ(2)

1. はじめに

前回はVueのプロジェクトを立ち上げて、そこにMSWを導入するところまで行いました。

今回はサンプルアプリを元に、MSWでモックAPIを実装していきたいと思います。

2. サンプルアプリの内容

MSWを用いるサンプルアプリとしてTodoアプリを用います。アプリの仕様は以下の通りです。

  • Todoの一覧を取得し表示(GET)
  • Todoの追加(POST)
  • API接続中に「Loading」と表示

サンプルのTodoアプリ

3. モックAPIの作成

最初にモックAPIを実装していきます。

3.1. モックデータの用意

Todoのモックデータを用意します。モックデータのファイルもmockフォルダの配下にまとめておきます。 src/mock/data/todoMockData.json

[
  {
    "id": 1,
    "title": "Todo-1"
  },
  {
    "id": 2,
    "title": "Todo-2"
  },
  {
    "id": 3,
    "title": "Todo-3"
  }
]

idとタイトルだけのシンプルなjsonです。

3.2. handlerの実装

次にMSWのリクエストハンドラーを実装していきます。Todoの一覧を返すGETと、Todoに追加するPOSTのハンドラーを追加します。

3.2.1. ハンドラーの定義

今回はREST APIで作成します。

src/mock/handler.ts

import { rest } from "msw";

export const handlers = [
  // GETハンドラー
  rest.get('/todos', null),

  // POSTハンドラー
  rest.post('/todos', null),
]

3.2.2. レスポンスリゾルバの実装

APIのリクエストを処理するために、HTTPメソッドとパスの定義が完了しました。次にget、postのnullになっている第二引数にモック化したレスポンスを返す関数を定義します。

src/mock/handler.ts

import { rest } from "msw";
import todosMockData from "./data/todosMockData.json" // モックデータ

export const handlers = [
  // GETハンドラー
  rest.get('/todos', (req, res, ctx) => {
    // 
    return res(
      ctx.delay(2000), // レスポンスを2秒遅らせる
      ctx.status(200), // 200のステータスコードを設定
      ctx.json(todosMockData) // モックデータをレスポンスボディに設定
    )
  }),
  // POSTハンドラー
  rest.post('/todos', async(req, res, ctx) => {
    const { todo } = await req.json(); // 入力したTodoの文字列取得
    return res(
      ctx.status(201), 201のステータスコードを設定
      ctx.json({ 
        id: Math.random(), 
        title: todo,
      })
    )
  })
]

レスポンスを返す関数(レスポンスリゾルバ)ではreq, res, ctxを引数にとります。
この中でctxはモックのレスポンスを作成するためのResponse transformerが含まれています。これを使用することで、レスポンスのステータスコードやヘッダ、ボディなどを設定することができたり、レスポンスを遅延させるdelayなども設定することができます。今回はAPI接続時のローディングも確認したいので、delayを加えました。

3.2.3. TypeScriptの適用

最後に各ハンドラーにTypeScriptを適用します。 src/mock/handler.ts

import { rest } from "msw";
import todosMockData from "./data/todosMockData.json"
// Todoの型をApp.vueで定義しているのでimport
import type { Todo as postResponseBody } from "../App.vue"

interface Todo {
  todo: string
}
export const handlers = [
  // GETハンドラー
  rest.get<never, never, postResponseBody[]>('/todos', (req, res, ctx) => {
    return res(
      ctx.delay(2000),
      ctx.status(200),
      ctx.json(todosMockData)
    )
  }),
  // POSTハンドラー
  rest.post<Todo, never, postResponseBody>('/todos', async(req, res, ctx) => {
    const { todo } = await req.json<Todo>();
    return res(
      ctx.status(201),
      ctx.json({ 
        id: Math.random(),
        title: todo,
      })
    )
  })
]

MSWのハンドラーにはRequest Body、Request Params, Response Bodyの型を定義します。
以下の記事を参考に記述を行いましたが、最近のバージョンではRequest ParamsとResponse Bodyが逆になっているので注意が必要です。(Discussionsにその旨の記載があります。)

以上でモックAPIの作成は完了です。

4. Vueコンポーネントの作成

Vueコンポーネントは以下の通りです。普通のWeb APIに接続するときと同様の記述になっていますので、説明は省略します。
Todoの型定義はsrc/mock/handler.tsでも使用するのでexportしています。

src/App.vue

<template>
  <!-- API通信中はLoadingを表示 -->
  <template v-if="apiLoading">
    <div>Loading...</div>
  </template>
  <template v-else>
    <ul v-if="todos.length > 0">
      <template v-for="todo in todos">
        <li>{{ todo.title }}</li>
      </template>
    </ul>
  </template>
  <form @submit.prevent="handleSubmit">
    <input type="text" v-model="inputValue" />
    <button type="submit">追加</button>
  </form>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
export type Todo = {
  id: number;
  title: string;
};

const apiLoading = ref<Boolean>(false);
const todos = ref<Todo[]>([]);
const inputValue = ref<string>("");

onMounted(() => {
  apiLoading.value = true;
  fetch("/todos")
    .then((res) => {
      if (!res.ok) {
        throw new Error(res.statusText);
      } else {
        return res.json().then((data: Todo[]) => {
          todos.value = data;
        });
      }
    })
    .catch((error) => {
      console.log(error);
    })
    .finally(() => {
      apiLoading.value = false;
    });
});

const handleSubmit = () => {
  apiLoading.value = true;
  fetch("/todos", {
    method: "POST",
    body: JSON.stringify({
      todo: inputValue.value,
    }),
  })
    .then((res) => {
      if (!res.ok) {
        throw new Error(res.statusText);
      } else {
        return res.json().then((data: Todo) => {
          todos.value = [...todos.value, data];
        });
      }
    })
    .catch((error) => {
      console.log(error);
    })
    .finally(() => {
      apiLoading.value = false;
    });
};
</script>

5. 終わりに

MSWを使ってモックAPIを作成してみましたが、スムーズに実装することができました。
実際の開発で使用する際には、以下の内容の画面表示を確認できるようにモックAPIを作成するルールにすれば、画面実装時の考慮漏れが防げると思いました。

  • 正常時のレスポンス
  • 異常時のレスポンス
  • レスポンスとして受け取った配列が空の場合
  • ネットワークエラーの場合(参考:networkError)

"レスポンスとして受け取った配列が空の場合"の画面表示は、そもそものデザインの段階で考慮されてないといったこともあるので、モックAPIで確認できるようにしておけば、早い段階で気づくことができます。
またネットワークエラーは再現がしづらいので、簡単に確認ができるのは便利だと思いました。

6. 参考

Cypressで特定の要素にドラッグ&ドロップする方法

はじめに

Cyressである要素を別の要素領域内にドラッグ&ドロップする操作を行いたいと思い、そのやり方を調べました。

今回のサンプルアプリ

今回は下記のサイトを元に、Cypressで動かすVueアプリを実装しました。

下図は実装後の画面です。

ドラッグ&ドロップのサンプルアプリ

各Category内の「ProductA」「ProductB」などがドラッグの対象となっており、ドラッグした要素を別のCategory領域にドロップすると、移動させることができます。
今回はCategoryA内の「ProductA」をCategoryBにドラッグ&ドロップで移動させる操作を、Cypressで行うようにします。

ProductAをCategoryBに移動

実装方法

結論から先に書くと、以下のコードで実現することができます。ProductA、CategoryBのdiv要素にそれぞれdata-cy属性を追加し名称を設定しています。

describe('ドラッグ&ドロップ操作の検証', () => {
  beforeEach(() => {
    cy.visit('http://localhost:XXXX/')
  })
  it("ProductAをCategoryAからCategoryBに移動させる", () => {
    const dataTransfer = new DataTransfer();
    
    // ProductAをドラッグ
    cy.get("[data-cy='ProductA']")
      .trigger("dragstart", { dataTransfer });

    // CategoryBにドロップ。
    cy.get("[data-cy='Category_B']")
      .trigger("drop", { dataTransfer });
  });
});

引っかかったところ

最初に"dataTransfer"をtriggerのオプションに指定せず、実行していました。

// ProductAをドラッグ
cy.get("[data-cy='ProductA']")
    .trigger("dragstart");

するとCypress上で下図のエラーで落ちました。

Cypressのエラー画面

"effectAllowed"を設定するように指示されています。effetctedAllowはDataTransferのプロパティです。

Cypress内部でDataTransferオブジェクトを使用するようなのでdragstart、dropの各イベントのtriggerのオプションにDataTransferオブジェクトを渡すように設定すると、エラーが起きずドラッグ&ドロップの操作が通りました。

余談

Cypressのドキュメントのrecipesドラッグ&ドロップのE2Eテストのサンプルが記載されています。

このサンプルでは、mousemoveイベントでx,yの移動距離をセットしてドラッグ&ドロップを行う仕様でした。

function movePiece (number, x, y) {
      cy.get(`.piece-${number}`)
      .trigger('mousedown', { which: 1 })
      .trigger('mousemove', { clientX: x, clientY: y })
      .trigger('mouseup', { force: true })
    }

今回の用途は特定の要素領域にドロップしたかったので参考にはしなかったですが、スライダー形式のUIであればこれを使えば良さそうです。

参考

Vue3 + TypeScript + MSWの試しメモ(1)

はじめに

最近MSW(MockServiceWorker)の話題をよく見かけるので、自分が関わっているプロダクトで利用できそうか試しに使ってみました。 今回の記事ではMSWを実際に利用するまでの環境構築について記載しています。

MSWとは

名前の通りServiceWorkerを使用しており、クライアントからのAPIリクエストをインターセプトして返してくれるモックAPIです。このを見ると動作イメージがしやすいかと思います。
ServiceWorker上で返してくれるので、別途モックサーバーを立てることなく、すぐに用意できるところがメリットの1つとして挙げられるようです。実際に簡単にモックAPIを試すことができました。

試したバージョン情報

  • Vue:3.2.45
  • MSW: 1.0.0

環境構築

Vueの環境構築

Vue3のドキュメントに掲載されている方法で環境構築していきます。

npm init vue@latest

今回はTypeScriptを利用するのでTypeScriptだけ"Yes"にしました。

✔ Project name: … vue-msw
✔ Add TypeScript? … No / Yes
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes
✔ Add Pinia for state management? … No / Yes
✔ Add Vitest for Unit Testing? … No / Yes
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … No / Yes

Vueプロジェクトが生成されたら、起動して画面を確認します。

cd vue-msw
npm install
npm run dev

起動後の画面
起動後の画面

MSWの導入

作成したVueプロジェクトに、MSWを導入していきます。
こちらもMSWのインストール手順を元に進めていきます。

npm install msw --save-dev

次にブラウザでのセットアップ手順を元に、MSWを起動できるところまで作業します。

mockServiceWorker.jsの用意

MSWで使用するServiceWorkerの起動スクリプトファイルを用意します。これは以下のコマンドを実行することで自動生成されます。Vueの場合はpublicフォルダ配下に置く必要があるため、下記のコマンドで実行します。
参考:Browser Setup

npx msw init public/ --save

実行が完了すると、public配下にmockServiceWorker.jsが生成されます。またpackage.jsonに下記の内容が追記されます。

"msw": {
    "workerDirectory": "public"
}

この記述があることで、mswのインストールを行うたびに指定されたディレクトリ内のmockServiceWorker.jsの内容を生成、更新してくれます。なのでライブラリのバージョンアップ時もこのjsファイルに何か手を加えたりなどする必要はないようです。 mockServiceWorker.jsの内容が古い場合は、コンソールでエラーを吐いてくれるみたいです。
参考:Why do I see the "Detected outdated Service Worker" error in my console?

browser.tsの用意

src/mock/browser.tsファイル内で、ServiceWrokerのインスタンス生成を行う記述を書いておきます。インスタンスの生成にはmswが提供しているsetupWorkerを使用します。今回はVueからモックAPIを利用するのでsetupWorkerを使用しましたが、JestなどNodeから利用する場合はsetupServerを使うようです。
参考:Configure server

setupWorkerの引数にモックAPIのハンドラー設定を渡します。

import { setupWorker } from 'msw'

export const worker = setupWorker();

参考:Configure worker

MSWの起動設定

アプリケーションのコード内から、src/mock/browser.tsのworkerを呼び出し、mswを起動実行の記述を追加します。
Vue.jsの場合はsrc/main.tsに書きます。

import { createApp } from 'vue'
import App from './App.vue'

+if (process.env.NODE_ENV === 'development') {
+  import('./mocks/browser')
+ .then(({worker}) => {
+    worker.start()
+ });
+}

createApp(App).mount('#app')

モックAPIなので本番環境では利用しないため(公式ドキュメントにも非推奨と記載)、開発モードのみMSWが起動するようにします。

起動確認

以上の設定を行い、npm run devでVueアプリケーションを立ち上げると、コンソール画面にMSWの起動完了のメッセージが表示されます。

mockServiceWorker.jsの置き場所について

ドキュメント通りだとmockServiceWorker.jsをpublicフォルダに置く必要がありましたが、今の開発環境の都合上別のフォルダに置きたいと思い、対応方法を調べてみました。
以下のようにworker.start()に参照先のURL設定を追加すると、public配下じゃなくてもmockServiceWorker.jsを起動できるようです。

    worker.start({
      serviceWorker: {
        url: '/example/mockServiceWorker.js'
      }
    })

URL パスは、サーバーのルート ディレクトリからの相対パスです。
参考:serviceWorker

またドキュメントでは、mockServiceWorker.jsはgitにpushしておくことを推奨しています。
ただnpx msw initnpm ciを実行すれば自動生成されるので、それでも良いとも書かれています。個人的にはライブラリ側で自動生成したコードファイルをgitで管理するのは違和感があるため、後者の運用が良いかと思っています。
参考:Should I commit the worker script to Git?

終わりに

MSWを実際に起動するところまで確認ができました。
次の記事では実際にAPIのハンドラーを書いていき、VueアプリケーションからモックAPIに接続する部分を書いていきたいと思います。

参考

warning: DevTools failed to load SourceMap の対処

React + TypeSctipt + Webpackの環境構築を試しており、webpack-dev-serverを導入した際に、 DevTools failed to load SourceMapのWarningが出てきたので、動作的には問題ないですけど気になるので、その対処方法をメモしておきます。

解決のきっかけはここの issue のコメントにありました。「webpack.config.jsのdevtoolにinline-source-mapを入れればいけるよ」と。

確かにソースマップが見つからないと言われているので、devtoolに何かしらの値をセットし、ソースマップファイルを生成すればよかったですね。 今回はissueのコメント通りに、devtoolにinline-source-mapを指定しました。これでwarningが消えました。

module.exports = {
  // 省略
  devServer: {
    contentBase: `${__dirname}/dist`,
    port: 8080,
    open: true
  },
  devtool: 'inline-source-map',

};

devtoolにfalsenoneを設定すると、ソースマップファイルは生成されないので、再びwarningが出ます。
ここに devtoolに設定できる値の一覧が掲載されています。環境に応じて、ソースマップの生成方法を選べるみたいです。
またこちらの記事ではもう少し詳しくdevtoolの設定内容について説明してくれています。

useRefについて

現在、以下のDeep Dive into Redux Toolkit with React - Complete Guideを視聴し、React/Redux環境でのTypeScriptの記述方式やReduxToolkitについて学習しています。

www.youtube.com

その中で、ReactHooksの一種であるuseRefがプロジェクト内で使用されており、 よく知らないなと思い調べたのでメモを残しておきます。

先にuseRefが何なのか述べると、useRefというのはDOMへアクセスしたいときか、状態を保持したいときに利用するものらしいです。
後者はuseState管理でもできますが、異なる点は状態が更新されても再レンダリングされないところです。 なので、そういったシチュエーションの場合はuseRefを使用した方が良いことがわかりました。

動画内では下記のようにuseRefを使用しています。これはDOMへアクセスしたいときの使い方です。

const editInput = useRef<HTMLInputElement>(null); // ①

...// 省略

useEffect(() => {
  if (isEditMode) {
    editInput.current.focus(); // ③
  }
}, [isEditMode]);

...// 省略

<form onSubmit={handleUpdate}>
  <label htmlFor="edit-todo">Edit:</label>
  <input
     ref={editInput} // ②
     onChange={handleEditInputChange}
     value={editTodoInput}
  />
  <button type="submit">Update</button>
  <button onClick={handleCancelUpdate}>Cancel</button>
</form>


初期値をnullとしてuseRefを用いた変数を定義します。このとき、この変数自体はinputタグの中に仕込むので、型はHTMLInputElementを設定しておきます。

inputタグに①で定義したeditInputを組み込みます。これでeditInputからDOM操作を行えます。

isEditMode(編集可能な状態)がtrueであれば、editInput.current.focus()でinputタグにアクセスしフォーカスを当てることができます。

公式ドキュメントのでは、インスタンス変数として状態を保持したいときの使用方法の例も掲載されています。 ここではタイマーIDの値を保存するために使用しています。

React Hook Form IE11での構文エラー対処法

React Hook Formを使用していると、IE11を起動すると構文エラーが発生します。

公式ドキュメントでIE11を使用する場合の対応方法が記載されています。

React Hook Formをimportする際に、下記のように変更するとエラー回避されます。

// 変更前
import { useForm } from "react-hook-form";

// 変更後
import { useForm } from 'react-hook-form/dist/react-hook-form.ie11'

webpackを使用している場合は、aliasにimportの設定を追加しておくことで、 変更前の記述のままで使用することができます。
参考: IE11 support ?

  resolve: {
    alias: {
      "react-hook-form": "react-hook-form/dist/react-hook-form.ie11"
    },
  },