Set up準備する
In the folder of your index.html, create package.json:
index.htmlのあるフォルダに、package.jsonを作ります。
{
"private": true,
"type": "module",
"scripts": {
"test": "vitest run"
}
}
Install the test runner, a DOM for Node, and the wcstack packages the page uses:
テストランナー、Node用のDOM、ページが使うwcstackのパッケージを入れます。
$ npm install -D vitest happy-dom @wcstack/testing @wcstack/server @wcstack/state @wcstack/fetch @wcstack/storage @wcstack/router
Tell Vitest to give the tests a DOM:
Vitestに、テストへDOMを用意するよう伝えます。
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { environment: "happy-dom" },
});
The page itself doesn't change: it still loads the packages from the CDN. The installed copies are for the tests.
ページ自体は変わりません。パッケージはこれまでどおりCDNから読み込みます。インストールしたものはテスト用です。
Write the testsテストを書く
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { readFileSync } from "node:fs";
import { mount, settle, fire } from "@wcstack/testing";
import { bootstrapState } from "@wcstack/state";
import { bootstrapFetch } from "@wcstack/fetch";
import { bootstrapStorage } from "@wcstack/storage";
import { bootstrapRouter } from "@wcstack/router";
import "../product-card.js";
// The page's <body>. Its <head> loads the packages from the CDN, which a test
// does not need: mount() registers the elements imported above.
const body = /<body>([\s\S]*)<\/body>/.exec(readFileSync("index.html", "utf8"))[1];
// Three products are enough to test with, and they never change.
const catalog = [
{ id: 1, name: "House Blend", category: "coffee", description: "Chocolate, caramel.", price: 14, stock: 12, icon: "☕" },
{ id: 4, name: "Sencha", category: "tea", description: "Fresh, grassy.", price: 12, stock: 20, icon: "🍵" },
{ id: 5, name: "Matcha", category: "tea", description: "Stone-ground.", price: 24, stock: 0, icon: "🍵" },
];
const names = () => [...app.root.querySelectorAll("product-card:not([hidden]) h3")].map((h) => h.textContent);
const card = (name) => [...app.root.querySelectorAll("product-card")].find((c) => c.textContent.includes(name));
let app;
beforeEach(async () => {
localStorage.clear();
vi.stubGlobal("fetch", async () => Response.json(catalog));
app = await mount(body, { bootstrap: [bootstrapState, bootstrapFetch, bootstrapStorage, bootstrapRouter] });
await vi.waitFor(() => expect(names()).toHaveLength(3));
});
afterEach(() => {
app.unmount();
vi.unstubAllGlobals();
});
describe("the shop", () => {
it("shows the catalog", () => {
expect(names()).toEqual(["House Blend", "Sencha", "Matcha"]);
});
it("adds to the cart, totals it and keeps it", async () => {
fire(card("Sencha").querySelector("button"), "click");
fire(card("Sencha").querySelector("button"), "click");
await settle();
expect(app.root.querySelector("dd.grand").textContent).toBe("$29.00");
expect(JSON.parse(localStorage.getItem("little-roastery-cart"))).toEqual([{ id: 4, quantity: 2 }]);
});
it("does not sell what is sold out", () => {
expect(card("Matcha").querySelector("button").disabled).toBe(true);
});
it("filters by search", async () => {
const search = app.root.querySelector('input[type="search"]');
search.value = "sen";
fire(search, "input");
await settle();
expect(names()).toEqual(["Sencha"]);
});
it("opens a product page", async () => {
await app.root.querySelector("wcs-router").navigate("/products/4");
await vi.waitFor(() => expect(app.root.querySelector(".product-page h2")?.textContent).toBe("Sencha"));
});
});
Run them with npm test. Add -- --reporter=verbose to see every test by name:
npm testで実行します。-- --reporter=verboseを付けると、テストが1つずつ名前付きで出ます。
$ npm test -- --reporter=verbose RUN v5.0.4 …/little-roastery ✓ tests/shop.test.js > the shop > shows the catalog ✓ tests/shop.test.js > the shop > adds to the cart, totals it and keeps it ✓ tests/shop.test.js > the shop > does not sell what is sold out ✓ tests/shop.test.js > the shop > filters by search ✓ tests/shop.test.js > the shop > opens a product page Test Files 1 passed (1) Tests 5 passed (5) Duration 1.04s
How it worksしくみ
The real page, without its head本物のページを、headを除いて
The test reads index.html and mounts its <body>: the state script, the tags and the templates are the ones the browser runs. The <head> is left out, because it loads the packages from the CDN; instead, the bootstrap list registers the installed ones, and the test imports product-card.js itself. mount() resolves once the state is built and the router has shown its first route.
テストはindex.htmlを読み、その<body>をマウントします。stateのスクリプトも、タグも、テンプレートも、ブラウザが動かすものと同じです。<head>はCDNからパッケージを読み込むので外し、代わりにbootstrapの一覧がインストールしたものを登録し、テストがproduct-card.jsを自分でimportします。mount()は、stateができあがり、ルーターが最初のルートを表示した時点で終わります。
A catalog that never changes変わらない商品一覧
<wcs-fetch> calls the global fetch, and vi.stubGlobal replaces it with one that returns three products. The tests no longer need the network, and they don't break when the real catalog gets a new product. The catalog still arrives a moment after mount(), as in the browser, so vi.waitFor waits until three cards are there.
<wcs-fetch>はグローバルのfetchを呼び、vi.stubGlobalがそれを3つの商品を返すものに置き換えます。テストはネットワークを必要とせず、本物の商品一覧に商品が増えても壊れません。商品一覧はブラウザと同じくmount()の少し後に届くので、vi.waitForでカードが3枚になるまで待ちます。
Act, then settle操作して、落ち着くのを待つ
fire() dispatches what a user would cause — a click, an input — and settle() waits until the page has caught up. Then you check the DOM (the total reads $29.00) and anything else the page touched (the cart in localStorage). A product page is one navigate() away.
fire()は、ユーザーが起こすもの(clickやinput)を送り、settle()はページが追いつくまで待ちます。それからDOM(合計が$29.00になる)や、ページが触ったほかのもの(localStorageのカート)を確かめます。商品ページはnavigate()1つで開けます。
Break the component on purpose: in product-card.js, change textContent: name to textContent: nmae. The checker still says nothing; npm test doesn't:
わざと部品を壊してみましょう。product-card.jsのtextContent: nameをtextContent: nmaeに変えます。検査器は何も言いませんが、npm testは違います。
$ npm test × shows the catalog × adds to the cart, totals it and keeps it × does not sell what is sold out × filters by search FAIL tests/shop.test.js > the shop > shows the catalog AssertionError: expected [ '', '', '' ] to deeply equal [ 'House Blend', 'Sencha', 'Matcha' ] … Tests 4 failed | 1 passed (5)
What these tests don't seeこのテストに見えないもの
happy-dom is not a browser. The tests don't click a real link (the router's in-place navigation needs the browser's Navigation API, so the test calls navigate()), and they don't see when a custom element is upgraded or how a real browser orders its events. Keep opening the shop in a browser after a change that touches those; a browser test runner such as Playwright can automate it.
happy-domはブラウザではありません。テストは本物のリンクをクリックしません(ルーターのその場での遷移にはブラウザのNavigation APIが要るので、テストはnavigate()を呼びます)。カスタム要素がいつアップグレードされるかや、本物のブラウザのイベントの順番も見えません。そこに触れる変更の後は、引き続きブラウザでショップを開いて確かめましょう。Playwrightのようなブラウザのテストランナーで自動化することもできます。
The whole filesファイル全体
package.json after the install (your version numbers may be newer). index.html and product-card.js are those of step 14.
インストール後のpackage.jsonです(バージョンはこれより新しいかもしれません)。index.htmlとproduct-card.jsはステップ14のものです。
Show package.jsonpackage.jsonを表示
{
"private": true,
"type": "module",
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"@wcstack/fetch": "^4.0.4",
"@wcstack/router": "^4.0.4",
"@wcstack/server": "^4.0.4",
"@wcstack/state": "^4.0.4",
"@wcstack/storage": "^4.0.4",
"@wcstack/testing": "^4.0.4",
"happy-dom": "^21.0.1",
"vitest": "^5.0.4"
}
}