家計簿アプリを作る #59:Vitestによるユニットテスト導入

家計簿アプリ作成シリーズの第59回です。これまで機能追加のたびに手動で動作確認してきましたが、ロジック部分にユニットテストを導入して、リグレッションを検知しやすくします。

Vitestのインストールと設定

npm install -D vitest

-D(--save-dev)は、本番の実行環境には不要で開発・テスト時にのみ使うパッケージを devDependencies に記録するオプションです。

vite.config.tstest オプションを追加しますが、通常の 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 から取得した allTransactionscalculateTotals に渡すと型エラーになりました。

型 'string' を型 '"income" | "expense"' に割り当てることはできません。

原因は、categoriesrecurringTransactions のスキーマでは 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はデフォルトで typestring 型に広げて(widen)推論するため、Transaction[] を期待する引数と型が合いませんでした。

// ✅ as constでリテラル型のまま扱う
{ type: 'income' as const, amount: 300000 }

toBetoEqualの使い分け

toBe はプリミティブ値の厳密な同一性比較(オブジェクトの場合は参照比較)、toEqual はオブジェクト・配列の中身を再帰的に比較します。

// ❌ オブジェクトの中身が同じでも、参照が違うと失敗する
expect({ income: 100 }).toBe({ income: 100 });

// ✅ 中身を比較したい場合はtoEqual
expect({ income: 100 }).toEqual({ income: 100 });

calculateTotals のようにオブジェクトを返す関数のテストでは toEqualformatLocalDate のように文字列を返す関数のテストでは toBe を使い分けています。

まとめ

ポイント 内容
Vitestの設定 viteではなくvitest/configからdefineConfigをimportする
テストの基本形 describe/it/expectを組み合わせ、1ケース1itで書く
ロジックの切り出し DBアクセスを含まない純粋関数として抽出するとテストしやすい
DBスキーマのenum text(col, { enum: [...] })はマイグレーション不要でTS側の型だけ絞り込める
as const オブジェクトリテラル内の値がstringに広がるのを防ぎ、リテラル型のまま扱う
toBetoEqual プリミティブはtoBe、オブジェクト・配列の中身比較はtoEqual
← トップページに戻る