Deno test と Kysely を組み合わせる

Denoで、Kysely 通して Turso を使うようになって久しいが、テストと組み合わせることはまだやっていなかった。

今回は、Deno testでKyselyを使う方法を試してみる。

参考

実装

方針

方針として、Tusro をメインのDBとして取り扱い、テスト時にはローカルのSQLiteを使うことにする。

最終的には以下のような構成になる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
|- db
| |- migrations
| | `- 20240819114643a.ts
| |- client.ts
| |- file_migration_provider.ts
| |- migrate_tool.ts
| |- migrator.ts
| |- test_client.ts
| `- types.ts
|- deno.jsonc
|- deno.lock
|- docker-compose.yml
|- main.ts
`- main_test.ts

マイグレーションツール改修

以前作成したマイグレーションツールは、接続対象になるクライアントを固定していたため接続先が変わる今回のようなケースと相性が良くない。

前の実装

というわけで、マイグレーションツール周りを改修する。

クライアントの取得

クライアントの取得は、Turso 用とローカルのsqlite用で分けて用意する。

以下の通り2つのファイルに分離する。

db/client.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Kysely } from "kysely";
// Truso 用
import { LibsqlDialect } from "@libsql/kysely-libsql";

export function createTrusoClient<T>() {
const dialect = new LibsqlDialect({
url: Deno.env.get("TURSO_DATABASE_URL")!,
authToken: Deno.env.get("TURSO_AUTH_TOKEN"),
});

return new Kysely<T>({
dialect,
log(event) {
if (event.level === "query") {
console.log("query:", event.query.sql);
console.log("parameters:", event.query.parameters);
}
},
});
}
db/test_client.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
import { Kysely } from "kysely";
// ローカル SQLite 用
import { DB as Sqlite } from "https://deno.land/x/sqlite/mod.ts";
import { DenoSqliteDialect } from "jsr:@soapbox/kysely-deno-sqlite";

export function createTestClient<T>() {
const dialect = new DenoSqliteDialect({
database: new Sqlite("db.sqlite3"), // 引数空にしてメモリDBにすることも可能
});

return new Kysely<T>({
dialect,
// テスト用であればログは出力なくてもよいとしてコメントアウト
// log(event) {
// if (event.level === "query") {
// console.log("query:", event.query.sql);
// console.log("parameters:", event.query.parameters);
// }
// },
});
}

// 後処理でファイルの削除を行いたいので、ファイル削除関数も用意する
export async function removeTestDatabase() {
await Deno.remove("db.sqlite3"); // メモリ上で展開するなら不要
}

この実装、実は1ファイルにまとめて別の関数にすればそれでも解決できる。
だが、Turso 用とSqlite用それぞれのdialectを作る関数が、kyselyとの型チェックをパス出来ないことが起こる。

@libsql/kysely-libsql は、Kysely 0.25.0 系は合致。
jsr:@soapbox/kysely-deno-sqlite は、Kysely 0.27.0 系に合致。
そしてどちらかに合わせると、もう片方が型チェックをパス出来ない。

後の Deno test 実行時の型チェックで、この点指摘があるため、ファイルとして分離し指摘を受けないようにする。
なお、Deno test --no-check で型チェックをスキップも可能だが、今回は避けた。

マイグレーションツールの本体の改修

マイグレーションツールの本体も、クライアントを取得する部分を改修する。

以下、Migrator の取得。

db/migrator.ts
1
2
3
4
5
6
7
8
9
10
11
12
import "https://deno.land/std@0.203.0/dotenv/load.ts";
import { Kysely, MigrationResult, Migrator, NO_MIGRATIONS } from "kysely";
import { FileMigrationProviderOnDeno } from "./file_migration_provider.ts";

export function createMigrator(client: Kysely<any>) {
return new Migrator({
db: client,
provider: new FileMigrationProviderOnDeno("./migrations"),
});
}

export { NO_MIGRATIONS };

それぞれの場所で、クライアントを取得する関数を呼び出すように変更するので、マイグレーターを分離。

以下、マイグレーションツール本体。

db/migrate_tool.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
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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import "https://deno.land/std@0.203.0/dotenv/load.ts";
import { MigrationResult } from "kysely";
import { createTrusoClient } from "./client.ts";
import { Command } from "cliffy";
import { createMigrator } from "./migrator.ts";

// クライアントと、マイグレーターを取得
const client = createTrusoClient();
const migrator = createMigrator(client);

function showReslut(
results: MigrationResult[] | undefined,
error: unknown,
) {
results?.forEach((it) => {
if (it.status === "Success") {
console.log(`migration "${it.migrationName}" was executed successfully`);
} else if (it.status === "Error") {
console.error(`failed to execute migration "${it.migrationName}"`);
}
});

if (error) {
console.error("failed to run `migrateToLatest`");
console.error(error);
}
}

async function doMigrate() {
const { results, error } = await migrator.migrateToLatest();
showReslut(results, error);
}

async function doRollback() {
const { results, error } = await migrator.migrateDown();
showReslut(results, error);
}

const getDateTimeString = () => {
const now = new Date();
const year = now.getFullYear();
const month = `${now.getMonth() + 1}`.padStart(2, "0");
const date = `${now.getDate()}`.padStart(2, "0");
const hour = `${now.getHours()}`.padStart(2, "0");
const minute = `${now.getMinutes()}`.padStart(2, "0");
const second = `${now.getSeconds()}`.padStart(2, "0");
return `${year}${month}${date}${hour}${minute}${second}`;
};

const doCreateMigration = async (fileName: string) => {
const kyselySkeletonText = `import { Kysely, sql } from "npm:kysely@^0.25.0";

export async function up(db: Kysely<any>): Promise<void> {
}

export async function down(db: Kysely<any>): Promise<void> {
}
`;

const path = import.meta
.resolve(`.import { createMigrator } from './migrator';
/migrations/${getDateTimeString()}${fileName}.ts`)
.split("file:///")[1];
Deno.writeTextFileSync(path, kyselySkeletonText, { create: true });
console.info(`created migration file: ${path}`);
};

const { cmd, options, args } = await new Command()
.name("kysely migration tool")
.command("migrate", "migrate to latest.")
.action(async () => {
await doMigrate();
Deno.exit(0);
})
.command("rollback", "rollback 1 migration.")
.action(async () => {
await doRollback();
Deno.exit(0);
})
.command("create <name:string> [name:string]", "create migration.")
.action((_options: any, source: string, _destination?: string) => {
doCreateMigration(source);
Deno.exit(0);
})
.stopEarly()
.parse(Deno.args);

cmd.showHelp();

これで、マイグレーションツールは完成。

テストコードの実装

テストコードは、以下のように実装できる。

main_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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import {
assertEquals,
} from "jsr:@std/assert";
import {
afterAll,
afterEach,
beforeEach,
describe,
it,
} from "jsr:@std/testing/bdd";
import { createMigrator, NO_MIGRATIONS } from "./db/migrator.ts";
import { createTestClient, removeTestDatabase } from "./db/test_client.ts";
import { Database } from "./db/types.ts";

const client = createTestClient<Database>();
const migrator = createMigrator(client);

describe("main", () => {
beforeEach(async () => {
await migrator.migrateToLatest();
});

afterEach(async () => {
await migrator.migrateTo(NO_MIGRATIONS);
});

afterAll(async () => {
await removeTestDatabase();
});

it("add user", async () => {
await client.insertInto("users").values({ name: "Alice" }).execute();

const users = await client.selectFrom("users").selectAll().execute();
assertEquals(users.length, 1);
assertEquals(users[0].name, "Alice");
});

it("add user2", async () => {
await client.insertInto("users").values({ name: "Bob" }).execute();

const users = await client.selectFrom("users").selectAll().execute();
// リセットされたので、usersのテーブルには1件であるはず
assertEquals(users.length, 1);
assertEquals(users[0].name, "Bob");
});
});

個々のテストの前後で、マイグレーションと、ロールバックを行っている。
ロールバックをかけることで、書き込み済み内容を削除している。(database_cleanerのような処理)


というわけで、Deno test でKyselyを組み合わせる方法を試してみた。

クライアントの差し替え方法としては、ファイル分離以外に環境変数で切り替えることも考えられるはず。
例えば APP_ENV=’TEST’ であれば、ローカルのSQLiteを使うといった具合に。
外から渡せるなら、今回のような形で明示的に渡すとわかりやすいのではなかろうか。
コントローラテストなど明示的にクライアントを渡す口が設定できない場合、前者の方が有効にも感じられる。
クライアントをモックし、今回のようなダミーのクライアントを渡すケースもあり得る。

やり方が確立したので、この方法を直近の開発にも取り入れる。

では。