NestJS+Prisma+Jestで実際のDBMSを使用した自動テスト
経緯
実際のDBMSを用いたテストを書く際に、ベストプラクティス的なものが見つからず試行錯誤。
そんな中で自分なりの結論が出たため、同じ悩みを持った誰かの引き出しになればと思い記事を書きました。
前提
- NestJS
- Typescript
- Prisma (
schema.prisma
がありnpx prisma db push
が通るようになっていること) - postgres (多分MySQLでも問題ないが、ここではpostgres)
この記事のゴール
- テスト毎にデータが空になる(出来る)こと
- GitHub Actionsで実行できること
- Jestを並列で実行できること
-
npm run test
で実行できること
下準備
Docker
docker-compose.yml
今回test-database
としてテスト用のDBを別で追加しました。
version: '3.9'
services:
test-database:
image: postgres:15.3
restart: always
container_name: integration-tests
ports:
- "5400:5432" # 競合を避けて5400としています
environment:
POSTGRES_DB: "tests"
POSTGRES_USER: "prisma"
POSTGRES_PASSWORD: "prisma"
package.json
package.json
のtest
はDockerが立ち上がるよう以下のようにします。
{
...
"scripts": {
...
"test": "docker-compose up -d && jest && docker-compose down",
},
}
環境変数
自分の場合は.env.test
というtest用のenvファイルを作成しましたが、
各々の環境に合わせ追加してください。
# Local DB
DATABASE_URL="postgresql://prisma:prisma@localhost:5400/tests"
ヘルパー関数の実装
jest.setup.ts
というファイルにヘルパー関数を実装しました。
import { PrismaClient } from '@prisma/client';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import { execSync } from 'child_process';
/**
* JEST_WORKDER_ID毎にDatabaseを作成し、データのリセット処理を行う。
*/
export async function setupDatabase() {
// 作成するDB名
const newDbName = `worker_${process.env.JEST_WORKER_ID}`;
// DBの作成
const prisma = new PrismaClient();
await prisma.$connect();
try {
await prisma.$executeRaw`CREATE DATABASE ${newDbName}`;
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
// DB作成済みだった場合は無視
// 本来はここでエラーコードをチェックした方が良い。今回は割愛
} else {
throw error;
}
}
await prisma.$disconnect();
// 環境変数上書き
const dbUrl = new URL(process.env.DATABASE_URL ?? '');
const baseUrl = dbUrl.href.substring(0, dbUrl.href.lastIndexOf('/'));
process.env.DATABASE_URL = `${baseUrl}/${newDbName}`;
// DB初期化処理
execSync('npx prisma migrate reset --force --skip-seed', {
env: {
...process.env,
},
});
execSync('npx prisma db push', {
env: {
...process.env,
},
});
}
これでsetupDatabase
関数を呼び出すことで任意のタイミングでDBをリセット出来るようになりました。
execSync
を使用した初期化やJEST_WORKER_ID
毎にDBを作るというアイデアはこちらの記事を参考にしています。
使用例
beforeAllで一回だけ初期化
describe('UserService Integration Test', () => {
let service: UserService;
let prisma: PrismaService;
beforeAll(async () => {
// 最初の一回だけ初期化
await setupDatabase();
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
PrismaService,
LoggingService,
],
}).compile();
service = module.get<UserService>(UserService);
prisma = module.get<PrismaService>(PrismaService);
// データベースにテスト用データの挿入
await prisma.userEntity.create({
data: {
id: 1,
name: 'TEST_USER'
},
});
});
}, 1000); // setupDatabaseの時間がかかる場合はこのようにタイムアウト時間を伸ばしてください
it('ユーザーを取得できること', async () => {
const user = await service.getUser(1);// DBからID:1を取得する処理
expect(user.id).toBe(1);
expect(user.name).toBe('TEST_USER');
});
jest.setup.tsで自動的に初期化する
-
setupFilesAfterEnv
にjest.setup.ts
を追加
jest.config.ts
module.exports = {
...,
setupFilesAfterEnv: ['jest.setup.ts']
}
-
jest.setup.ts
のbeforeAll
でsetupDatabase
を呼び出し
jest.setup.ts
beforeAll(async () => {
await setupDatabase();
});
このようにすることでテストファイル毎に自動でリセットされるので便利です。
しかし 自分の環境ではDBに関連しない関数のテストも含まれていた為、jest.setup.ts
ではなくテスト毎に呼び出す方式を採用しました。
utilsなどのテストもあると思うので、面倒ですがテスト毎に呼び出す方式が良いかと思います。
GitHub Actions (workflow)
name: Integration Test
on: pull_request
jobs:
tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: NPM install
run: npm install
- name: TSC
run: tsc
- name: Run Test
run: npm run test
余談
今回追加したjest.setup.ts
ですが、テスト(*.spec.ts
)以外からの呼び出しを禁止する為、 eslintに以下のような設定を加えました。
これにより本番コードからsetupDatabase
を呼び出される心配はありません。
module.exports = {
// ...
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['jest.setup'],
message: 'src/jest.setup.tsはテストファイル(*.spec.ts)でのみインポート可能です。'
}
]
}
]
},
overrides: [
{
files: ['*.spec.ts'],
rules: {
'no-restricted-imports': 'off',
},
},
],
}
最後に
setupDatabase
はどうしても時間がかかってしまうのが気になりポイントですね。
DBの初期化周りはもっといい方法あると思っていますが、一旦諦めた感じです。
もっといい方法あるよ!って方がいたらコメントで教えて頂けると幸いです。
Discussion
このDB初期化処理が非常に遅いので、
このように直接TRUNCATEすることで速度は上がりました。
※このコードはPostgreSQL用
手が空いたタイミングで更新します。