1. はじめに {#introduction}
設計とパフォーマンスを整えたバッチでも、「ちゃんと同期されている?」を証明できなければ安心して運用できません。特に生SQLを直接書いている場合、TypeScript側のユニットテストだけではカバーできないギャップが残りがちです。
この記事では、NestJS + Prismaバッチを例に、**Vitest(ビジネスロジック)とpsql(SQLの実効検証)**を組み合わせたテスト戦略を紹介します。フレームワークに依存しない考え方なので、他言語・他ORMでも応用できます。なお、ジョブキューは BullMQ + Redis 上で動かす想定ですが、テストの焦点は「キュー投入 → Worker実行 → DB反映」の一連をどう担保するかに置いています。
1.1 バックエンドテスト気づいたこと {#takeaways}
- バッチ処理のユースケースに合わせたVitestの書き方
-
psqlを使ったSQLレベルの直接テスト(シェルスクリプト化) - ファクトリ・マスターデータ・依存関係を整えるコツ
- 時刻固定や冪等性確認など、バッチ特有のテストケース
- CI/CDへの統合例とトラブルシューティング
2. なぜVitestだけでは十分でないのか {#why-two-layers}
2.1 見逃しがちなポイント {#blind-spots}
| 項目 | ユニットテストだけでは見えない例 |
|---|---|
| SQL構文 | JOIN順のミス、CTEの書き間違い |
| 制約 | 外部キー・ユニーク制約の違反 |
| PostgreSQL固有 |
IS DISTINCT FROMの挙動、make_dateなど |
| 実行計画 | 意図せぬSeq Scan、INDEX未活用 |
Vitestで検証できるのは「サービス層の振る舞い」まで。生SQLの品質は実データで確認する必要があります。
3. Vitestで押さえるべき基本 {#vitest}
3.1 テスト構造 {#structure}
import { beforeEach, describe, expect, it } from 'vitest';
import { resetSequence } from '@/test/factories/helpers';
import { ProjectFactory } from '@/test/factories/project';
describe('ProjectSyncService', () => {
let service: ProjectSyncService;
beforeEach(async () => {
await resetSequence();
service = createService();
});
it('差分があるレコードだけUPDATEする', async () => {
await ProjectFactory.create();
await service.syncAll();
const rows = await prisma.targetMonthly.findMany();
expect(rows).toHaveLength(1);
});
});
- BullMQ を使っている場合も、Processor(Worker)クラスを直接呼び出す形にしておくと、キューモジュールを立ち上げずに振る舞いを検証できる
- Factoryで依存関係を組み立て、外部キーを気にせずデータを作る
-
resetSequence()などを使ってID衝突を防ぐ
3.2 キュー投入のテスト観点 {#queue-tests}
-
単体テスト: BullMQの
QueueやWorkerをモックし、Processorのハンドラを直接コールする -
結合テスト的に確認したい場合: 低頻度で実行される専用
describeを用意し、テストRedisを起動してキュー→Worker→DBまで確認する(必要に応じて@bull-board/apiなどを利用)
3.3 時刻固定の重要性 {#fake-time}
import { vi } from 'vitest';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-10-01T00:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
- ウォーターマーク比較、月末・年度跨ぎなど境界テストには必須
- CIでも安定して動作させるための基本テク
3.4 冪等性テスト {#idempotent}
it('同じ同期を2回叩いても更新日時が変わらない', async () => {
await service.syncAll();
const first = await prisma.targetMonthly.findMany({ select: { updatedAt: true } });
await service.syncAll();
const second = await prisma.targetMonthly.findMany({ select: { updatedAt: true } });
expect(second).toEqual(first);
});
4. SQL直接テストの構成 {#sql-tests}
4.1 シェルスクリプトで包む {#shell}
Vitestでは拾いきれない部分は、psqlを使って「マスターデータ投入→SQL実行→結果検証」を自動化します。
#!/usr/bin/env bash
set -euo pipefail
DB_URL=$(echo "$DATABASE_URL" | sed -E 's/\?schema=[^&]*//')
cleanup() {
psql "$DB_URL" -c "TRUNCATE TABLE target_monthly CASCADE;" >/dev/null
# 依存テーブルも順番にTRUNCATE
}
run_query() {
psql "$DB_URL" <<'SQL'
-- バッチと同じSQLを実行
UPDATE target_monthly ...;
SQL
}
assert_eq() {
local expected="$1" actual="$2" name="$3"
if [[ "$expected" == "$actual" ]]; then
echo "✅ $name"
else
echo "❌ $name\n expected: $expected\n actual: $actual"
exit 1
fi
}
4.2 マスターデータの注入 {#seed}
- 外部キーを意識して依存順にINSERT
-
ON CONFLICT DO NOTHINGやON DELETE SET NULLなど、実装の挙動に合わせる - テスト終わりに
TRUNCATE ... CASCADEでクリーンアップ
4.3 ケース設計 {#cases}
| # | ケース | 観点 |
|---|---|---|
| 1 | 既存値を差分UPDATE |
IS DISTINCT FROMで差分検知できるか |
| 2 | 欠損行をINSERT |
createManyでまとめて挿入されるか |
| 3 | 同じ値の再実行 | 冪等性が担保されているか |
| 4 | NULL → 値 | NULL比較を正しく扱えるか |
| 5 | 範囲外データ | start/end期間から外れるデータをスキップできるか |
4.4 DevContainerからpsqlを叩くときのコツ {#devcontainer-psql}
開発コンテナ(例: VS Code Dev Containers)でPostgreSQLへアクセスする場合、PrismaのDATABASE_URLには?schema=...が付与されていることが多いので、psql実行時に取り除くのがポイントです。
export DATABASE_URL='postgresql://user:pass@db:5432/database?schema=public'
# schemaクエリパラメータを除去してSQLファイルを実行
psql "$(printf '%s' "$DATABASE_URL" | sed -E 's/\?schema=[^&]*//')" \
-f /workspace/apps/api/src/cli/monthly-snapshot.test.sql
-
ファイルパスエラーが出る場合:
-f ./src/...の代わりに、/workspace/...の絶対パスを渡すと解決することが多い - 環境に合わせて
DATABASE_URLのユーザー名/パスワード/DB名を調整してください - 1回限りのスクリプト実行なら
cat sql | psql ...でもOKですが、-fでファイル指定しておくと再利用しやすい
5. VitestとSQLテストの役割分担 {#division}
| 観点 | Vitest | SQLテスト |
|---|---|---|
| ビジネスロジック | ✅ | △ |
| Prisma呼び出し・例外 | ✅ | - |
| SQL構文/CTE/関数 | - | ✅ |
| 制約・依存関係 | △ | ✅ |
| 実行時間計測 | △ | ✅(EXPLAIN ANALYZEで確認可能) |
両方を組み合わせることで、アプリ層/SQL層それぞれのバグを早期に検知できます。リリース前にどちらか一方だけで済ませず、定期的に双方のテストを走らせる運用を目指しましょう。
6. CI/CDへの組み込み {#ci}
name: batch-tests
on:
pull_request:
paths:
- "apps/api/src/batch/**"
- "apps/api/test-scripts/**"
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: pnpm install
- run: pnpm vitest run
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
- run: ./apps/api/test-scripts/batch-sql-tests.sh
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
- Postgresサービスを立ち上げてSQLテスト用のDBを確保
- Vitestでビジネスロジックを検証したあとにSQLテストを走らせる
- どちらか一方でも失敗したらPRをブロックする仕組みにしておく
7. トラブルシューティング {#troubleshooting}
7.1 代表的なエラー {#common-errors}
- 外部キー違反: マスターデータの挿入順序を見直す
- カラム存在エラー: PrismaスキーマとSQLスキーマのズレを確認する
-
psql ... schemaエラー:?schema=パラメータを削除してから接続する(4.4参照)
7.2 デバッグTips {#debug}
# 詳細エラーを有効化
psql "$DB_URL" <<'SQL'
\set ON_ERROR_STOP on
\set VERBOSITY verbose
-- 検証したいSQLをここに貼る
SQL
# 変更を残したくない場合はトランザクションで囲んでROLLBACK
psql "$DB_URL" <<'SQL'
BEGIN;
-- テストSQL
ROLLBACK;
SQL
8. チェックリスト {#checklist}
- Factoryで依存データを自動生成できる
- Vitestで冪等性・境界条件・例外を網羅
- SQLテストで実際のクエリを検証している
-
DevContainerなどから
psqlを安全に叩ける(スクリプト化済み) - CIでVitestとSQLテストが両方実行される
- 実行ログや計測結果を追ってパフォーマンス劣化を検知できる
9. まとめ {#conclusion}
- アプリ層(Vitest)とSQL層(psqlテスト)の二段構えで品質を担保する
- BullMQのProcessorは直接呼び出せるように設計し、キュー環境がなくてもテストを回せるようにする
- CIに組み込み、常に両方のテストが走る状態を保つことで安心してリリースできる
これで、設計(パート1)・最適化(パート2)・テスト(本記事)の3本柱が揃いました。自分のチームのバッチ処理に合わせてアレンジしつつ、ぜひ活用してみてください!
関連記事