Fresh 上の island をテストしたい

Fresh の island をテストしたかった。
それには preact のテストが必要になる。
それもランタイムは、Deno だけで解決したい。

いろいろやって、妥協点が見いだせるようになったので、一旦まとめておきたい。

(2023/10/07追記)
コミュニティーで質問投げたら、fresh_testing_library を使いなよと回答。
この記事でいろいろ書いているが、結論これを使うと解決する。

Deno で Preact をテストしたい時の現状

Preact のコンポーネントをテストするには、おそらく 2 つ程度の方法が正攻法になっている。(公式ドキュメントの記載されているのもこの 2 つ)

  1. Preact Testing Library を使ったテスト
  2. enzyme を使ったテスト

Preact Testing Library は、ドキュメントで DOM 環境でのみ使用できると記載があり、enzyme は jsdom で良いとされている。

この件を調べてみると jsdom 周りは、どうやら Deno の中でも結構鬼門のようで、「できた」と書いてあったり、issue が立っていたりと判然としない。
Preact Testing Library に jsdom を使って対応しようとしているものまである。

いろいろと調べて次のように書いてみたが、イベント発火するところでエラーを起こしてしまう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { DOMParser } from "https://deno.land/x/deno_dom/deno-dom-wasm.ts";
import { assertEquals } from "https://deno.land/std@0.203.0/testing/asserts.ts";
import {
fireEvent,
render,
} from "https://esm.sh/@testing-library/preact?deps=preact@10.15.1";
import { waitFor } from "https://esm.sh/@testing-library/dom";
import Counter from "../../islands/Counter.tsx";
import { signal } from "@preact/signals";

Deno.test("Counter Component", async () => {
const document = new DOMParser().parseFromString(
"<!DOCTYPE html>",
"text/html",
)!;

globalThis.document = document;
window.document = document;

const count = signal(3);
const { container, getByText } = render(<Counter count={count} />);
const counter = container.querySelector("p")!;
assertEquals(counter.textContent, count.value.toString());


fireEvent.click(getByText("-1")); // <== error: Error: The given node is not an Element, the node type is: object.

await waitFor(() => {
assertEquals(counter.textContent, count.value.toString());
});
});

というところで、どうにも判断しがたく。
自分はできませんでしたが、もしかしたら世の中には上手いことテストを書いている人がいる可能性を否定できない。
という気持ち悪い結果に。
もし、「こうすればできるんですよ。」という人がいたら、是非 X で絡んできてほしい。

妥協点としての対応

こうなると、そもそも Fresh のテストはどのようになっているのかが気になるのが人の性。

見てみると、puppeteer を使ったテストが行われている。

https://github.com/denoland/fresh/blob/main/tests/islands_wasm_test.ts

こちらだと、puppeteer の browser 作成のコードはない。が、内部的には puppeteer を使ったときと同じような呼び出しをしている。
https://github.com/denoland/fresh/blob/main/tests/islands_test.ts

startFreshServer を見ると、子プロセスとして Fresh が起動されている事もわかる。
https://github.com/denoland/fresh/blob/40649fbd55fc65eff7ba151b172be1e69320415c/tests/test_utils.ts#L38C1-L38C71

ということで、puppeteer でやればよいという方針は見えてきた。
既存のソースを見ると、どうやら、コンポーネントのテストというよりも island の様子。
あくまでコンポーネントをテストする方針で用意してみた。

Deno と puppeteer が動く実行環境を Docker で用意する

例のごとく、Docker で構築する。

参考にしたのは、こちら。

https://github.com/lucacasonato/deno-puppeteer/issues/16#issuecomment-842784232

実際の記述は以下の通り。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
FROM denoland/deno:debian-1.37.0

RUN apt-get -qq update \
&& apt-get install -y --no-install-recommends \
curl \
ca-certificates \
unzip \
ca-certificates \
fonts-liberation \
libappindicator3-1 \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libc6 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libexpat1 \
libfontconfig1 \
libgbm1 \
libgcc1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libstdc++6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxss1 \
libxtst6 \
lsb-release \
wget \
xdg-utils \
libdrm2 \
libxkbcommon0 \
libxshmfence1 \
unzip \
gnupg \
libappindicator1 \
fonts-liberation \
libu2f-udev \
libvulkan1 \
&& apt --fix-broken install \
&& curl -O https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
&& apt-get install ./google-chrome-stable_current_amd64.deb \
&& curl -O https://chromedriver.storage.googleapis.com/2.37/chromedriver_linux64.zip \
&& unzip chromedriver_linux64.zip \
&& chmod +x chromedriver \
&& mv -f chromedriver /usr/local/share/chromedriver \
&& ln -s /usr/local/share/chromedriver /usr/local/bin/chromedriver \
&& ln -s /usr/local/share/chromedriver /usr/bin/chromedriver \
&& echo 'alias chrome=/usr/bin/google-chrome' >> /root/.bashrc \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get -y -qq autoremove \
&& apt-get -qq clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& PUPPETEER_PRODUCT=chrome deno run -A --unstable https://deno.land/x/puppeteer@16.2.0/install.ts

RUN mkdir /usr/src/app
WORKDIR /usr/src/app

EXPOSE 8000
docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
version: "3"
services:
app:
build:
context: .
dockerfile: Dockerfile
privileged: true
command: tail -f /dev/null
ports:
- "8000:8000"
volumes:
- .:/usr/src/app:cached
tty: true

参考にしたものとはベースのイメージを変えて、Deno 向けコンテナイメージに puppeteer の設定を足し込んだ。
環境は、これで以上。

実装

実装箇所は3つ、fresh.config.ts とテスト用のコンポーネント群とテスト本体。

テスト用のコンポーネント

routes に置くときのように、テスト対象のコンポーネントだけを包んで返す。
これをしなくてもテスト対象コンポーネントだけを渡すこともできるが、パラメータを渡せずに困るはず。
(今回の場合、signal)

1
2
3
4
5
6
7
8
9
10
11
12
// test\components\CounterTest.tsx
import { useSignal } from "@preact/signals";
import Counter from "../../islands/Counter.tsx";

export default function CounterTest() {
const count = useSignal(3);
return (
<>
<Counter count={count} />
</>
);
}
1
2
3
4
5
6
7
8
9
10
// test\components\ButtonTest.tsx
import {Button} from "../../components/Button.tsx";

export default function CounterTest() {
return (
<>
<Button>text</Button>
</>
);
}

fresh.config.ts のカスタマイズ

プラグインを拡張し、DENO_ENV が設定されたときのみ先にコンポーネント群を返すプラグインに差し替える処理を実装した。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { defineConfig } from "$fresh/server.ts";
import twindPlugin from "$fresh/plugins/twind.ts";
import twindConfig from "./twind.config.ts";
import { Plugin } from "$fresh/server.ts";
import CounterTest from "./test/components/CounterTest.tsx";
import ButtonTest from "./test/components/ButtonTest.tsx";

const testPlugin:Plugin = {
name: "testPlugin",
routes: [{
path: "/test/components/counter",
component: CounterTest,
},
{
path: "/test/components/button",
component: ButtonTest,
}],
}

const switchTestPlugin = (defaultPlugins: Plugin[]) => Deno.env.get("DENO_ENV") === "test" ? [testPlugin]: defaultPlugins

export default defineConfig({
plugins: switchTestPlugin([twindPlugin(twindConfig)]),
});

テストコード

テストコードというよりも puppeteer を動かすコード。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// test.ts
import { delay } from "$std/async/delay.ts";
import { assertEquals } from "https://deno.land/std@0.203.0/testing/asserts.ts";
import {
startFreshServer,
} from "https://deno.land/x/fresh@1.4.0/tests/test_utils.ts";
import puppeteer from "https://deno.land/x/puppeteer@16.2.0/mod.ts";

Deno.test({
name: "Component test",
async fn(t) {
const { lines, serverProcess, address } = await startFreshServer({
args: ["run", "-A", "main.ts"],
});
const browser = await puppeteer.launch(
{
headless: true,
ignoreDefaultArgs: ['--disable-extensions'],
args: ['--disable-dev-shm-usage', '--enable-gpu', '--no-sandbox', '--disable-setuid-sandbox']}
);
const page = await browser.newPage();

await t.step("island: Counter component test", async () => {
const res = await page.goto(`${address}/test/components/counter`);
assertEquals(res?.status(), 200);

const pElement = await page.$("p");
const buttonElement = await page.$$("button");

assertEquals(await page.evaluate(element => element.textContent, pElement), "3");

await buttonElement[0].click();
assertEquals(await page.evaluate(element => element.textContent, pElement), "2");

await buttonElement[1].click();
assertEquals(await page.evaluate(element => element.textContent, pElement), "3");
})

await t.step("component: Button component SSR test", async () => {
const res = await page.goto(`${address}/test/components/button`);
assertEquals(res?.status(), 200);
const buttonElement = await page.$("button");
assertEquals(await page.evaluate(element => element.textContent, buttonElement), "text");
})

await browser.close();
await lines.cancel();
await serverProcess.kill("SIGTERM");

// sanitizeOps: false
// sanitizeResources: false
// のオプションを入れるか、少しdelayを入れないと Leaking async ops が発生する
// await delay(100);
},
sanitizeOps: false,
sanitizeResources: false,
});

実行

1
2
3
4
5
6
7
8
$ DENO_ENV=test deno test -A test.ts
running 1 test from ./test.ts
Component test ...
island: Counter component test ... ok (192ms)
component: Button component SSR test ... ok (17ms)
Component test ... ok (457ms)

ok | 1 passed (2 steps) | 0 failed (582ms)

問題点

モック処理などが難しい。
Fresh 自体は、別プロセスで起動しておりテストコード自体ではフォローが難しい。
及第点としては、テスト用のコンポーネントを1枚かませているので、本来のコードと異なるSignalを差し込むことはできた。
それでも、islands 以下にあるものは良いが components 以下に置かれているものはSSRだけされてしまう。
そのため、テストコードでも動作のテストはできなかった。
この辺りは、通常のroutesと同じように処理にされているのでコントロールができない。

この点は、islands をプラグインで作成できる機能のissueとプルリクが出ているので、もう少しまっていると解決する可能性がある。

https://github.com/denoland/fresh/pull/1472

テスト用のコンポーネントを islands として作成し、その中で components 以下のコンポーネントを記述する。
この場合は、ボタン単体の動作を含むテストのようなこともできそうなことが見える。

それでも通信するコンポーネントの通信をモックする事は相変わらず難しい
コンポーネントの中で DENO_ENV を参照するようなことはあまりしたいとは思わない。
なので、このあたりは素直に jsdom が動くと嬉しい。


というわけで、Fresh のコンポーネントを Deno(とpuppeteer) で(islandに限定すれば)及第点程度にはテストができた。
Fresh の次の更新にも期待したい。

では。

fresh_testing_library を使う

(2023/10/07追記)

fresh_testing_library を使うパターンをメモしておく。

fresh_testing_library を使うと、components 以下のコンポーネントも動的なテストができたので、少し改修しておく。

components/Button.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
import { JSX } from "preact";
import { IS_BROWSER } from "$fresh/runtime.ts";

export function Button(props: JSX.HTMLAttributes<HTMLButtonElement> & {dataText?: string}) { // <= 追記
return (
<button
{...props}
disabled={!IS_BROWSER || props.disabled}
data-text={props.dataText} // <= 追記
class="px-2 py-1 border-gray-500 border-2 rounded bg-white hover:bg-gray-200 transition-colors"
/>
);
}
test/components_test.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import {
cleanup,
fireEvent,
render,
setup,
} from "$fresh-testing-library/components.ts";
import { afterEach, beforeAll, describe, it } from "$std/testing/bdd.ts";
import {
assertEquals,
assertExists,
assertFalse,
} from "https://deno.land/std@0.203.0/testing/asserts.ts";
import {
assertSpyCall,
spy,
} from "https://deno.land/std@0.203.0/testing/mock.ts";
import Counter from "../islands/Counter.tsx";
import { Button } from "../components/Button.tsx";
import { signal } from "@preact/signals";

describe("components test", () => {
beforeAll(setup);
afterEach(cleanup);

it("Counter component", async () => {
const count = signal(5);
const screen = render(<Counter count={count} />);
const plusButton = screen.getByRole("button", { name: "+1" });
const minusButton = screen.getByRole("button", { name: "-1" });
assertExists(screen.getByText("5"));

await fireEvent.click(plusButton);
assertFalse(screen.queryByText("5"));
assertExists(screen.getByText("6"));

await fireEvent.click(minusButton);
assertExists(screen.getByText("5"));
assertFalse(screen.queryByText("6"));
});

it("Button component", async () => {
const internalFunc = () => {};
const internalFuncSpy = spy(internalFunc);
const func = (f: () => void) => {
f();
};

let dataText = "test";

const screen = render(
<Button onClick={() => func(internalFuncSpy)} dataText={dataText}>text</Button>,
);

assertExists(screen.getByText("text"));

const textButton = screen.getByText("text");

assertEquals(textButton.dataset.text, dataText);

await fireEvent.click(textButton);
await fireEvent.click(textButton);
assertSpyCall(internalFuncSpy, 0, {});
assertSpyCall(internalFuncSpy, 1, {});

dataText = "update-test";
screen.rerender(
<Button onClick={() => func(internalFuncSpy)} dataText={dataText}>text</Button>,
);
assertEquals(screen.getByText("text").dataset.text, dataText);
});
});

fresh_testing_library をかませると components 以下のコンポーネントも動的なテストができる。

fresh_testing_library は、 handler middleware async route component もテストができる。

ということで、fresh 上の preact は、fresh_testing_library でテストすればいいし、他機能もフォローできるのでこれを使おう。

では。