Test itテストする

Clicking through the shop after every change stops scaling, and the checker can't see inside product-card.js. Tests can: Vitest runs your real page in Node with happy-dom, and @wcstack/testing mounts it, clicks, and waits for the page to update — in about a second.

変更のたびにショップをクリックして確かめるのは、すぐに追いつかなくなります。検査器はproduct-card.jsの中を見ません。テストなら見られます。Vitestがhappy-domの上で本物のページをNodeで動かし、@wcstack/testingがそれをマウントし、クリックし、ページの更新を待ちます。かかるのは1秒ほどです。

  1. 13
  2. 14
  3. 15
  4. 16

Set up準備する

In the folder of your index.html, create package.json:

index.htmlのあるフォルダに、package.jsonを作ります。

package.jsonnew file新しいファイル
{
  "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のパッケージを入れます。

terminal
$ 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を用意するよう伝えます。

vitest.config.jsnew file新しいファイル
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テストを書く

tests/shop.test.jsnew file新しいファイル
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つずつ名前付きで出ます。

terminal
$ 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つで開けます。

Try itやってみよう

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は違います。

terminal
$ 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を表示
package.jsonstep 15ステップ15
{
  "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"
  }
}