このページで分かること

  • @emdash-cms/plugin-test の役割と、2種類のテストホスト(createPluginTestHost()createPluginRuntimeTestHost())の使い分け
  • Vitestの設定と、フック・ルート・ストレージのテスト、テスト用のコンテンツの用意
  • EmDashの処理を通したテストと、このテストでは確かめられないこと(管理画面の描画、Cloudflareのリソース制限など)
難易度
上級
読む時間
4分
このページの目次

@emdash-cms/plugin-test は、サンドボックス型プラグインをビルドし、ローカルのWorker Loaderのバインディングを通してそのテストを実行します。テストでは、EmDashの本番用のCloudflareサンドボックスのラッパーと PluginBridge を使います。ローカルのD1とWorker Loaderのバインディングは、@cloudflare/vitest-plugin が提供します。

テストしたい動作に合ったホストを選びます。

  • createPluginTestHost() は、サンドボックスの通信経路をまたいで、フックやルートを直接呼び出します。シリアライズ、権限の強制、プラグインのストレージ、ルートハンドラーの処理のテストに使います。
  • createPluginRuntimeTestHost() は、実際のEmDashのコンテンツ、プラグインの有効化、メディア、コメント、予定されたタスク、プラグインのルートの操作を実行します。ホスト側の操作がプラグインに届くことを確かめる必要があるテストに使います。

emdash-plugin init で作成したプロジェクトには、この設定が含まれています。既存のプラグインのプロジェクトでは、テストホストを開発用の依存パッケージとしてインストールできます。

pnpm add -D @emdash-cms/plugin-test vitest

プロジェクトで依存パッケージのビルドスクリプトを制限している場合は、workerd がプラットフォーム用のバイナリをインストールできるように許可します。生成されるpnpmの方針には、次の項目が含まれています。

pnpm-workspace.yaml
allowBuilds:
  workerd: true
本サイトの補足 やさしい解説

@emdash-cms/plugin-test が本番と同じサンドボックスの仕組みを手元に用意し、Vitestからプラグインのフックやルートを呼び出します。フックやルートを直接呼ぶだけなら createPluginTestHost()、コンテンツの保存などEmDash側の操作からプラグインが呼ばれることまで確かめるなら createPluginRuntimeTestHost() を使います。

Vitestの設定

プロジェクトのVitestの設定に、EmDashのテスト用プラグインを追加します。

vitest.config.ts
import { emdashPluginTest } from "@emdash-cms/plugin-test/config";
import { defineConfig } from "vitest/config";

export default defineConfig({
	plugins: [emdashPluginTest()],
});

emdashPluginTest() は、Vitestが起動する前にプラグインをビルドします。生成された実行時のファイルとマニフェストを読み取り、分離されたD1データベースとWorker Loaderのバインディングを作成し、Cloudflareへのデプロイで使うものと同じ PluginBridge をエクスポートします。Vitestの設定がプラグインのディレクトリの外にある場合は、{ dir: "./packages/gallery" } を渡します。

サンドボックスの通信経路のテスト

各テストの中でホストを作成し、破棄します。破棄するとプラグインが停止し、テスト用のバインディングがリセットされます。

tests/plugin.test.ts
import { afterEach, describe, expect, it } from "vitest";

import { createPluginTestHost, type PluginTestHost } from "@emdash-cms/plugin-test";

let host: PluginTestHost | undefined;

afterEach(async () => {
	await host?.dispose();
	host = undefined;
});

describe("health route", () => {
	it("identifies the plugin", async () => {
		host = await createPluginTestHost();

		await expect(host.invokeRoute("health")).resolves.toEqual({
			ok: true,
			plugin: "save-log",
		});
	});
});

invokeRoute() は、入力値と、省略可能なリクエストのプロパティを受け取ります。デフォルトのリクエストは、プラグインのルートへの POST で、ヘッダーとリクエストのメタデータは空です。

直接の呼び出しでは、EmDashのルートの認証、権限、トークンのスコープ、クロスサイトリクエストフォージェリ(CSRF)、キャッシュの方針は確認されません。これらを確認するには、ランタイムホストの host.actions.routes.request() を使います。

フックとストレージのテスト

EmDashから受け取るのと同じ形のイベントで、フックを呼び出します。ストレージとKVの読み取り用の関数で、ブリッジを通して書き込まれた状態を調べます。

tests/plugin.test.ts
host = await createPluginTestHost();

await host.invokeHook("content:afterSave", {
	collection: "posts",
	content: { id: "post-1", title: "First post" },
});

const events = await host.storage("events").list();
expect(events).toHaveLength(1);
expect(events[0]?.data).toMatchObject({
	collection: "posts",
	contentId: "post-1",
});

ストレージの呼び出しでは、引き続き emdash-plugin.jsonc で宣言したコレクションだけが許可されます。コンテンツ、メディア、ユーザー、メール、ネットワークの呼び出しでは、引き続きプラグインが宣言した権限と許可ホストが強制されます。

コンテンツの用意

サイトのコンテンツを読むルートやフックを呼び出す前に、コレクションを作成してエントリーを入れておきます。

tests/plugin.test.ts
host = await createPluginTestHost();
await host.createCollection({
	slug: "posts",
	label: "Posts",
	fields: [{ slug: "title", label: "Title", type: "string" }],
});
await host.seedContent("posts", [{ title: "First" }, { title: "Second" }]);

await expect(host.invokeRoute("post-count")).resolves.toEqual({ count: 2 });

コレクションとエントリーには、D1に対して動く実際のEmDashのスキーマレジストリとコンテンツリポジトリが使われます。

ホストの操作のテスト

結果がEmDashによる処理の組み立てに左右される場合は、ランタイムホストを作成します。フィクスチャーは、プラグインのフックを発火させずに初期状態を書き込みます。アクションは本番のランタイムやハンドラーの境界を呼び出し、インスペクターはプラグインのコードを呼び出さずに観察できる状態を読み取ります。

content:beforeSave フックでタイトルの末尾に [checked] を付けるプラグインの場合、次のテストで、コンテンツの保存がフックに届くことを確かめられます。

tests/plugin.test.ts
import { afterEach, describe, expect, it } from "vitest";

import {
	createPluginRuntimeTestHost,
	type PluginRuntimeTestHost,
} from "@emdash-cms/plugin-test";

let host: PluginRuntimeTestHost | undefined;

afterEach(async () => {
	await host?.dispose();
	host = undefined;
});

describe("content save", () => {
	it("applies the plugin hook", async () => {
		host = await createPluginRuntimeTestHost();
		await host.fixtures.collection({
			slug: "posts",
			label: "Posts",
			fields: [{ slug: "title", label: "Title", type: "string" }],
		});

		const result = await host.actions.content.create("posts", {
			data: { title: "First post" },
		});

		expect(result).toMatchObject({
			success: true,
			data: { item: { data: { title: "First post [checked]" } } },
		});
	});
});

ランタイムホストのAPIは、境界ごとにまとめられています。

  • transport は、通信経路のレベルの確認のために、isolateを直接呼び出します。
  • fixtures は、フックを発火させずに、サイト、コレクション、フィールド、ユーザー、コンテンツ、プラグインの状態を作成します。
  • actions は、コンテンツの状態の変更、プラグインの有効化と無効化、メディアのアップロード、公開ページからのコメントの投稿、コメントのモデレーション、方針が確認されるプラグインのルートを実行します。
  • inspect は、コンテンツ、プラグインのストレージ、KV、設定、プラグインの状態、予定されたタスク、メディア、コメント、捕捉したメールを読み取ります。
  • scheduled は、cronのタスクと予約公開で使う現在時刻を制御し、本番と同じメンテナンスの処理を1回実行します。
  • restart() は、D1、プラグインのストレージ、メディアのストレージ、プラグインの状態を保ったまま、ランタイムとisolateを置き換えます。

各テストのあとで dispose() を呼び出します。破棄するとisolateが終了し、すべてのバインディングがリセットされるため、後のテストから前のホストのデータベースやメディアが見えることはありません。

ランタイムホストが提供するのは、リリース済みの操作だけです。翻訳、公開の方針、Block Kitのインタラクション、バイナリーのHTTP、そのままのルート、タクソノミー、リダイレクト、拡張されたコメントの管理、メディアのバイト列、暗号化された設定のための、機能ごとのヘルパーは、それらの機能を追加するリリースで提供されます。

テストの範囲

デフォルトのVitestの設定は、プラグイン開発で最も速い本番用のサンドボックスの経路であるWorker Loaderを使います。EmDash自体は、同等のランタイムのコンテンツと再起動の一連の流れを、Node.jsのworkerdランナーでも実行しています。プラグインがランナーによって変わる動作に依存する場合は、Node/workerd用のジョブを別に、任意で追加します。生成されたプロジェクトは、デフォルトでは両方のランナーを実行しません。

どちらのホストも、EmDashの管理画面のアプリケーションを描画せず、デプロイされたCloudflareでのCPU、メモリー、サブリクエストの制限も再現しません。ブラウザーでの一連の操作は使い捨てのEmDashのサイトで確認し、制限に左右される動作はCloudflareのプレビューまたはステージングのデプロイで確認します。

Block Kitのハンドラーについては、プラグインの admin ルートを呼び出し、返されたブロックのドキュメントを検証します。描画されたレイアウトや操作は、Block Playgroundまたはブラウザーでの一連の操作で確認します。

本サイトの補足 やさしい解説

このページのテストで確かめられるのは、プラグインのコードがサンドボックスの中で期待どおりに動くかどうかです。管理画面での見た目や、Cloudflareに実際にデプロイしたときのCPUやメモリーの制限は確かめられません。それらは、使い捨てのサイトやCloudflareのプレビュー環境で別に確認します。