Closed56

Jest本の第6章をSvelteKitで進めてみる

kazokmrkazokmr

ゴール

Jest本 の 第6章「フロントエンド開発と自動テスト」をSvelteKitで開発して自動テストを行なってみる

動機

Svelte でフロントエンド開発を行ってReactとの違いを把握してみたかったから。

制約

  • 進め方は書籍に従う
  • コンポーネントの分割も可能な限り書籍に従う
    • 終わった後に変えたりはするかも
  • テストはVitestで実行する
    • SvelteKitはVite + Vitest がデフォルトで使えるので
  • Storybookも使う
  • CSSフレームワークにどれを使うかは進めながら考える
    • Chakra UI の Svelte向けは 存在している のでまずは Chakra UI で始めてはみる
    • 個人的には Svelte標準のスタイリングも活用してみたいし、Tailwind CSSも試してみたいので一通り終わった後に試してみる
kazokmrkazokmr

プロジェクトを作成する

SvelteKit の 公式サイト を元にプロジェクトを作成してみる

npm create svelte@latest tax-ui

インタラクション形式でセットアップしていくので、質問は以下のように選択した

  • Which Svelte app template?
    • Skeleton project
  • Add type checking with TypeScript?
    • Yes, using TypeScript syntax
  • Select additional options (use arrow keys/space bar)
    • Add ESLint for code linting
    • Add Prettier for code formatting
    • Add Playwright for browser testing
    • Add Vitest for unit testing

Playwrightはどうしようか考えたけど、終わった後にせっかくなのでE2Eテストも書いてみようかと思ったのでこのタイミングで追加しておくことにした。

その後は以下の指示の通りコマンドを実行した。
※No.2のGitの操作は git initは実行していない。バックエンド開発の時に既に管理していたため。

Next steps:
  1: npm install (or pnpm install, etc)
  2: git init && git add -A && git commit -m "Initial commit" (optional)
  3: npm run dev -- --open

To close the dev server, hit Ctrl-C

依存関係を更新する

SvelteKitのセットアップでインストール済みの依存関係に対して最新版にアップデートした。

この時点での package.json の内容

package.json
{
  "name": "tax-ui",
  "version": "0.0.1",
  "private": true,
  "engines": {
    "node": "18.x"
  },
  "packageManager": "npm@9.6.7",
  "scripts": {
    "dev": "vite dev",
    "build": "vite build",
    "preview": "vite preview",
    "test": "playwright test",
    "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
    "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
    "test:unit": "vitest",
    "lint": "prettier --plugin-search-dir . --check . && eslint .",
    "format": "prettier --plugin-search-dir . --write ."
  },
  "devDependencies": {
    "@playwright/test": "^1.34.1",
    "@sveltejs/adapter-auto": "^2.1.0",
    "@sveltejs/kit": "^1.18.0",
    "@typescript-eslint/eslint-plugin": "^5.59.7",
    "@typescript-eslint/parser": "^5.59.7",
    "eslint": "^8.41.0",
    "eslint-config-prettier": "^8.8.0",
    "eslint-plugin-svelte": "^2.29.0",
    "prettier": "^2.8.8",
    "prettier-plugin-svelte": "^2.10.0",
    "svelte": "^3.59.1",
    "svelte-check": "^3.3.2",
    "tslib": "^2.5.2",
    "typescript": "^5.0.4",
    "vite": "^4.3.8",
    "vitest": "^0.31.1"
  },
  "type": "module"
}
kazokmrkazokmr

Prettierの設定は自分の好み(タブとシングルクォートは使わない)に変えている。
これ以外の tsconfig, vite.config, .eslintrc は特に変更しなくても進められそうなので手をつけない。

NPMプロジェクト(JS/TSのプロジェクト)は初期設定が面倒だと感じていたのでSvelteKitがいい感じにセットアップしてくれるのはありがたい

kazokmrkazokmr

Storybookを導入

以下のコマンドをでStorybookの最新版(7.0.15 7.0.17) をインストールした

npx storybook@latest init

SvelteKit, Vite, Typescript, ESLintを利用していることを検知して、設定ファイルも更新してくれるので便利。
また サンプルのStoryも .svelte で作成されているので 確認用に削除せずにしばらく残しておく。

インストール後、npm run storybook を実行しサンプルStoryが表示されることを確認した。

kazokmrkazokmr

Storybookをインストールすると devDependenciesに reactreact-dom もインストールされていることを確認した。背景は不明だが この2つをアンインストールしてもStorybookが動作するか今回の作業の中で検証してみることにする。

どうやら Storybookの各種アドオンの依存関係として使われている模様。StorybookのUIがReactを使っているのかな?

kazokmrkazokmr

ChakraUI をインストール

CharkraUI-svelteの サイト の通りインストールし、InputFormコンポーネントとStoryファイルを用意したがStorybookで表示しようとすると以下のエラーが発生した。

TypeError: (0 , import_dash_get.default) is not a function
    at createStyle (http://localhost:6006/node_modules/.cache/.vite-storybook/deps/chakra-ui-svelte.js?v=8ca60f3d:4621:68)
    at update (http://localhost:6006/node_modules/.cache/.vite-storybook/deps/chakra-ui-svelte.js?v=8ca60f3d:4638:23)
    at chakra (http://localhost:6006/node_modules/.cache/.vite-storybook/deps/chakra-ui-svelte.js?v=8ca60f3d:4641:3)
    at Object.mount [as m] (http://localhost:6006/node_modules/.cache/.vite-storybook/deps/chakra-ui-svelte.js?v=8ca60f3d:5700:51)
    at Object.mount [as m] (http://localhost:6006/node_modules/.cache/.vite-storybook/deps/chakra-ui-svelte.js?v=8ca60f3d:5493:24)
    at Object.mount [as m] (http://localhost:6006/node_modules/.cache/.vite-storybook/deps/chakra-ui-svelte.js?v=8ca60f3d:4845:43)
    at Object.mount [as m] (http://localhost:6006/node_modules/.cache/.vite-storybook/deps/chakra-ui-svelte.js?v=8ca60f3d:5899:45)
    at mount_component (http://localhost:6006/node_modules/.cache/.vite-storybook/deps/chunk-KNYZUMSY.js?v=15e2ccf0:1832:24)
    at Object.mount [as m] (http://localhost:6006/node_modules/.cache/.vite-storybook/deps/chakra-ui-svelte.js?v=8ca60f3d:7748:7)
    at mount_component (http://localhost:6006/node_modules/.cache/.vite-storybook/deps/chunk-KNYZUMSY.js?v=15e2ccf0:1832:24)

調査中だが今のところわからない。 わからなければ ChakraUIを一旦諦める

kazokmrkazokmr

公式の導入ページのように ./src/routes/+page.svelte で以下のように書いても表示されなかったので、ChakraUI Svelteの導入は見送る

+page.svelte
<script lang="ts">
  import { ChakraProvider } from "chakra-ui-svelte";
  import InputForm from "$lib/InputForm.svelte";
</script>
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
<ChakraProvider>
  <InputForm />
</ChakraProvider>
kazokmrkazokmr

Bootstrap をインストール

Chakra-UI-Svelte をアンインストールして、Bootstrapを使うことにする。
フレームワークは Sveltestrapにした

npm install sveltestrap でインストール後、app.html の head に Bootstrapをインポートするようにした

app.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <link rel="icon" href="%sveltekit.assets%/favicon.png" />
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.1/dist/css/bootstrap.min.css" />
  <meta name="viewport" content="width=device-width" />
  %sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

./src/lib/ に InputForm.svelte を作成しButonコンポーネントを利用する

InputForm.svelte
<script lang="ts">
  import { Button } from "sveltestrap/src";
</script>

<Button color="primary">Hello</Button>

StorybookでBootstrapを有効にする

./.storybook に preview-head.html を作り、BootstrapとiconのStylesheetを取り込みStorybook上でコンポーネントにBootstrapのスタイルを適用させる

preview-head.html
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.1/dist/css/bootstrap.min.css"
/>
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css"
/>

Sveltestrapのコンポーネントはbutton要素に対して、Bootstrapのスタイルを適用する形になっている模様。このため Bootstrapを適用しなければ通常のHTMLのbutton要素で描画することになる。

あとは InputFormのStoryを作成すればStorybookでBootstrapが適用されたボタンが確認できる。

今回はこれで進める。

kazokmrkazokmr

Sveltestrapを使っていると アプリケーションとして起動(npm run dev) すると以下のようなエラーが出て表示されなかった。(Storybookでは閲覧できる

console
[vite] Error when evaluating SSR module /node_modules/sveltestrap/src/Popover.svelte: failed to import "@popperjs/core/dist/esm/popper"
|- .//tax-ui/node_modules/@popperjs/core/dist/esm/popper.js:1
import { popperGenerator, detectOverflow } from "./createPopper.js";
^^^^^^

SyntaxError: Cannot use import statement outside a module
    at internalCompileFunction (node:internal/vm:73:18)
    at wrapSafe (node:internal/modules/cjs/loader:1176:20)
    at Module._compile (node:internal/modules/cjs/loader:1218:27)
    at Module._extensions..js (node:internal/modules/cjs/loader:1308:10)
    at Module.load (node:internal/modules/cjs/loader:1117:32)
    at Module._load (node:internal/modules/cjs/loader:958:12)
    at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:169:29)
    at ModuleJob.run (node:internal/modules/esm/module_job:194:25)

GitHubのIssueにも上がってた
エラーとなった popperjs のリポジトリが削除されてて別のリポジトリにリダイレクトされたりしている状況なのも何か関係があるかも。

スタイリングで時間を消費したく無いので、出来るところまでコーディングを進めて後で確認する。
その時にもダメだったら 普通にCSSを書くか、Tailwindcssを試してみる

kazokmrkazokmr

+page.svelte などの SveleteKitのRouter用コンポーネントを import Page from "./+page.svelte"; としたら Page で認識してくれた。 ちなみに Pageは自分で付けただけで +page.svelte でも export default は宣言していない。

Page.stories.ts
import Page from "./+page.svelte";
import type { Meta, StoryObj } from "@storybook/svelte";

const meta = {
  component: Page
} satisfies Meta<Page>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Standard:Story = {};
kazokmrkazokmr

SvelteKitのテスト戦略を考え中。

Svelteには ReactのHooksに相当するものがなく、react-testing-library の renderHook() も無いので「6.3.2 フックの動きを確認したい」 のようなテストコードが書けなさそう。なので Tanstac Query の createMutationやcreateQuery (Reactの useMutation、useQueryと同等) のテストも書けなさそうなので、fetch() だけを別モジュールに取り出してテストするしか無く、playwright を使ったE2Eテストなどでカバーする感じかな。

kazokmrkazokmr

Svelte公式の How do I test Svelte apps? を読む。

自分が悩んでいるのは、Unit tests の部分かなと思ったけど、Tanstack Queryも含め Component Tests として 確認しても良いのかなと思った。ただし今回の書籍のように InpurtFormコンポーネントで入力したFormの値をAPIに渡して戻り値をResultコンポーネントで表示するので、Presentationコンポーネントに対するテストになりそうなので、複数コンポーネントを結合したテストというレベルかな。

kazokmrkazokmr

API 呼び出し

まずは、 src/lib/ に fetchを実行するファイルを用意した。
Tanstack Queryはこれを呼び出す Svelte Componentで利用する予定。
また SvelteKit の API Routes はここでは利用せず、一通り完成してから用意してみることにする

calcTax.ts
export type CalcTaxParam = {
  yearsOfService: number
  isDisability: boolean
  isOfficer: boolean
  severancePay: number
}

export type CalcTaxResult = {
  tax: number
}

export const calcTax = async (param: CalcTaxParam) =>
  await fetch("http://localhost:3000/calc-tax", {
    method: "POST",
    headers: {
      "Content-type": "application/json"
    },
    body: JSON.stringify(param)
  });

テストコードは次の通り。 MSWは使っているが 書籍のようなUtility関数は作成していない

calcTax.test.ts
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
import type { CalcTaxParam, CalcTaxResult } from "$lib/fetch/client/calcTax";
import { calcTax } from "$lib/fetch/client/calcTax";
import { setupServer } from "msw/node";
import { rest } from "msw";

// MSWのNodeサーバーのセットアップとクローズ処理
const server = setupServer();
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe("所得税計算APIをコールする", () => {
  test("所得税計算APIを呼び出せる", async () => {

    // Begin
    // mswのHandlerを設定する
    server.use(
      rest.post("http://localhost:3000/calc-tax", async (req, res, ctx) => {
        return res(ctx.status(200), ctx.json({ tax: 15_315 }));
      })
    );

    // When
    const response = await calcTax({
      yearsOfService: 6,
      isOfficer: false,
      isDisability: false,
      severancePay: 3_000_000
    } satisfies CalcTaxParam);

    // Then
    expect(response.status).toBe(200);
    expect((await response.json()) satisfies CalcTaxResult).toStrictEqual({ tax: 15_315 });
  });
});
kazokmrkazokmr

目的とはズレるのだが、ChakraUIやSveltestrapのようなスタイルドコンポーネントで提供されるCSSフレームワークはSvelteでやりたいことと合わない気がしてきたので、Tailwindに切り替えてみる。

理由は、InputForm コンポーネント内の FormコンポーネントあるいはButtonコンポーネントに on:submit|preventDefault としたかったのだが、次のようなコンパイルエラーが出力され、Svelteのイベント修飾子はonce以外DOM要素にしか適用できないそう。

Event modifiers other than 'once' can only be used on DOM elements

となると、フレームワークで提供されているFormやButtonコンポーネントではなく、form や button といったDOM要素にイベントを定義をするのでDOM要素に対して自分でスタイリングをする必要がある。

なので CSSが得意でない自分としてはまずは Tailwindでスタイリングを行ってみてそれで動かない箇所があれば <style> でCSSを書くことにする。

kazokmrkazokmr

SvelteKitプロジェクトへの Tailwind CSSの導入は、公式の 手順 に従って実施した。

ちなみに Storybookで有効にするために、 preview.ts に作成した app.css をimportした。

特に問題なく利用することができて、Sveltestrap から移行することができた。
あと、Taiwind はclass名を探したり覚えるのは大変だけど慣れてくると使いやすそうだと思った。

kazokmrkazokmr

「6.4.7 結果表示コンポーネントに結果を表示する」まで完了。紆余曲折あったが 書籍の内容をSvelteKitに置き換えるとこんな感じになった。

なお、SvelteKit の サーバーサイドで行う処理は意図的に実行していない。これは書籍も SSRなどのPrerenderを使っていないので合わせたため。よって 最後まで完成した後に サーバーサイドロジックに切り替えるつもり。

InputForm.svelte
<script lang="ts">

  export type InputForm = {
    yearsOfService: number;
    isDisability: boolean;
    isOfficer: string;
    severancePay: number;
  };

  export let { yearsOfService, isDisability, isOfficer = "0", severancePay }: InputForm = {};
</script>
<div class="border-2 rounded-xl w-96 h-[450px]">
  <div class="border-b-2 bg-gray-100 leading-10 text-lg text-center">退職金情報を入力してください</div>
  <div>
    <form method="post" on:submit|preventDefault>
      <label for="yearsOfService" class="block mx-3 mt-3 mb-2 text-base font-medium text-gray-900">勤続年数</label>
      <div class="inline-flex ml-4">
        <input type="number" name="yearsOfService" id="yearsOfService" bind:value="{yearsOfService}"
               class="rounded-none rounded-l-lg border text-gray-900 focus:ring-blue-500 focus:border-blue-500 block flex-1 w-24 text-base border-gray-300 p-2.5" />
        <span
          class="inline-flex items-center px-2.5 text-base text-gray-900 bg-gray-200 border border-l-0 border-gray-300 rounded-r-md"></span>
        <span class="text-gray-500 text-sm pt-4 pl-4">1年未満の端数は切り上げ</span>
      </div>
      <p class="block mx-3 mt-3 mb-2 text-base font-medium text-gray-900">退職基因</p>
      <div class="flex items-center ml-4">
        <input type="checkbox" name="isDisability" id="isDisability" bind:checked="{isDisability}"
               class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" />
        <label for="isDisability"
               class="ml-2 text-base font-normal text-gray-900">障害者となったことに直接基因して退職した</label>
      </div>
      <p class="block mx-3 mt-3 mb-2 text-base font-medium text-gray-900">役員等以外か役員等か</p>
      <div class="flex ml-4">
        <div class="flex items-center mr-4">
          <input type="radio" name="isOfficer" id="isOfficer-0" value="0" bind:group="{isOfficer}"
                 class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 " />
          <label for="isOfficer-0" class="ml-2 text-base font-normal text-gray-900">役員等以外</label>
        </div>
        <div class="flex items-center mr-4">
          <input type="radio" name="isOfficer" id="isOfficer-1" value="1" bind:group="{isOfficer}"
                 class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 " />
          <label for="isOfficer-1" class="ml-2 text-base font-normal text-gray-900">役員等</label>
        </div>
      </div>
      <label for="severancePay" class="block mx-3 mt-3 mb-2 text-base font-medium text-gray-900">退職金</label>
      <div class="inline-flex ml-4">
        <input type="number" name="severancePay" id="severancePay" bind:value="{severancePay}"
               class=" rounded-none rounded-l-lg border text-gray-900 focus:ring-blue-500 focus:border-blue-500 block flex-1 w-36 text-base border-gray-300 p-2.5" />
        <span
          class="inline-flex items-center px-2.5 text-base text-gray-900 bg-gray-200 border border-l-0 border-gray-300 rounded-r-md"></span>
      </div>
      <div class="m-3">
        <button type="submit"
                class="text-white bg-blue-600 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-2.5 mr-2 mb-2 ml-auto block">
          所得税を計算する
        </button>
      </div>
    </form>
  </div>
</div>
Result.svelte
<script lang="ts">
  export type ResultProps = {
    tax: number | null;
  };
  export let { tax }: ResultProps = { tax: null };
  $: taxStr = tax === null ? "---" : new Intl.NumberFormat("ja-JP").format(tax);
</script>
<div class="border-2 rounded-xl w-96 h-[450px]">
  <div class="border-b-2 bg-gray-100 leading-10 text-lg text-center">退職金にかかる所得税</div>
  <div class="my-12 text-center" aria-label="tax">
    <span class="text-5xl">{taxStr}</span>
    <span></span>
  </div>
</div>
Presentation.svelte
<script lang="ts">
  import type { InputFormProps, ResultProps } from "$lib/components/InputForm.svelte";
  import InputForm from "$lib/components/InputForm.svelte";
  import Result from "$lib/components/Result.svelte";

  type PresentationProps = InputFormProps & ResultProps;

  export let { formInputs }: PresentationProps = {};
  export let { tax }: PresentationProps = { tax: null };
</script>
<div class="container w-[870px]">
  <h2 class="text-center text-2xl font-semibold">退職金の所得税計算アプリケーション</h2>
  <div class="columns-2">
    <div class="">
      <InputForm {...formInputs} on:submit />
    </div>
    <div>
      <Result tax="{tax}" />
    </div>
  </div>
</div>
+page.svelte
<script lang="ts">
  import Presentation from "$lib/components/Presentation.svelte";
  import type { CalcTaxParam, CalcTaxResult } from "$lib/fetch/client/calcTax";
  import { calcTax } from "$lib/fetch/client/calcTax";

  let tax: number | null = null;

  const handleInputFormSubmit = async (event) => {
    const formData = new FormData(event.target);
    const param = {
      yearsOfService: Number(formData.get("yearsOfService")),
      isDisability: !!formData.get("isDisability"),
      isOfficer: !!Number(formData.get("isOfficer")),
      severancePay: Number(formData.get("severancePay"))
    }satisfies CalcTaxParam;
    const response = await calcTax(param);
    if (response.ok) {
      const json = await response.json() satisfies CalcTaxResult;
      tax = json.tax;
    }
  };
</script>

<Presentation tax="{tax}" on:submit={handleInputFormSubmit} />
+layout.svelte
<script>
  import "../app.css";
</script>

<slot />
kazokmrkazokmr

「6.4.8 ページコンポーネントの振る舞いに対するテストを書く」では、testing-libraryを追加した。
インストールした npm パッケージは次のとおり

  • @testing-library/svelte
  • @testing-library/jest-dom
  • @testing-library/user-event
  • jsdom
  • @types/jsdom

※ jest-dom は 推奨 となっており、アサーションを利用する必要がなければインストールは不要

また vite.config.ts に test.environment: "jsdom" を追加するのを忘れないこと。

+page.svelte はこのファイル名だとレンダリングできなかったので、Page と別名でインポートしてからレンダリングしている位で、その他は Reactと同じようにテストコードが書けた。
testファイルに +を付けてしまうとコンパイルエラーとなったのtestファイルには+を付けていない

page.test.ts
import { setupServer } from "msw/node";
import { rest } from "msw";
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
import userEvent from "@testing-library/user-event";
import { render, screen, waitFor } from "@testing-library/svelte";
import Page from "./+page.svelte";


const server = setupServer(
  rest.post("http://localhost:3000/calc-tax", (req, res, context) =>
    res(context.status(200), context.json({ tax: 10000 }))
  )
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe("ページコンポーネント", () => {
  test("所得税を計算できる", async () => {
    // Begin
    const user = userEvent.setup();
    render(Page);

    // When
    await user.click(screen.getByRole("button", { name: "所得税を計算する" }));

    // Then
    await waitFor(() => expect(screen.getByLabelText("tax").textContent).toBe("10,000 円"));
  });
});
kazokmrkazokmr

Vitest で @testing-library/jest-dom を利用するときの注意点

以下の2つを行うこと

  1. テストファイルに import @testing-library/jest-dom; を追加する。
  • または vite.config.ts で、setupfilesを定義しそのファイル内で import すれば全てのテストファイルに展開できる。

なお、ファイルパスやファイル名は変更可能。

vite.config.ts
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vitest/config";

export default defineConfig({
  plugins: [sveltekit()],
  test: {
    include: ["src/**/*.{test,spec}.{js,ts}"],
    environment: "jsdom",
    setupFiles: ["./vitest.setup.ts"]
  }
});

vitest.setup.ts
import '@testing-library/jest-dom'

理由:jest-domのアサーションを利用したいのに Chaiのアサーションを探して Invalid Chai property: toBeXXXX のようなエラーが出るため。

  1. vite.config.ts に globals: true を追加し、各テストファイルで VitestのGlobalAPIの明示的なImportを不要にする。
vite.config.ts
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vitest/config";

export default defineConfig({
  plugins: [sveltekit()],
  test: {
    include: ["src/**/*.{test,spec}.{js,ts}"],
    environment: "jsdom",
    setupFiles: ["./vitest.setup.ts"],
    globals: true,
  }
});

VitestでImportを明示している場合、expect が jest-dom と競合するようでエラーになるため。

kazokmrkazokmr

テストファイルに import @testing-library/jest-dom; を追加する。

これは次のように jest-dom の matchers だけをインポートしても問題なさそう。

import * as matchers from "@testing-library/jest-dom/matchers";

kazokmrkazokmr

「6.5 フォームのバリデーション」を進めるにあたり、 Superforms を導入して進行中。このライブラリのおかげなのかSvelteKitへの理解が進んだおかげなのかはわからないが form の受け渡しのところがスッキリできるようになった。

ただStorybookで、「Cannot read properties of undefined (reading 'before_navigate')」 が発生したので、調べたところ、 beforeNavigate は SvelteKit の $app/navigation で提供されているモジュールなのだがこれへの対応が Storybook7.1 予定とのことだったので、今回は Storybookへの対応は諦めて先に進む

Storybook for SvelteKit

kazokmrkazokmr

Superforms自体も 現在 V1.0に向けてRC版がリリースされている状態であるなど、Svelteのエコシステムはまだ発展途中な印象。

kazokmrkazokmr

メモ

Superformsの Client-side validation を実装中で色々理解が深まったこと

constrains は、zodのschemaからHTML5 の Constaint validation に 準拠する属性を持ってくれる。
便利ではあるものの Validationでのメッセージを変えたり出力方法を変えたいなどUIの仕様によっては不要で、独自の対応を入れる

で、独自のチェックとして Validator って属性を設定できるのだが、せっかくZodのSchemaでValidationとメッセージを作っているのだからそれを使いたい。で調べていたところ、普通に Schemaを渡せばできるようになっていた。多分、Documentの反映が遅れていただけなのかもしれない。(SPAの説明ページには記載があった)

https://github.com/ciscoheat/sveltekit-superforms/issues/101

これを行うとバンドルサイズが少し重くなる?っぽいがIssueのコメントにもある通り、Single source of truth の考え方からはこれが良いとは思う。

kazokmrkazokmr

メモ:
zodのスキーマプロパティに required_error で必須項目メッセージを設定したが、勤続年数や退職金のinput typeは number にしているが初期値を強制的にstringの空文字 ""にしているので、未入力でSubmitすると invalid_type_error のメッセージが表示される

kazokmrkazokmr

これを行うとバンドルサイズが少し重くなる?っぽいがIssueのコメントにもある通り、Single source of truth の考え方からはこれが良いとは思う。

これは zodのスキーマ定義のJSがクライアント側にもバンドルされることになるからってことみたい

kazokmrkazokmr

「6.5.1 バリデーションを実装」完了

Superforms を使うことで、サーバーサイドの form actions関数を使うなど SSRな処理を行ったことで、Jest本と結構変わってきたが、結果はほぼ同等になったと思う。

Superforms は 今日現在で V1.0-RC3で、次が1.0リリースとのタイミングではあるが、zodと連携したValidation処理が面白いと感じた。細かいところで気になる挙動はあるけど設計次第で対応できそう。(今回で言うと、isOfficerをbooleaにするなら radioボタンに変えるとか。)
また Superformsを使うことで、sveltekit の use:enhance (progressive enhance) についても少し理解できたし、これについては別途ブログを書いてみたい。

あと、Superformsを使ったことで、Storybookで未対応の SvelteKitモジュール ($app/navigation と $app/forms)の影響で この後のStorybookでの確認ができなくなった。これについては、現在開発中のStorybook 7.1で対応予定とあったので バージョンアップを待って確認したい。

kazokmrkazokmr

Reactとの違いに関しては現在はこんな感じ

  • React
    • hooks で ロジックとコンポーネントを切り離して書けるのは便利
    • ナレッジが豊富なので困ったことなども解決方法が見つけやすい
    • 周辺ライブラリが多いのとかつ複雑なイメージはある
  • SvelteKit
    • Reactの主要な関連ライブラリの機能と同等の機能が SvelteKitでサポートされていて使いやすい
    • まだ未発達の部分があったりナレッジがReactに比べて少ないので自分でカットアンドトライになりやすい

比較的、画面やコンポーネントの数が少なくインタラクティブな操作を複雑でなければ Svelte/SvelteKitを積極的に採用してみたい気持ち。 対して 画面が多く、操作も比較的複雑だとReactの方がナレッジや関連ライブラリも多いので対応は比較的ラクな気はする。(ただしメンテも難しくなるかもしれない)

kazokmrkazokmr

(今回で言うと、isOfficerをbooleaにするなら radioボタンに変えるとか。)

こちら クライアントでは isOfficerをStringとし、バックエンドAPIに送信するときにbooleanに変換するようにした。
理由は、submitボタンを押したときのClient Validationでinvalidになったときに どうしても値がtrueになってしまい、選択状態が変わってしまうことがあるため。

kazokmrkazokmr

SvelteKitのサーバーサイドロジック load()actions() を利用したことで、単体テストコードが失敗するようになった。

既存のテストは Page オブジェクトを作ってレンダリング時に渡すのと、submitの結果となる ActionResult のモックを用意して表示を確認するのが良さそう。サーバーサイドロジックの実行は Praywright で E2Eテストで検証する。

kazokmrkazokmr

さらに、vitestを 0.32.0 にして実行すると次のエラーも出たので調査中。 0.31.4 に戻すとエラーは解消するが、見つからないのは sveltekit/paths というモジュールだし、SvelteKit側が原因かもしれない。SvelteKitのverは 1.20.2

Error: Cannot find module '__sveltekit/paths'.

- If you rely on tsconfig.json to resolve modules, please install "vite-tsconfig-paths" plugin to handle module resolution.
 - Make sure you don't have relative aliases in your Vitest config. Use absolute paths instead. Read more: https://vitest.dev/guide/common-errors
kazokmrkazokmr

Superforms では、zodの validation schemaの情報を持ったValidation用データ(SuperValidated型オブジェクト) から専用のformデータ(SuperForm型オブジェクト)を生成するのだが、このSuperFormオブジェクトが持っている formデータは SvelteKit の runtimeモジュール の一つである $app/store モジュール の page store に格納される。

$app/store はテスト対象のSvelte component の外側にあるため、+page.svelte コンポーネントに テストデータを投入しても次のようなエラーが発生してしまう。

Error: Cannot subscribe to 'page' store on the server outside of a Svelte component, as it is bound to the current request via component context. This prevents state from leaking between users.For more information, see https://kit.svelte.dev/docs/state-management#avoid-shared-state-on-the-server

このため、$app/store モジュールをモック化する必要がありそう。こちらのリポジトリで Svelte/SvelteKit に対するテストコードレシピがあったのでこれを熟読する

https://github.com/davipon/svelte-component-test-recipes

kazokmrkazokmr

このため、$app/store モジュールをモック化する必要がありそう。

レシピの setupを参考にモックを作ったらテストは実行された(失敗するテストだけど)ので、このまま進めてみる

kazokmrkazokmr

始めに +server.tsload() 関数で処理している SuperValidateオブジェクトの初期化を行い、Pageオブジェクト(+page.svelte) のPageDataプロパティにセットすることで Mock化した storeに格納されコンポーネントをレンダリングして テストが成功するようになった。

また formデータの値を変えたければプロパティにセットする前に直接上書きすることができる。

page.test.ts
const form = await superValidate(inputSchema);
form.data.yearsOfService = 10;
form.data.severancePay = 5_000_000;
render(Page, { data: { form } });
kazokmrkazokmr

submit時の actions() のモック化方法を知りたい。
計算結果を表示するテストを実行すると、TypeError: Cannot read properties of undefined (reading 'form')Error: Cannot call applyAction(...) on the server が表示される。これらは、 $app/form モジュールを使っているのでこれをMock化する必要があると考えているが、前述のレシピには存在しなかった。

ちなみに 元のソースはこれかな
https://github.com/sveltejs/kit/blob/master/packages/kit/src/runtime/app/forms.js

kazokmrkazokmr

SvelteKitのサーバーサイドの処理をテストするため「所得税額を計算する」テストをPlaywrightで書いた。

test.ts
test("所得税を計算できる", async ({ page }) => {
  // Begin
  await page.goto("http://localhost:4173/");

  // When
  await page.getByRole("spinbutton", { name: "勤続年数" }).click();
  await page.getByRole("spinbutton", { name: "勤続年数" }).fill("10");
  await page.getByRole("spinbutton", { name: "退職金" }).click();
  await page.getByRole("spinbutton", { name: "退職金" }).fill("5000000");

  await page.getByRole("button", { name: "所得税を計算する" }).click();

  // Then
  await expect(page.getByLabel("tax")).toHaveText("25,525 円");
});

backendとなる tax-apiを起動した状態で実行すると テストが成功した。SvelteKitを使う場合はこんな感じでテストを実行した方が早いのかもしれない。

ただAPIサーバーを起動しておく必要があるので、フロントエンドアプリの開発をテストで確認しながら実施したいときが不便。

なので、MSWの setupServer や Playwright の Mock APIs を使ってバックエンドをMockにしたいのだが上手くいかない。

おそらくだが Playwrightではブラウザのテストを行っているのでMock化できるのはブラウザからのリクエスト/レスポンスだけの模様。(なので、MSWの場合、setupWorkerでサービスワーカーをモックしないといけない) SvelteKitのサーバーやバックエンドサーバーはテスト実行の管理対象外になっていると考えられる。

やりたいことは、SvelteKitのサーバーまでは実際に起動させて、バックエンドのAPIサーバーをモック化したいのだが Playwrightのテストコードではそこまで管理ができないのだろう。

バックエンドAPIのモックサーバーを構築する方法を少し調べてみる。(Nextjsなどでも同じ課題がありそう。)

kazokmrkazokmr

json-server でこのようにリクエストを返すサーバー設定を行えば良いが、シナリオごとにデータを作り直すのが面倒かも。テストコードで (req,res,next) => () 関数をセットして都度 json-serverを起動するようにすればいけるかな。
ただし、json-serverの本来の目的とは違うのは気になる

server.js
import jsonServer from "json-server";

const server = jsonServer.create();
const router = jsonServer.router("./tests/db.json");
const middlewares = jsonServer.defaults();

server.use(middlewares);
server.use((req, res, next) => {
  if (req.path === "/calc-tax") {
    res.status(200).json({ tax: 25525 });
  } else {
    next();
  }
});

server.use(router);
server.listen(3000, () => {
  console.log("JSON Server is running");
});
kazokmrkazokmr

vite-test-utilsも試してみたが 上手くいかず。元々の用途が異なるのかもしれないが、以下が設定できると動いたりしないか??

  • SvelteKitサーバーを起動するときのポートが指定し、かつテスト対象ページからでサーバーに対してsubmitが送信できると良さそう
  • setup()で beforeAllなどがwrapされているが、mswも使いたい場合にどうしたら良いか?
kazokmrkazokmr

+page.svelte+server.ts で別々にテストできれば良いのかな?

  • 前者はSubmitの送信データをインターセプトし (ActionResult を返すMockを作るとか?
  • 後者は vite-test-utils を使って SvleteKitサーバーを起動して $fetch で submit同等の送信を行って動作を確認する。 (バックエンドAPIはmswを使う?)
    • vite-test-utils 使わなくても actions オブジェクトを呼び出してテストすれば良い??
kazokmrkazokmr

前者はSubmitの送信データをインターセプトし (ActionResult を返すMockを作るとか?

これ、MSWでモック作れないか行ってみた。

rest.post("http://localhost:3000/*", (req, res, context) => {
    res(context.status(200), context.json({ result: { data: { form: { message: 10000 } } } }));
});

結果は、Mockでレスポンスを返そうが返すまいが、statusがエラーで戻ってきてしまう。
<empty line>result: {"type":"error","error":{"cause":{"errno":-61,"code":"ECONNREFUSED","syscall":"connect","address":"::1","port":3000}}}

というか、レスポンスオブジェクトのモックが正しくなさそう。
Expected response resolver to return a mocked response Object, but got undefined. The original response is going to be used instead.

ちなみにurlを http://localhost:3000/* としたのは Vitest + Svelte-Testing-Library で submitすると msw がキャプチャしたURLが http://localhost:3000/undefined だったから。(多分、formのaction属性を定義していないからpathがundefinedになってて、sveltekitだと Form actions で defaultプロパティと読み替えるんだと推測している。)

kazokmrkazokmr

レスポンスオブジェクトを SvelteKitの ActionResultに合わせてみたけど結果は同じ

return res(context.json({
          type: "success",
          status: 200,
          data: {
            form: {
              message: 10000
            }
          }
        }));
kazokmrkazokmr

responseに "data" プロパティをセットすると "{"type":"error","error":{}}" がセットされる。 SvelteKitで ActionResultを生成するときに何を行っているのか確認する。

kazokmrkazokmr

SvelteKitのソースコードを調べてわかった。 use:enhance を使っている場合は RESTのように serverとはjsonオブジェクトでactionデータだけをやり取りしていた。

https://zenn.dev/kazokmr/scraps/750b2e1fd4cb26

なのでこのように mswでJsonオブジェクトをモック化したらテストが通った!

test("所得税を計算できる", async () => {
  // Begin
  server.use(
    rest.post("http://localhost:3000/*", (req, res, context) =>
      res(context.json({
        type: "success",
        status: 200,
        data: devalue.stringify({ form, tax: 10000 })
      }))
    )
  );
  const form = await superValidate(inputSchema);
  render(Page, { data: { form } });

  // When
  const user = userEvent.setup();
  await user.click(screen.getByRole("spinbutton", { name: "勤続年数" }));
  await user.clear(screen.getByRole("spinbutton", { name: "勤続年数" }));
  await user.keyboard("10");
  await user.click(screen.getByRole("spinbutton", { name: "退職金" }));
  await user.clear(screen.getByRole("spinbutton", { name: "退職金" }));
  await user.keyboard("5000000");
  await user.click(screen.getByRole("button", { name: "所得税を計算する" }));

  // Then
  await waitFor(() => {
    expect(screen.getByLabelText("tax")).toHaveTextContent("10,000 円");
  });
});
kazokmrkazokmr

ただしコンソールに以下のエラーが出る。

Error: Cannot call applyAction(...) on the server 
    at Module.applyAction (/Users/tax-app-advanced/tax-ui/node_modules/@sveltejs/kit/src/runtime/app/forms.js:22:9)
    at validationResponse (/Users/tax-app-advanced/tax-ui/node_modules/sveltekit-superforms/dist/client/formEnhance.js:372:57)
    at processTicksAndRejections (node:internal/process/task_queues:95:5)

これは クライアントで applyActionを実行しようとしているが、サーバー側の関数を呼ぼうとしているためと考えられる。テスト時に BROWSERをセットすれば改善するかもしれないがテストはNodeで実行されているため難しいかも。テストの目的としては "submitボタンを押と => taxの値が更新される"を確認したかったので保留

https://github.com/sveltejs/kit/blob/master/packages/kit/src/runtime/app/forms.js

kazokmrkazokmr

テストしたいことは問題なくてもエラーが出力されるのはスッキリしない。ブラウザでの実行とみなせることができれば良いが方法が不明。テストコードでエラーを握りつぶすのは最終手段にしたい。

kazokmrkazokmr

Vitestを使ったコンポーネントテストで行う範囲と Playwrightを使った結合テストで行う範囲を明確にしておきたい

現時点で考えていること

  • 結合テスト(Playwrightで行う)
    • SvelteKitのServer側の処理が伴うもの(サーバーとクライアントがActionResult型で結果をやり取りするケース)
      • Submit操作が伴うもの。つまり Form Action が実行されるケース。
      • API Routes を呼び出すケースも同様
  • 単体テスト (Vitestで実施)
    • Propsの値を参照したロジックを確認する
      • 要素の表示/非表示、disabled、初期値 などが設計通りかテストするのが目的
      • 色とかフォントなどの変化はテストしない(StorybookやUIテストとして実施する)
    • イベント発生時にコンポーネント内部で状態が変わることを確認するケース
      • イベントハンドラ関数が呼ばれたことを確認するだけのモックはいらないかな
      • PageあるいはTemplateレベルのコンポーネント間だけで完結するイベントはケースバイケースで。
      • 要は サーバーへのリクエストが発生するイベントかどうかが切り分けポイントの一つ
    • Load Dataを使うコンポーネントはPropsにテストデータを直接注入して確認できる
kazokmrkazokmr

MockoonというモックAPIが気になる。npm で cliも提供されており GitHub Actions などからでも起動して使えるみたい。
予めモックデータを作成して取り込むようにすれば Playwrightを用いたフロントエンドのテストが行いやすいかもしれない

https://mockoon.com/

kazokmrkazokmr

Mockoonが使いやすければ SvelteKitサーバーを含めたフロントエンドテストを Playwright + Mockoon で実施する方針して、Vitestを使ったコンポーネントテストはコンポーネント開発のためのテスト用に切り分けるのが良いかもしれないと思った

kazokmrkazokmr

「6.5.2. バリデーションの振る舞いを確認」後半の境界値テストの実施について、"バリデーションエラーにならないこと" を 『フォームの入力内容をAPIに送信して結果が返る』ことで検証している。

これを現状のSvelteKitで行うと、一応テストは通るものの ActionResultがCallできないというエラーメッセージが出力されてしまうので submitまで実行せずに『バリデーションエラーメッセージが表示されないこと』までとすることにした。

kazokmrkazokmr

未入力の時のテストが無いことに気がついた。
ここで2つ課題が出た

  1. testing-library の userEvent.keyboard() に 空文字("")を渡すとエラーになるっぽい
  2. 仕様として未入力チェックはどのタイミングで行うべきか?
kazokmrkazokmr

SuperformsはSubmit時にClientValidationが実行され未入力チェックが行われる。 npm run dev で起動したときにはSubmit前にこれが実行されエラーメッセージが出ることは確認した。

しかし同様の操作をVitest + Svelte-testing-library で行ってもClientValidationが実行されなかった。

恐らく原因は ClientValidationの引数にStoreの値を参照していることだと考えている。なのでStoreをモック化すれば解消するかもしれないがMockの数が多いので避ける。

このため今回は、Submit時ではなく 一度値を入力してクリアした後に focusoutすることで未入力チェックを行うようにした。(focusout時のEvent Validatorは値が変更することが検出されるため)

また Submit時の検証は Playwrightで実施する方向とする。

kazokmrkazokmr

6章を一通りやり終えた。

SvelteKitと周辺エコシステムの使い方に苦労した感じで、本の主旨であるはずのテストやテストをしながら開発するっていうことをポイントに勧められなかった。。。

https://github.com/kazokmr/tax-app-svelte

このスクラップは2023/06/22にクローズされました