테스트에서 데이터베이스를 모킹하면, 리포지토리 레이어가 올바른 메서드를 호출하는지 검증하는 것이다. 데이터가 크래시에서 살아남는지, 고유 제약 조건이 실제로 중복을 차단하는지, 또는 트랜잭션이 실패할 때 롤백되는지는 테스트하지 않는다.
이 차이는 중요하다. 모킹된 save()는 지시한 대로 반환할 뿐이다. 진짜 save()는 데드락에 걸리거나, 문자열을 조용히 잘라내거나, 프라이머리보다 3초 뒤처진 레플리카에 쓸 수도 있다. 테스트 스위트는 초록불인데 사용자들은 데이터를 잃고 있다.
왜 모의 데이터베이스 테스트는 거짓 자신감을 주는가
대부분의 지속성 테스트는 이렇게 생겼다:
// 문법은 검증하지만 물리는 검증하지 않는 테스트
jest.mock('./db', () => ({
query: jest.fn().mockResolvedValue({ rows: [{ id: 1 }] }),
}));
it('saves a user', async () => {
const repo = new UserRepository();
const user = await repo.create({ name: 'Ada' });
expect(user.id).toBe(1);
});
이 테스트는 SQL이 문법적으로 유효하고 매퍼가 예외를 던지지 않는다는 것만 증명한다. 지속성에 대해서는 아무것도 증명하지 않는다.
진짜 위험은 ‘쿼리가 실행됐다’와 ‘데이터가 지속된다’ 사이의 간극에 존재한다. ORM은 반환하기 전에 쓰기 버퍼를 플러시하는가? 커넥션 풀은 이미 롤백된 트랜잭션을 재사용하는가? ON CONFLICT DO NOTHING이 성공할 것으로 기대한 삽입을 삼켜버리는가?
명령에 { id: 1 }을 반환하는 목 객체로는 그런 버그를 절대 찾을 수 없다.
왕복 테스트: 가장 단순하고 정직한 테스트
가장 기본적인 지속성 테스트가 동시에 가장 정직한 테스트다. 데이터를 쓰고, 다시 읽어서, 일치하는지 단언한다.
import { createConnection } from './db';
it('persists and retrieves a user', async () => {
const db = await createConnection();
const repo = new UserRepository(db);
await repo.create({ id: 'user-1', name: 'Ada', email: 'ada@example.com' });
const found = await repo.findById('user-1');
expect(found).toEqual({
id: 'user-1',
name: 'Ada',
email: 'ada@example.com',
});
});
이것은 눈에 보이는 실패를 잡아낸다: 누락된 커밋, 잘못된 컬럼 매핑, 직렬화 버그. 미묘한 실패도 잡아낸다. create 메서드는 쓰기 전에 이메일을 해싱하지만 findById는 쿼리 전에 해싱하지 않는다면, 이 테스트는 실패한다. 모의 테스트는 그렇지 않을 것이다.
제약 조건이 실제로 존재하는 곳에서 테스트하기
데이터베이스 제약 조건은 논리다. 코드의 다른 모든 분기처럼 테스트를 받을 자격이 있다.
it('enforces unique emails at the database level', async () => {
const db = await createConnection();
const repo = new UserRepository(db);
await repo.create({ id: 'user-1', email: 'ada@example.com' });
await expect(
repo.create({ id: 'user-2', email: 'ada@example.com' })
).rejects.toThrow(/unique constraint/i);
});
이 테스트는 실제로 규칙을 집행하는 유일한 장소에서 비즈니스 규칙을 문서화한다. 누군가 나중에 불안정한 마이그레이션을 ‘고치기’ 위해 고유 인덱스를 제거하면, 이 테스트는 소리친다. 목 객체는 인덱스라는 개념이 없으므로 기꺼이 두 사용자가 이메일을 공유하도록 허용할 것이다.
트랜잭션 동작은 이론이 아니다
애플리케이션은 아마도 여러 쓰기를 트랜잭션으로 감싸고 있을 것이다. 그 트랜잭션에 대한 테스트는 부분 실패가 데이터베이스를 일관된 상태로 남기는지 증명해야 한다.
it('rolls back the entire transfer on failure', async () => {
const db = await createConnection();
const accounts = new AccountRepository(db);
await accounts.create({ id: 'A', balance: 100 });
await accounts.create({ id: 'B', balance: 50 });
// 체크 제약 조건 위반으로 트랜잭션 중간에 실패를 유도
const transfer = db.transaction(async (trx) => {
const acc = new AccountRepository(trx);
await acc.debit('A', 30); // 성공
await acc.debit('B', 9999); // 잔액 초과, 예외 발생
});
await expect(transfer).rejects.toThrow();
const a = await accounts.findById('A');
const b = await accounts.findById('B');
expect(a.balance).toBe(100); // 롤백이 원래 상태를 보존
expect(b.balance).toBe(50);
});
이것이 새벽 3시에 ‘계좌에 돈이 없다’는 호출을 막아주는 테스트다. 다운스트림 API가 타임아웃되어 차변은 커밋됐지만 대변은 커밋되지 않았기 때문이다. 목 객체는 트랜잭션 격리를 모델링할 수 없다. 진짜 데이터베이스만이 가능하다.
격리: 어려운 부분
모두가 제기하는 이의는 속도와 불안정성이다. 모든 테스트가 PostgreSQL을 친다면, 스위트는 기어가고 테스트들은 서로 간섭할 것이다.
실용적인 해결책이 세 가지 있고, 아마도 세 가지를 모두 사용하게 될 것이다.
템플릿 데이터베이스. PostgreSQL과 대부분의 엔터프라이즈급 데이터베이스는 CREATE DATABASE ... TEMPLATE을 지원한다. 스키마로 하나의 데이터베이스를 초기화한 후, 테스트 파일마다 수 밀리초 안에 복제한다.
// test-setup.ts
import { execSync } from 'child_process';
let templateCreated = false;
export async function createIsolatedDatabase() {
if (!templateCreated) {
execSync('createdb test_template');
execSync('psql test_template -f schema.sql');
templateCreated = true;
}
const dbName = `test_${process.pid}_${Date.now()}`;
execSync(`createdb -T test_template ${dbName}`);
return connect(dbName);
}
각 테스트 파일은 새로운 데이터베이스를 얻는다. 정리 로직이 필요 없다. 실행이 끝나면 복제본을 삭제한다.
격리 경계로서의 트랜잭션. 템플릿 데이터베이스가 너무 무겁다면, 각 테스트를 트랜잭션 안에서 실행하고 마지막에 롤백한다. 대부분의 데이터베이스 클라이언트는 이를 명시적으로 제공한다.
afterEach(async () => {
await db.query('ROLLBACK');
});
it('inserts a row', async () => {
await db.query('BEGIN');
await repo.create({ id: 'x' });
expect(await repo.findById('x')).toBeDefined();
// afterEach에 의해 롤백됨
});
문제는 트랜잭션을 직접 사용하는 코드가 바깥 롤백 트랜잭션과 충돌할 수 있다는 점이다. SAVEPOINT를 통한 중첩 트랜잭션이 보통 이를 해결하지만, 모든 드라이버가 깔끔하게 처리하는 것은 아니다.
CI 일관성을 위한 Testcontainers. 로컬에서는 SQLite로, 프로덕션에서는 PostgreSQL로 테스트를 실행한다면, 다른 데이터베이스를 테스트하는 것이다. Testcontainers를 사용하여 매 CI 실행마다 Docker에서 진짜 데이터베이스를 띄운다.
import { PostgreSqlContainer } from '@testcontainers/postgresql';
const container = await new PostgreSqlContainer().start();
const db = await createConnection(container.getConnectionUri());
테스트 실행에 2~3초가 추가된다. Postgres 15에서 standard_conforming_strings = on일 때만 재현되는 전체 버그 클래스를 제거한다.
무엇을 모킹하고, 무엇을 모킹하지 말아야 하는가
나는 모킹을 전혀 하지 말자고 주장하는 것이 아니다. 모킹은 제어할 수 없는 경계에서 올바르다: Stripe API 호출, S3 업로드, SMTP 서버. 이들은 느리고, 비싸고, 속도 제한이 있다.
데이터베이스는 같은 의미에서 외부 경계가 아니다. 운영하는 인프라다. 동작은 결정론적이다. 스키마는 당신이 작성한 코드다. 이를 모킹한다는 것은 실제로 실행하는 시스템의 환상 버전을 상대로 테스트한다는 의미다.
트레이드오프는 속도다. 진짜 데이터베이스 테스트는 2050ms가 걸린다. 모의 테스트는 25ms다. 만 개의 지속성 테스트가 있다면 그 차이는 중요하다. 대부분의 팀은 만 개의 지속성 테스트를 갖고 있지 않다. 백 개 정도이며, 정직함은 그 기다림을 감수할 만한 가치가 있다.
스키마 드리프트 테스트는 코드 리뷰가 놓치는 버그를 잡는다
놀랍게도 효과적이지만 거의 아무도 작성하지 않는 테스트가 하나 더 있다. 마이그레이션이 실행된 후, TypeScript 타입이 여전히 스키마와 일치하는지 단언한다.
import { sql } from './db';
it('types match the database schema', async () => {
const columns = await sql`
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'users'
`;
const userColumns = new Map(columns.map((c) => [c.column_name, c]));
// 누군가 NOT NULL 컬럼을 추가하고 타입을 업데이트하지 않으면 이 테스트는 실패한다
expect(userColumns.get('email').is_nullable).toBe('NO');
expect(userColumns.get('created_at').data_type).toBe('timestamp with time zone');
});
이것은 TypeScript가 여전히 옵셔널로 인식하는 컬럼에 NOT NULL 기본값을 추가하는 마이그레이션을 잡아내는 테스트다. 수동으로 유지하는 것은 지루하며, 그래서 kysely-codegen이나 drizzle-kit generate 같은 도구가 존재한다. 하지만 작은 수동 버전이라도 프로덕션 인시던트를 막아줄 것이다.
자주 묻는 질문
모의 데이터베이스 테스트를 모두 삭제해야 하나요?
아니오. 데이터베이스가 무관한 순수 로직 테스트, 예를 들어 서비스가 쿼리 문자열을 올바르게 포맷팅하는지 테스트하는 경우에는 목 객체를 유지하라. 지속성 동작이 핵심인 곳은 어디든 진짜 데이터베이스 테스트로 교체하라.
테스트에서 마이그레이션은 어떻게 처리하나요?
템플릿 데이터베이스에 대해 마이그레이션을 한 번 실행한 후 복제하라. 또는 빠르다면 테스트 스위트마다 실행하라. CI에서 마이그레이션을 건너뛰고 스키마가 일치하기를 바라지 마라.
읽기 레플리카는 어떻게 하나요?
앱이 프라이머리에 쓰고 레플리카에서 읽는다면, 테스트도 그래야 한다. Testcontainers에서 두 개의 연결을 띄우고 프로덕션과 동일한 방식으로 라우팅을 구성하라. 지연으로 인한 오래된 읽기는 진짜 버그다.
이것이 NoSQL 데이터베이스에도 적용되나요?
예. 원칙은 같다. 모의 MongoDB 클라이언트는 복합 인덱스가 생각하는 대로 쿼리를 지원하는지 알려주지 않는다. 진짜 클라이언트는 알려준다.
오늘 하나의 왕복 테스트를 실행하라
가장 자주 건드리는 리포지토리 메서드를 고른다. 그 목 객체를 삭제한다. 행을 생성하고, 다시 읽어서, 필드가 일치하는지 단언하는 테스트를 작성한다. 시간을 잰다. 50ms 미만이면 또 하나 작성한다.
대부분의 팀은 진짜 데이터베이스 테스트가 스위트가 느린 이유가 아니라는 것을 발견한다. 이유는 보통 유닛 테스트를 위해 헤드리스 브라우저를 띄우거나, CI에서 진짜 결제 API를 호출하거나, 테스트 파일을 병렬화하지 않는 것이다. 데이터베이스 테스트는 정직하고, 충분히 빠르며, 수면을 빼앗는 버그를 잡아낸다.