Vitest Browser Modeを使ってTauri+SvelteKitのフロントエンド統合テストを行う

に公開

背景

現在 Tauri 2 + Svelte 5 (SvelteKit) を利用してアプリ開発をしています。
Tauri (Rust) 部分をモックしてフロントエンドだけの統合テストをやりたいなーと考えました。

Vitest には(まだ Experimental だけど)Vitest Browser Mode ってのがあって、Vitest のモック機能を使いつつ、ブラウザでテストを動作させられるらしいぞ?

実験段階だけに、情報が・・・情報が少ない!!
Svelte のコンポーネント単体テストのやり方は公式で説明されてるけど、SvelteKit の情報が皆無。統合テストの情報も見当たらない。

ここで説明すること

Vitest Browser Mode を使って、SvelteKit を利用したフロントエンドの統合テストを行う方法を説明します。

私の環境は Tauri 2 ですが、Electron はもちろん、普通の Web アプリでも同様です。

Tauri 想定なので SSR は無視ですが・・・そもそも Node.js 環境で動くもので Browser Mode で動かすものではありません。

Vitestの設定

公式の Vitest Browser Mode のドキュメント は目を通しておいてください。
ここでは SvelteKit で統合テストを行うためのポイントだけ解説します。

plugins に sveltekit() を指定

まず plugins に svelte() じゃなくて sveltekit() を指定。

import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';

export default defineProject({
  plugins: [tailwindcss(), sveltekit()],

SSR 無効化

これだけだと SSR 関係のエラーが出るので、SSR を無効化しておく。

export default defineProject({
  // ...
  build: {
    ssr: false,
  },

これは Vite でのビルド設定です。全てにおいて SSR が OFF になります。

SvelteKit の仕組みを利用しない理由

Tauri アプリとしては全ページ共通の設定として、routes/+layout.ts 内に以下を設定しています。これが SvelteKit の SSR/SSG 制御の仕組みです。

export const prerender = false;
export const ssr = false;

SvelteKit では、このファイル(routes/+layout.ts)は各ページが表示される前に実行されますので、全ページに適用されます。

しかし Vitest Browser Mode のテストは基本的にコンポーネントテストであり、コンポーネントを render 関数に渡してレンダリングするという方式です。
各ページの +page.svelte をコンポーネントとしてレンダリングするように統合テストをしているので、routes/+layout.tsroutes/+layout.svelte は読み込まれません。

ページごとに export const ssr = false; を記述する方針であれば、この設定はいらないかもしれませんが、試していないです。(もしくは各統合テストファイルで routs/+layout.ts を import)

もしうまくいくとしても、コンポーネントの単体テストも Browser Mode で行いたいなら、そちらは設定を別に分けて plugins に sveltekit() でなく svelte() を適用する必要があるはずです。
SSR は Node.js でテストすべきものなので、Browser Mode では SSR をビルド設定で全部 OFF にしてしまうのが、そういった面倒なことを考えなくて良くて簡単と考えました。

環境変数の仕組みをモック(というかスタブ)

自分の場合は開発時とリリース時の動作を環境変数で変更していたので、その部分をモックに置き換える必要がありました。

export default defineProject({
  // ...
  resolve: {
    alias: {
      $src: path.resolve(__dirname, './src'),
      '$env/dynamic/public': path.resolve(
        __dirname,
        './src/lib/mocks/env-dynamic-public.ts'
      ),
    },
  },

env-dynamic-public.ts は単に環境変数が記述してあるだけです。

export const env: Record<string, string> = {
  PUBLIC_I18NEXT_DEBUG: 'false',
};

$src にも alias を指定しているのは、テストファイル中で import '$src/app.css'; と記述して、グローバルCSSファイル (src/app.css) を読み込みたいためです。
統合テストのファイルは src/integration-tests に全部置いてるので、import '../app.css'; って書けばいいじゃんという話なのですが、コンポーネントの単体テストはコンポーネントと同じディレクトリに置くつもりなので、どこに置いてあっても import '$src/app.css'; と書けるといいなと。

全体像

import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { webdriverio } from '@vitest/browser-webdriverio';
import path from 'path';
import { fileURLToPath } from 'url';
import { defineProject } from 'vitest/config';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default defineProject({
  plugins: [tailwindcss(), sveltekit()],
  resolve: {
    alias: {
      $src: path.resolve(__dirname, './src'),
      '$env/dynamic/public': path.resolve(
        __dirname,
        './src/lib/mocks/env-dynamic-public.ts'
      ),
    },
  },
  test: {
    name: 'browser',
    setupFiles: ['vitest-browser-svelte'],
    browser: {
      enabled: true,
      provider: webdriverio(),
      instances: [{ browser: 'chrome' }],
      headless: true,
      viewport: { width: 800, height: 1024 },
    },
    globals: true,
    include: ['src/**/*.browser.test.ts'],
  },
  build: {
    ssr: false,
  },
}

ブラウザテストは皆さん Playwright を使われると思うのですが、Tauri の場合 E2E テストは WebdriverIO か Selenium を使うことになります(Playwright はブラウザしか動かせない)。
ここでは E2E テストで使う WebdriverIO を、ブラウザで動かす統合テストでも利用することにしました。

Vitest Browser Mode がラップしてくれているので、バックに使うのが Playwright でも WebdriverIO でもテストの記述方法は同じです。なので Vitest Browser Mode と E2E で WebdriverIO に揃えるメリットはそれほど大きくありません。
バック部分の設定や、Node.js 側で動作するカスタムコマンドでバックのオブジェクトを利用する時、CI/CD 環境のセットアップでも、共通のものを使っていると知識が共有できます・・・が、Playwright の方が情報が多いのでメリットが相殺されそうです。

追記:
WebdriverIO だと firefox は大丈夫なのですが、chrome では指定した viewport が効かない問題が発生してスクショが見切れるので、結局 Playwright で chromium を使うことにしました。

      provider: playwright(),
      instances: [{ browser: 'chromium' }],

Tauri は WebView を使うわけですが、Windows では Edge が使ってる Chromium (Blink) ベースの Microsoft Edge WebView2、macOS では Safari が使ってる WebKit ベース の WKWebView、Linux も WebKit ベースの WebKitGTK です。
Firefox (Gecko) はどの OS の WebView も使ってないしなー、どうせ確認するなら Chromium がいいなーと。

普通の Node.js で動くテストと設定を分ける

vitest.config.ts

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    projects: ['./vitest.config.nodejs.ts', './vitest.config.browser.ts'],
  },
});

vitest.config.nodejs.ts には Node.js で動かすテストの設定を、vitest.config.browser.ts には Vitest Browser Mode で動かすテストの設定を記載します。

私は Browser Mode で動かすテストは *.browser.test.ts で、普通の Node.js で動かすテストは *.test.ts としています。
末尾の拡張子がどちらも .test.ts なので vitest.config.nodejs.ts では exculde 設定で、*.browser.test.ts を除外するようにしています。

export default defineProject({
  test: {
    name: 'nodejs',
    // ...
    include: ['**/*.test.ts'],
    exclude: ['**/*.browser.test.ts'],
  },

実行方法

vitest 実行時に --project browser でどのプロジェクトを利用するかを指定できます。

vitest run --project browser

package.json の scripts に記述しておきます。

  "scripts": {
    "test": "vitest run --project nodejs",
    "test:nodejs": "vitest run --project nodejs",
    "test:browser": "vitest run --project browser",
    "test:all": "vitest run",

統合テストを書く

コンポーネントを render 関数に渡してブラウザで描画させる仕組みなので、Integration Test する場合は routes の +page.svelte を描画させます。+page.ts は実行してくれないので、テスト側で実行してコンポーネントに結果を渡します。

src/integration-tests/settings.browser.test.ts

import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import type { PageData } from '../routes/settings/$types';
import { load } from '../routes/settings/+page';
import Component from '../routes/settings/+page.svelte';

import '$src/app.css';

// Mock Tauri
vi.mock('@tauri-apps/plugin-store');
vi.mock('@tauri-apps/api/core');
vi.mock('@tauri-apps/api/path');
vi.mock('@tauri-apps/api/app');

beforeEach(() => {
  vi.clearAllMocks();
});

test("test sample", , async () => {
  // (略)Vitest のモック機能使って Tauri(Rust) 関係はモックしておく

  // Execute load function
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const result = (await load({} as any)) as PageData;

  // Verify load result
  expect(result.errorMessage).toBeNull();
  // ...

  // Render component with loaded data
  render(Component, { data: result, params: {} });

  // Verify UI elements
  await expect.element(page.getByText('Settings')).toBeInTheDocument();
  // ...

  await page.screenshot();
});

制限

本当は routes/+layout.svelte も適用したいのですが、これをコンポーネントとして render に渡すとして、$props で渡される children 関数をどうやってテスト側のコードで作ればいいのかが分かりません。

なので現状は、各 +page.svelte 単位で統合テストを実施しています。
ルーティングの仕組みも動いていないので、ページ遷移を伴うテストは行えません。

どうしても +layout.svelte も含めてレンダリングしたい場合は、以下のようなラッパーコンポーネントを用意することで実現できますが、各画面ごとに用意するのが面倒です。

src/integration-tests/settingsWrapper.svelte

<script lang="ts">
  import Layout from '../routes/+layout.svelte';
  import type { PageProps } from '../routes/settings/$types';
  import TargetComponent from '../routes/settings/+page.svelte';

  const props: PageProps = $props();
</script>

{#snippet target()}
  <TargetComponent {...props} />
{/snippet}

<Layout children={target} />

テスト時には import Component from '../routes/settings/+page.svelte'; の代わりに import Component from './settingsWrapper.svelte'; を利用します。

自分のアプリでは+layout.sveltesrc/app.css を適用しているので、テストファイルでの import '$src/app.css'; は必要なくなります。

まとめ

情報が全然なかったので、Vitest Browser Mode を使って SvelteKit で統合テストを行う方法について情報共有しました。

とはいえ別に特別なことはしておらず、普通に Vitest や Vite が提供している仕組みを使っているだけです。

実際のところ、SvelteKit の +layout 適用やルーティング動作などは実現できておらず、あくまで Vitest Browser Mode が提供するコンポーネントテストの仕組みを利用してできる範囲のことをやっている格好です。

追記:
Vitest Browser Modeでカバレッジ計測する という記事を書いたので、カバレッジ計測したい方は参考にしてください。

Discussion