remix (react-router v7) で動かす sqlite-wasm を vitest でテストしたい

2024/12/22に公開

ふとしです。

テスト書きたいですよねえ。

概要

sqlite-wasm はブラウザで動かすことを前提としているため、普通に import すると例外が発生します。これはダイナミックインポートで解決しますが、vitest を普通に動かすと Node 上の動作となり、やはり動きませんので、vitest ブラウザモードでテストできるようにしたいと思います。

とりあえず sqlite-wasm が動くようにする

ここらへんはリファレンスや他の記事があるので適当に。

設定を書き換え、

import { reactRouter } from "@react-router/dev/vite";
import autoprefixer from "autoprefixer";
import tailwindcss from "tailwindcss";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  css: {
    postcss: {
      plugins: [tailwindcss, autoprefixer],
    },
  },
  plugins: [reactRouter(), tsconfigPaths()],
  server: {
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin",
      "Cross-Origin-Embedder-Policy": "require-corp",
    },
  },
  optimizeDeps: {
    exclude: ["@sqlite.org/sqlite-wasm"],
  },
});

適当なセットアップ的と sqlite-wasm を利用する関数を書き、

export type Sqlite3Static = Awaited<
  ReturnType<Awaited<typeof import("@sqlite.org/sqlite-wasm")>["default"]>
>;
export type Database = ReturnType<typeof connect>;

// ⚠️サーバーサイドで import すると ReferenceError: self is not defined になる
export const sqliteInstance =
  typeof window === "undefined"
    ? undefined
    : import("@sqlite.org/sqlite-wasm").then((v) => v.default());

export function connect(sqlite: Sqlite3Static) {
  return new sqlite.oo1.JsStorageDb("local");
}

export function initializeTables(db: Database) {
  db.exec({
    sql: `
      CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        age INTEGER
      )
    `,
  });
}

export function getTableNames(db: Database): string[] {
  return db
    .exec({
      sql: `SELECT name FROM sqlite_master WHERE type='table'`,
      rowMode: "object",
      returnValue: "resultRows",
    })
    .map(({ name }) => name?.toString() || "");
}

react-router 上で呼んで、

import { useEffect, useState } from "react";
import {
  connect,
  getTableNames,
  initializeTables,
  sqliteInstance,
} from "~/libs/sqlite";

export default function Home() {
  const [info, setInfo] = useState<string[]>([]);

  useEffect(() => {
    sqliteInstance?.then(connect).then((db) => {
      initializeTables(db);
      setInfo(getTableNames(db));
      db.close();
    });
  }, []);

  return <pre>{JSON.stringify(info, null, 2)}</pre>;
}

表示を確認します。

[
  "users",
  "sqlite_sequence"
]

テストできるようにする

テストファイル

適当なテストを書いて、

import { expect, test, vi } from "vitest";
import {
  connect,
  getTableNames,
  initializeTables,
  sqliteInstance,
} from "~/libs/sqlite";

test("has users", async () => {
  // arrange
  const sqlite = await sqliteInstance!;
  const db = connect(sqlite);
  initializeTables(db);

  // act
  const tables = getTableNames(db);

  // assert
  expect(tables).include("users");
});

このまま走らせると sqliteundefined なので普通に例外で落ちます。

vitest ブラウザモードにする

vitest には実験的機能としてブラウザ上で動作させるモードがありますので、それを有効にします。

詳しい導入方法は https://vitest.dev/guide/browser/ にあるので、ササッと。

初期化して、

npx vitest init browser

vitest.config.ts を作成。

/// <reference types="vitest" />
import { defineConfig } from "vite";
import * as path from "path";

export default defineConfig({
  test: {
    alias: {
      "~": path.resolve(__dirname, "./app"),
    },
    browser: {
      provider: "playwright", // or 'webdriverio'
      enabled: true,
      name: "chromium", // browser name is required
      headless: true,
    },
  },
});

vitest エラーを解決する

これでテストを走らせると以下のエラーがでます。

 FAIL |0|  app/libs/sqlite.test.ts > 
RuntimeError: Aborted(both async and sync fetching of the wasm failed). Build with -sASSERTIONS for more info.
 ❯ abort ../../../../node_modules/.vite/deps/@sqlite__org_sqlite-wasm.js:203:15
 ❯ ../../../../node_modules/.vite/deps/@sqlite__org_sqlite-wasm.js:244:9

ブラウザ上で動かす wasm ファイルは必ずいずこかからダウンロードする必要があるのですが、それが失敗しているというエラーです。

headless: false で立ち上がるブラウザで確認するとダウンロード失敗している模様が確認できます。

node_modules/.vite/deps は Vite がキャッシュを保存する場所?らしいのですが、任意のファイルを配置する方法はわかりませんでした。plugin などで無理やりコピーしても、タイミングが早いせいか、初回起動時は削除されてしまいます。

sqlite-wasm のダウンロード先を明示的に指定する

そこで sqlite-wasm のコードを眺めていると、wasm ファイルのダウンロード先を指定できることがわかりました。

declare type InitOptions = {
  // これ
  locateFile?: (path: string, prefix: string) => string;
  print?: (msg: string) => void;
  printErr?: (msg: string) => void;
};

vitest を利用しているときは手動配置して配信される wasm ファイルを指定することにします。

node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasmpublic/sqlite3.wasm としてコピーした上で sqlite-wasm 初期化メソッドに locateFile を指定します。

const locateFile =
  process.env.NODE_ENV === "test"
    ? (file: string, prefix: string) => {
        return file === "sqlite3.wasm"
          ? `/${file}`
          : new URL(file, prefix).toString();
      }
    : undefined;

// ⚠️サーバーサイドで import すると ReferenceError: self is not defined になる
export const sqliteInstance =
  typeof window === "undefined"
    ? undefined //                                                👇👇👇
    : import("@sqlite.org/sqlite-wasm").then((v) => v.default({ locateFile }));

テストする

以上の設定でテストが完遂するようになりました。

|0| app/libs/sqlite.test.ts (1)
   ✓ has users

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  12:39:52
   Duration  868ms (transform 0ms, setup 0ms, collect 16ms, tests 77ms, environment 0ms, prepare 247ms)

まとめ

テストできなくて散々ググったのですが、みなさん特に困っておられないので、自分のためにとりあえずの解決策をひねり出しました。

スマートな方法をご存知の方はご教示いただければ幸いです。

Discussion