家計簿アプリを作る #59:Vitestによるユニットテスト導入
家計簿アプリ作成シリーズの第59回です。これまで機能追加のたびに手動で動作確認してきましたが、ロジック部分にユニットテストを導入して、リグレッションを検知しやすくします。
Vitestのインストールと設定
npm install -D vitest
-D(--save-dev)は、本番の実行環境には不要で開発・テスト時にのみ使うパッケージを devDependencies に記録するオプションです。
vite.config.ts に test オプションを追加しますが、通常の vite からの defineConfig では型エラーになりました。
オブジェクト リテラルは既知のプロパティのみ指定できます。'test' は型 'UserConfigExport' に存在しません。
vitest/config からの defineConfig に差し替えることで解消しました。
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config'; // vite ではなく vitest/config から
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
},
});
テストの基本形
describe(グループ化)・it(個々のテストケース)・expect(検証)の組み合わせで書きます。
import { describe, it, expect } from 'vitest';
import { formatLocalDate } from './date';
describe('formatLocalDate', () => {
it('日付を YYYY-MM-DD 形式の文字列に変換する', () => {
const date = new Date(2026, 9, 23); // 2026年10月23日
expect(formatLocalDate(date)).toBe('2026-10-23');
});
it('1桁の月・日を0埋めする', () => {
const date = new Date(2026, 0, 5);
expect(formatLocalDate(date)).toBe('2026-01-05');
});
});
1つの it は1つの入力パターンに対する1つの期待結果に絞ることで、失敗したときにどのケースが壊れたか分かりやすくなります。
集計ロジックを純粋関数に切り出す
+page.server.ts のDBアクセスを含まない計算部分(収入・支出の合計計算)を $lib/utils/summary.ts に切り出しました。
type Transaction = { type: 'income' | 'expense'; amount: number };
export function calculateTotals(transactions: Transaction[]) {
const income = transactions.filter(t => t.type === 'income').reduce((sum, t) => sum + t.amount, 0);
const expense = transactions.filter(t => t.type === 'expense').reduce((sum, t) => sum + t.amount, 0);
return { income, expense, balance: income - expense };
}
ハマりポイント:DBのtypeカラムがリテラル型になっていなかった
+page.server.ts から取得した allTransactions を calculateTotals に渡すと型エラーになりました。
型 'string' を型 '"income" | "expense"' に割り当てることはできません。
原因は、categories や recurringTransactions のスキーマでは text('type', { enum: [...] }) でリテラル型になっていたのに、transactions テーブルだけ text('type').notNull() のままプレーンな string 型だったことでした。
// ✅ enumを追加してリテラル型にする(SQL上の列定義は変わらないのでマイグレーション不要)
type: text('type', { enum: ['income', 'expense'] }).notNull(),
enum オプションはTypeScript側の型を絞り込むだけで、実際の列の型(TEXT)は変わらないため、マイグレーションは不要でした。
as const で値をリテラル型に固定する
テストコードで { type: 'income', amount: 300000 } と書くと、TypeScriptはデフォルトで type を string 型に広げて(widen)推論するため、Transaction[] を期待する引数と型が合いませんでした。
// ✅ as constでリテラル型のまま扱う
{ type: 'income' as const, amount: 300000 }
toBeとtoEqualの使い分け
toBe はプリミティブ値の厳密な同一性比較(オブジェクトの場合は参照比較)、toEqual はオブジェクト・配列の中身を再帰的に比較します。
// ❌ オブジェクトの中身が同じでも、参照が違うと失敗する
expect({ income: 100 }).toBe({ income: 100 });
// ✅ 中身を比較したい場合はtoEqual
expect({ income: 100 }).toEqual({ income: 100 });
calculateTotals のようにオブジェクトを返す関数のテストでは toEqual、formatLocalDate のように文字列を返す関数のテストでは toBe を使い分けています。
まとめ
| ポイント | 内容 |
|---|---|
| Vitestの設定 | viteではなくvitest/configからdefineConfigをimportする |
| テストの基本形 | describe/it/expectを組み合わせ、1ケース1itで書く |
| ロジックの切り出し | DBアクセスを含まない純粋関数として抽出するとテストしやすい |
| DBスキーマのenum | text(col, { enum: [...] })はマイグレーション不要でTS側の型だけ絞り込める |
as const |
オブジェクトリテラル内の値がstringに広がるのを防ぎ、リテラル型のまま扱う |
toBeとtoEqual |
プリミティブはtoBe、オブジェクト・配列の中身比較はtoEqual |