Fresh(Deno) の Plugin のテスト自動化する(in github actions)

Deno Advent Calendar 2023 13 日目の記事です。

Fresh のプラグインをいくつか作って公開しているが、公開するからにはちゃんとテストも書きたかった。
そして昨今Freshの動きがなかなか激しく、新しいバージョンでも動作するための定期実行するテストも用意したかった。

数件実装して、方法が固まったので書き残したい。同じような需要の人に答えられたらうれしい。

参考

テストの実装方針

github actions 上でのテストを用意するにあたり、以下を要件にする。

  • Fresh の最新版に対してテストする
  • Fresh 本体への直接的な書き換えを伴う拡張をせずにテストする
  • テストのカバレッジの取得、表示をする

この要件を達成するために、以下の事を行う。

プラグインAを導入することで、動作に干渉/拡張するレスポンスを返す「routes(及びハンドラ)」を設定するテスト用のプラグインBを作る。
以下順を追って説明する。

ディレクトリ構成

ディレクトリ構成は、あまり特別なことはしていない認識。
基本的な構造としては次のようになる想定。

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
|   .gitignore
| deno.json
| deps.ts
| LICENSE
| mod.ts
| README.md
+---.github
| \---workflows
| test.yml
+---src
| | consts.ts
| | type.ts
| +---handlers
| | csrf_handler.ts
| +---plugins
| | csrf_plugin.ts
| \---utils
| request.ts
| response.ts
\---tests
| csrf_test.ts
| test_deps.ts
+---config
| csrf_fresh.config.ts
+---plugins
| test_plugin.ts
\---routes
test_route.tsx

先に上げた プラグインAの機能を使うroutesを拡張するプラグインBが、testディレクトリ以下のpluginなどになる。

github actions の job定義

github actions テストは概ね以下のように定義している。

.github/workflows/test.yml
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
name: Test

on:
push:
branches: ["main", "test"]
pull_request:
branches: ["main", "test"]
schedule:
# Run every Monday at 00:00 UTC
- cron: '0 0 * * 1'

jobs:
test:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Setup repo
uses: actions/checkout@v3

- name: Setup Deno
uses: denoland/setup-deno@v1

- name: Check version
run: deno -V

- name: Verify formatting
run: deno fmt --check

- name: Install Fresh # 1.
run: deno run -A https://deno.land/x/fresh/init.ts ./tests/work --force --twind --tailwind --vscode

- name: Move deno.json # 2.
run: mv ./tests/work/deno.json ./deno.json

- name: View deno.json # 3.
run: cat ./deno.json

- name: Run tests # 4.
run: deno test --unstable --allow-read --allow-write --allow-env --allow-net --no-check --coverage=./coverage

- name: View coverage # 5.
run: |
# reference: https://github.com/jhechtf/code-coverage
deno install --allow-read --no-check -n code-coverage https://deno.land/x/code_coverage/cli.ts &&
deno coverage --exclude=tests/work/ --lcov --output=lcov.info ./coverage/ &&
code-coverage --file lcov.info
  1. Fresh を自動でtests以下のディレクトリにインストールする
    ポイントになるのは、--twind などのオプションで各種質問をスキップすること。
    tests/work をインストール対象にしているが、ぶつからなければtests以下のどこでもよい。
    Freshのインストール時の質問が増えた時には、このテストは壊れる予定。
  2. deno.json を移動する
    deno.json は階層を上にたどってくれるが、より深いほうに探索してくれない(と認識している)ので、移動する。
    これをしておくと、上の階層で $fresh/server.ts を参照していたとしても、移動したdeno.jsonに記載のimportsが参照される。
    このことで最新のFreshが参照されることになる。
  3. deno.json の中身を表示
    仮にテストが失敗した時にどのバージョンで失敗したのか知るには出力させるのが楽。
  4. テスト実行
    Deno KV がかかわるもののテストをしているので --unstable が入っているが、基本不要。
    カバレッジを出力しておく。
  5. jhechtf/code-coverage をインストールし、コードカバレッジを出力。
    これも、github actions のログで確認できる。

プラグインAの機能を使うテスト用プラグインB

fresh_csrfを例に紹介します。

fresh_csrfプラグインの機能は、CSRF対策用のトークンの発行/検証をする。
なので、この発行されたトークンを使用する機能を使うプラグインを用意する。

fresh.config.ts の代わり

Fresh をインストールすると、fresh.config.ts が作成されている。このファイルは、dev.ts main.ts から参照され、各種プラグインの設定などができる。

このファイルを別の内容で呼び出せば、Freshのインストール直後の状態のまま、別のプラグインが適用できる。

tests/config/csrf_fresh.config.ts
1
2
3
4
5
6
7
8
9
10
11
/// <reference lib="deno.unstable" />
import { defineConfig } from "$fresh/server.ts";
import { getCsrfPlugin } from "../../mod.ts"; // <= プラグイン本体
import { testPlugin } from "../plugins/test_plugin.ts"; // <= テスト用プラグイン

export default defineConfig({
plugins: [
await getCsrfPlugin(await Deno.openKv(":memory:")),
testPlugin,
],
});

今回の場合はroutesの設定なので、あまり気にせずプラグインを登録できる。
もし拡張した機能をミドルウェアで使う場合、テスト用ミドルウェアは機能提供するミドルウェアの後に記述する。
書いた順番がミドルウェアの呼び出し順序と一致するはず。

テスト用プラグイン

テスト用のプラグインは以下のようにしている。

tests/plugins/test_plugin.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { PageProps, Plugin } from "$fresh/server.ts";
import TestComponent, { handler } from "../routes/test_route.tsx";
import { ComponentType } from "preact";

export const testPlugin: Plugin = {
name: "TestPlugin",
routes: [
{
handler,
component: TestComponent as ComponentType<PageProps>,
path: "/csrf",
},
{
handler,
component: TestComponent as ComponentType<PageProps>,
path: "/sub/csrf",
},
],
};

ここについては、特にひねったところはない。テスト用のコンポーネントと、ハンドラを設定しているだけ。
同じコンポーネントを複数のパスに設定もできる。
プラグインを使うとFreshのディレクトリベースルーティングから外れることができるのでこういう楽もできる。

テスト用 routes

テスト用プラグイン で読み込んでいたroutesが次のようになる。

tests/routes/test_route.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
import { FreshContext, Handlers, PageProps } from "$fresh/server.ts";
import type { WithCsrf } from "../../mod.ts";

export const handler: Handlers<unknown, WithCsrf> = {
async GET(_req: Request, ctx: FreshContext) {
const res = await ctx.render();

return res;
},
async POST(
req: Request,
ctx: FreshContext<WithCsrf>,
) {
const form = await req.formData();
const token = form.get("csrf");
const text = form.get("text");

// プラグインが提供するctx.state.csrf.~ 以下の機能を使う
if (!ctx.state.csrf.csrfVerifyFunction(token?.toString() ?? null)) {
const res = new Response(null, {
status: 302,
headers: {
Location: "/csrf",
},
});

return res;
}
ctx.state.csrf.updateKeyPair();

const res = await ctx.render({ text });

return res;
},
};

export default function Test(
props: PageProps<{ text: string }, WithCsrf>,
) {
return (
<div>
<p>{props?.data?.text || "NO SET"}</p>
<form method="post">
<!-- プラグインが提供するctx.state.csrf.~ 以下の機能を使う -->
<input
type="hidden"
name="csrf"
value={props.state.csrf.getTokenStr()}
/>
<input type="text" name="text" />
<button class="button border">Submit</button>
</form>
</div>
);
}

先の通り、プラグインの機能を使うroutesにする必要がある。
handlerでトークンの検証。コンポーネントでトークンの設定をしている。

テストコード

テストコードは以下のようになる。

tests/csrf_test.ts(抜粋)
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
import { createHandler, ServeHandlerInfo } from "$fresh/server.ts";
import manifest from "./work/fresh.gen.ts";
import config from "./config/csrf_fresh.config.ts";
import { expect, FakeTime } from "./test_deps.ts";

const CONN_INFO: ServeHandlerInfo = {
remoteAddr: { hostname: "127.0.0.1", port: 53496, transport: "tcp" },
};

Deno.test("Csrf Test", async (t) => {
await t.step("Get Tokens", async () => {
const handler = await createHandler(manifest, config); // 1.
const res = await handler(new Request("http://127.0.0.1/csrf"), CONN_INFO); // <= 2.
expect(res.status).toBe(200);

const text = await res.text();
expect(text.includes("<p>NO SET</p>")).toBeTruthy();

const csrfCookieToken = res.headers
.get("set-cookie")!
.split("csrf_token=")[1]
.split(";")[0]; // <= 3.
const csrfToken = text
.split('<input type="hidden" name="csrf" value="')[1]
.split('"/')[0]; // <= 4.

expect(csrfCookieToken).not.toMatch(/^$/);
expect(csrfToken).not.toMatch(/^$/);
});

// 以下省略
});
  1. テスト用プラグインで用意したconfigを設定する

  2. テスト用のプラグインで設定したroutesにアクセス

  3. レスポンスに、機能提供するプラグインが設定したCookieの設定検証する

  4. レスポンスボディ本体の設定箇所にトークンが埋まっていることを検証する。

このテストでは、機能提供するプラグインの機能を使ったテスト用プラグインのレスポンスを検証している。
このことで、最新のFreshを使いつつプラグインの機能を使ったレスポンスの検証ができる。

実行結果

github actions のログには次のように出力される。

actions ログ(抜粋)
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
  Verification Tokens Failed(Illegal Cookie Token) ... ok (8ms)
Verification Tokens Failed(Illegal Token) ... ok (5ms)
Verification Tokens Failed(Not set Cookie token) ... ok (4ms)
Verification Tokens Failed(Not set Token) ... ok (6ms)
Verification Tokens Failed(Token Time Out) ... ok (5ms)
Csrf Test ... ok (67ms)

ok | 9 passed (8 steps) | 0 failed (194ms)

Run # reference: https://github.com/jhechtf/code-coverage
Download https://deno.land/x/code_coverage/cli.ts
Warning Implicitly using latest version (0.3.1) for https://deno.land/x/code_coverage/cli.ts
Download https://deno.land/x/code_coverage@0.3.1/cli.ts
Download https://deno.land/x/code_coverage@0.3.1/args.ts
Download https://deno.land/x/code_coverage@0.3.1/deps.ts
Download https://deno.land/x/code_coverage@0.3.1/mod.ts
Download https://deno.land/std@0.208.0/streams/text_line_stream.ts
Download https://deno.land/x/ascii_table@v0.1.0/mod.ts
Download https://deno.land/x/code_coverage@0.3.1/projectCoverage.ts
Download https://deno.land/x/code_coverage@0.3.1/fileCoverage.ts
Download https://deno.land/x/code_coverage@0.3.1/range.ts
✅ Successfully installed code-coverage
/home/runner/.deno/bin/code-coverage
.-----------------------------------------------------------------------.
| File Path | Coverage | Lines Without Coverage |
|-----------------------------------|----------|------------------------|
| deps.ts | 100.00% | n/a |
| mod.ts | 100.00% | n/a |
| src/consts.ts | 100.00% | n/a |
| src/handlers/csrf_handler.ts | 96.67% | 122,136-138 |
| src/plugins/csrf_plugin.ts | 100.00% | n/a |
| tests/config/csrf_fresh.config.ts | 100.00% | n/a |
| tests/plugins/test_plugin.ts | 100.00% | n/a |
| tests/routes/test_route.tsx | 100.00% | n/a |
| tests/test_deps.ts | 100.00% | n/a |
| Totals: | 98.28% | |
'-----------------------------------------------------------------------'

(有意なテストが書けているのかという話は別にあれ、カバレッジとしては悪い数字ではないはず。)


github actions 上で Fresh のプラグインを最新のFreshに適用してテストしてみました。

いくつかのリポジトリで稼働させてパターンが固まったので今後もこの方法で対応して行く予定。

まだ、ミドルウェアの内部的な呼び出しをspyなりするのは実績が無い。
これもテスト用に用意するミドルウェアを何かしら加工して対応できる見込み。
ただし、今回のケースよりは煩雑なものになってくるはず。

では。