테스트하기 어려운 구조:
Controller → Service 안에 모든 로직 → Prisma 직접 호출 → 외부 API 직접 호출
테스트하기 쉬운 구조:
Controller → Use Case → Domain Service / Repository / Adapter
Service 파일이 너무 큼
DB query와 비즈니스 규칙이 섞임
외부 API 호출이 직접 들어감
transaction 범위가 숨겨져 있음
권한 체크가 여러 곳에 흩어짐
Mock으로 대체하기 어려움
| 종류 | 목적 | 예시 |
|---|---|---|
| Unit Test | 작은 로직 검증 | 상태 전이 규칙 |
| Integration Test | 여러 계층/DB 연동 검증 | 상태 변경 Use Case |
| E2E Test | 실제 API 흐름 검증 | 로그인 후 상태 변경 요청 |
| Contract Test | 외부 API/응답 계약 검증 | 알림톡 Adapter 응답 타입 |
Unit Test:
Domain Service, Policy, Mapper
Integration Test:
Use Case + Repository + Test DB
E2E Test:
Controller + Guard + Use Case + DB
Mock Test:
외부 API Adapter, S3, 알림톡, Webhook
1. 비즈니스 규칙은 Domain Service로 분리
2. 업무 흐름은 Use Case로 분리
3. DB 접근은 Repository로 분리
4. 외부 API는 Adapter로 분리
5. 현재 시간은 Clock으로 분리
6. 랜덤 ID 생성은 IdGenerator로 분리
7. 응답 변환은 Mapper로 분리
new Date()
randomUUID()
process.env 직접 참조
axios 직접 호출
S3 SDK 직접 호출
Prisma 직접 호출
파일 시스템 직접 접근
직접 호출:
Use Case 안에서 axios.post()
분리:
Use Case → NotificationAdapter 인터페이스 → 실제 AlimtalkAdapter
describe('ConsultStatusService', () => {
const service = new ConsultStatusService();
it('NEW 상태에서 CALLING으로 변경할 수 있다', () => {
expect(() =>
service.validateTransition('NEW', 'CALLING'),
).not.toThrow();
});
it('CANCELED 상태에서 CONVERTED로 변경할 수 없다', () => {
expect(() =>
service.validateTransition('CANCELED', 'CONVERTED'),
).toThrow();
});
});
describe('ConsultPermissionPolicy', () => {
const policy = new ConsultPermissionPolicy();
it('CONSULT_UPDATE_STATUS 권한이 없으면 상태 변경할 수 없다', () => {
const result = policy.canChangeStatus({
admin: {
id: 1,
role: 'STAFF',
permissions: ['CONSULT_READ'],
},
currentStatus: 'NEW',
nextStatus: 'CALLING',
assignedAdminId: null,
});
expect(result).toBe(false);
});
it('SUPER_ADMIN은 상태 변경할 수 있다', () => {
const result = policy.canChangeStatus({
admin: {
id: 1,
role: 'SUPER_ADMIN',
permissions: ['*'],
},
currentStatus: 'NEW',
nextStatus: 'CALLING',
assignedAdminId: null,
});
expect(result).toBe(true);
});
});
Test DB 준비
↓
상담 row 생성
↓
Use Case 실행
↓
consults.status 확인
↓
status_history 확인
↓
audit_log 확인
NEW → CALLING 성공
상태 변경 시 status_history 생성
상태 변경 시 audit_log 생성
CANCELED → CONVERTED 실패
동시 수정 충돌 시 409
Audit Log 생성 실패 시 전체 rollback
it('상담 상태 변경 시 상태, 이력, Audit Log가 함께 저장된다', async () => {
const consult = await seedConsult({
status: 'NEW',
});
await updateConsultStatusUseCase.execute({
consultId: consult.id,
nextStatus: 'CALLING',
adminId: 1,
requestId: 'test-request-id',
});
const updated = await prisma.consult.findUnique({
where: { id: consult.id },
});
const histories = await prisma.consultStatusHistory.findMany({
where: { consultId: consult.id },
});
const auditLogs = await prisma.auditLog.findMany({
where: {
targetType: 'CONSULT',
targetId: String(consult.id),
action: 'CONSULT_STATUS_UPDATE',
},
});
expect(updated?.status).toBe('CALLING');
expect(histories).toHaveLength(1);
expect(auditLogs).toHaveLength(1);
});
HTTP 요청
↓
AuthGuard
↓
PermissionGuard
↓
Controller
↓
Use Case
↓
DB
↓
표준 응답
관리자 로그인
상담 목록 조회
상담 상태 변경
권한 없는 엑셀 Export 요청
Validation Error 응답 구조
403 권한 없음 응답 구조
409 동시 수정 충돌 응답 구조
it('권한 있는 관리자는 상담 상태를 변경할 수 있다', async () => {
const token = await loginAsAdmin({
permissions: ['CONSULT_UPDATE_STATUS'],
});
const consult = await seedConsult({
status: 'NEW',
});
const response = await request(app.getHttpServer())
.post(`/admin/consults/${consult.id}/status`)
.set('Authorization', `Bearer ${token}`)
.send({
status: 'CALLING',
})
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data.status).toBe('CALLING');
});
it('권한이 없으면 표준 403 에러 응답을 반환한다', async () => {
const token = await loginAsAdmin({
permissions: ['CONSULT_READ'],
});
const consult = await seedConsult({
status: 'NEW',
});
const response = await request(app.getHttpServer())
.post(`/admin/consults/${consult.id}/status`)
.set('Authorization', `Bearer ${token}`)
.send({
status: 'CALLING',
})
.expect(403);
expect(response.body).toMatchObject({
success: false,
error: {
code: 'FORBIDDEN',
},
});
expect(response.body.requestId).toBeDefined();
});
실제:
AlimtalkAdapter → 외부 알림톡 API 호출
테스트:
MockAlimtalkAdapter → 정해진 응답 반환
외부 API 비용 방지
테스트 속도 향상
외부 장애에 테스트가 흔들리지 않음
실패 상황을 의도적으로 만들 수 있음
개인정보/Secret 없이 테스트 가능
AlimtalkAdapter
SmsAdapter
S3Adapter
ExcelService
Clock
IdGenerator
WebhookSignatureVerifier
ExternalCrmAdapter
외부 API
파일 업로드
이메일/알림톡/SMS 발송
현재 시간
랜덤 ID
결제/인증 Provider
느리고 비용이 드는 작업
Domain Service 규칙
Use Case 흐름
Repository query
DB transaction
권한 Guard
표준 에러 응답
내부 핵심 로직:
실제로 테스트
외부 의존성:
Mock으로 대체
DB 정합성:
Test DB로 확인
외부 API 계약:
Adapter 단위 테스트 또는 별도 Contract Test
장점:
빠름
DB 필요 없음
특정 상황 만들기 쉬움
단점:
실제 query 문제를 못 잡음
transaction 문제를 놓칠 수 있음
Mock이 실제 동작과 달라질 수 있음
장점:
실제 query 검증 가능
transaction 검증 가능
Prisma schema 문제 확인 가능
단점:
느림
테스트 환경 준비 필요
데이터 초기화 필요
Domain Service:
Mock/DB 없이 Unit Test
Use Case:
중요한 것은 Test DB 기반 Integration Test
단순 흐름:
Repository Mock도 가능
Repository 자체:
Test DB로 확인
개발 DB:
togethermall_dev
테스트 DB:
togethermall_test
운영 DB:
togethermall_prod
운영 DB 절대 사용 금지
테스트마다 데이터 초기화
seed 데이터 최소화
테스트 실행 후 정리
migration과 schema 일치
{
"scripts": {
"test": "jest",
"test:unit": "jest --config jest.unit.config.js",
"test:integration": "dotenv -e .env.test -- jest --config jest.integration.config.js",
"test:e2e": "dotenv -e .env.test -- jest --config jest-e2e.json"
}
}
DATABASE_URL이 테스트 DB인지 반드시 확인
test 환경에서 production URL 차단
테스트 실행 전 db reset 범위 확인
운영 Secret 사용 금지
NODE_ENV=test와 DB URL 검증을 강하게 해두는 것이 좋습니다.방법 1:
각 테스트 후 deleteMany
방법 2:
테스트 파일마다 transaction rollback
방법 3:
DB schema reset 후 seed
방법 4:
테스트 컨테이너 사용
beforeEach(async () => {
await prisma.auditLog.deleteMany();
await prisma.consultStatusHistory.deleteMany();
await prisma.consult.deleteMany();
await prisma.product.deleteMany();
await prisma.adminUser.deleteMany();
});
FK 관계가 있는 테이블은 자식 테이블부터 삭제
status_history → consult
role_permission → role/permission
export_job → admin_user
seedConsult, seedAdmin, seedProduct 같은 Factory를 만들면 좋습니다.export async function seedConsult(
prisma: PrismaService,
overrides: Partial<Prisma.ConsultCreateInput> = {},
) {
return prisma.consult.create({
data: {
customerName: '테스트 고객',
phoneMasked: '010****1234',
phoneNormalized: '01012341234',
status: 'NEW',
source: 'TEST',
...overrides,
},
});
}
export async function seedAdmin(
prisma: PrismaService,
overrides: Partial<Prisma.AdminUserCreateInput> = {},
) {
return prisma.adminUser.create({
data: {
email: `admin-${randomUUID()}@example.com`,
name: '테스트 관리자',
role: 'MANAGER',
isActive: true,
...overrides,
},
});
}
테스트 코드 중복 감소
기본값 통일
필요한 값만 override 가능
테스트 가독성 향상
new Date()를 코드 곳곳에서 직접 쓰면 테스트가 불안정해질 수 있습니다.const changedAt = new Date();
@Injectable()
export class Clock {
now() {
return new Date();
}
}
const changedAt = this.clock.now();
const fixedDate = new Date('2026-09-01T00:00:00.000Z');
const clockMock = {
now: jest.fn(() => fixedDate),
};
expiresAt, Retry nextRetryAt, Audit Log 시각 검증에 특히 유용합니다.@Injectable()
export class IdGenerator {
uuid() {
return randomUUID();
}
requestId() {
return `req_${randomUUID()}`;
}
}
const idGeneratorMock = {
uuid: jest.fn(() => 'fixed-uuid'),
requestId: jest.fn(() => 'req_test_123'),
};
requestId 생성
Webhook event 처리 key
Export 파일 key
idempotency key
테스트 snapshot 안정화
const alimtalkAdapterMock = {
sendTemplate: jest.fn().mockResolvedValue({
ok: true,
data: {
providerMessageId: 'msg_123',
status: 'SENT',
},
}),
};
const alimtalkAdapterMock = {
sendTemplate: jest.fn().mockResolvedValue({
ok: false,
errorKind: 'RETRYABLE',
errorCode: 'TIMEOUT',
safeMessage: '알림톡 발송 요청 시간이 초과되었습니다.',
}),
};
알림톡 성공 시 NotificationJob DONE
timeout 시 RETRYING
잘못된 템플릿이면 FAILED
providerMessageId 저장
전화번호 원본 로그 미출력
const s3AdapterMock = {
upload: jest.fn().mockResolvedValue({
key: 'exports/consults/test.xlsx',
}),
createPresignedUrl: jest.fn().mockResolvedValue(
'https://signed-url.example.com',
),
deleteObject: jest.fn().mockResolvedValue(undefined),
};
ExportWorker 성공 시 fileKey 저장
S3 업로드 실패 시 ExportJob FAILED
pre-signed URL 발급 전 권한 확인
만료된 Export 파일 다운로드 차단
CleanupJob에서 deleteObject 호출
PENDING Job을 PROCESSING으로 claim
성공 시 DONE
재시도 가능한 실패 시 RETRYING
재시도 불가능한 실패 시 FAILED
maxRetry 초과 시 FAILED
PROCESSING stuck job 감지
같은 Job 중복 처리 방지
it('알림톡 timeout이면 NotificationJob을 RETRYING으로 변경한다', async () => {
const job = await seedNotificationJob(prisma, {
status: 'PENDING',
retryCount: 0,
});
alimtalkAdapterMock.sendTemplate.mockResolvedValue({
ok: false,
errorKind: 'RETRYABLE',
errorCode: 'TIMEOUT',
safeMessage: '요청 시간이 초과되었습니다.',
});
await notificationWorker.processNext();
const updated = await prisma.notificationJob.findUnique({
where: { id: job.id },
});
expect(updated?.status).toBe('RETRYING');
expect(updated?.retryCount).toBe(1);
expect(updated?.errorCode).toBe('TIMEOUT');
});
signature가 올바르면 WebhookEvent 저장
signature가 틀리면 401/400
providerEventId 중복이면 비즈니스 처리 중복 안 함
중복 이벤트도 200 반환
WebhookEvent 저장 후 빠르게 응답
payload에 Secret이 저장되지 않음
const signatureVerifierMock = {
verify: jest.fn(() => true),
};
it('중복 Webhook은 다시 처리하지 않고 200을 반환한다', async () => {
await seedWebhookEvent(prisma, {
provider: 'ALIMTALK',
providerEventId: 'evt_123',
status: 'PROCESSED',
});
const response = await request(app.getHttpServer())
.post('/webhooks/alimtalk')
.set('x-signature', 'valid-signature')
.send({
eventId: 'evt_123',
type: 'ALIMTALK_DELIVERED',
})
.expect(200);
expect(response.body.duplicated).toBe(true);
});
Validation Error는 VALIDATION_ERROR 반환
권한 없음은 403 + FORBIDDEN 반환
상담 없음은 404 + CONSULT_NOT_FOUND 반환
상태 충돌은 409 + CONSULT_STATUS_CONFLICT 반환
Unknown Error는 500 + INTERNAL_SERVER_ERROR 반환
응답에 requestId 포함
응답에 stack trace 미포함
강제로 예상 못한 에러 발생
↓
응답은 안전한 500
↓
stack trace 없음
↓
requestId 있음
에러 message/details/log에 아래 값이 없는지 확인:
phone
phoneNormalized
Authorization
DATABASE_URL
API key
passwordHash
src/
modules/
consult/
domain/
consult-status.service.spec.ts
application/
update-consult-status.use-case.spec.ts
infra/
consult.repository.spec.ts
test/
e2e/
consult.e2e-spec.ts
auth.e2e-spec.ts
export-job.e2e-spec.ts
factories/
admin.factory.ts
consult.factory.ts
product.factory.ts
export-job.factory.ts
mocks/
alimtalk-adapter.mock.ts
s3-adapter.mock.ts
clock.mock.ts
helpers/
reset-test-db.ts
login-as-admin.ts
Unit Test:
소스 파일 가까이에 배치
E2E Test:
test/e2e에 분리
Factory/Mock:
test 공통 유틸로 분리
DB Reset:
공통 helper로 관리
상담 상태 변경
상담 중복 신청
권한 Guard
엑셀 Export 요청
Audit Log 저장
표준 에러 응답
NotificationJob Worker
ExportJob Worker
Webhook 수신
Soft Delete/Restore
상품 가격/지원금 수정
목록 검색/필터/정렬
Mapper
Dashboard 통계
CleanupJob
AI가 의도치 않게 응답 구조를 바꿀 수 있음
권한 체크를 빠뜨릴 수 있음
transaction을 깨뜨릴 수 있음
민감정보 노출을 만들 수 있음
기존 API 호환성을 깨뜨릴 수 있음
상담 상태 변경 Use Case를 수정하면서 테스트도 같이 추가해줘.
조건:
1. ConsultStatusService 단위 테스트를 추가해줘
2. UpdateConsultStatusUseCase 통합 테스트를 추가해줘
3. 상태 변경 성공 시 consults.status, consult_status_histories, audit_logs가 모두 저장되는지 확인해줘
4. 잘못된 상태 전이는 실패해야 해
5. 권한이 없으면 403이 나야 해
6. 동시 수정 충돌은 409로 처리해야 해
7. 응답에는 phoneNormalized가 없어야 해
8. 에러 응답에는 requestId가 있어야 해
9. 외부 알림톡 호출은 Mock 처리하고 실제 호출하지 마
10. 테스트 실행 방법과 변경 파일 목록을 정리해줘
테스트가 실제 핵심 동작을 검증하는가?
단순히 함수 호출 여부만 확인하지 않는가?
외부 API가 Mock 처리되었는가?
테스트 DB와 운영 DB가 분리되어 있는가?
권한/에러/민감정보 노출 테스트가 포함되어 있는가?
1단계:
로컬 unit test
2단계:
lint + typecheck
3단계:
GitHub Actions에서 unit test
4단계:
integration test용 PostgreSQL service 추가
5단계:
배포 전 smoke test
push 또는 pull request
↓
pnpm install
↓
typecheck
↓
lint
↓
unit test
↓
integration test
CI에 운영 Secret 넣지 않기
테스트용 DATABASE_URL 사용
외부 API 키 없이 Mock 사용
테스트 데이터가 운영과 연결되지 않게 하기
무조건 90% 이상
모든 getter/setter 테스트
Mock만 잔뜩 있는 테스트
실제 버그를 못 잡는 테스트
상담 상태 변경 핵심 흐름 테스트
중복 신청 방지 테스트
권한 없는 요청 차단 테스트
ExportJob 생성/실패 테스트
NotificationJob retry 테스트
표준 에러 응답 테스트
위험한 기능:
테스트 필수
자주 바뀌는 기능:
테스트 우선
단순 CRUD:
필요한 경우만
외부 연동:
Mock 기반 실패 테스트 필수
ConsultStatusService
ConsultPermissionPolicy
DuplicateConsultPolicy
ExportPermissionPolicy
Mapper
완료 기준:
DB 없이 빠르게 실행 가능
상태 전이/권한 규칙이 테스트로 고정됨
정책 변경 시 실패 테스트로 확인 가능
UpdateConsultStatusUseCase
consults.status 변경
consult_status_histories 생성
audit_logs 생성
동시성 충돌
rollback 검증
완료 기준:
상태 변경 정합성을 DB 기준으로 확인 가능
리팩토링 후에도 이력/Audit Log 누락을 잡을 수 있음
403 권한 없음
409 상태 충돌
400 validation error
404 상담 없음
표준 에러 응답
requestId 포함
완료 기준:
프론트 공통 에러 처리와 API 응답 계약이 깨지지 않음
권한 없는 요청이 백엔드에서 차단됨
NotificationWorker
ExportWorker
S3Adapter Mock
AlimtalkAdapter Mock
retry/failed/done 상태 전환
완료 기준:
외부 API 실패 시 Job 상태가 올바르게 저장됨
실제 외부 API 호출 없이 테스트 가능
중복 처리/재시도 로직 검증 가능
NestJS + Prisma + PostgreSQL 기반 온라인 휴대폰 판매몰 백엔드에 테스트 구조를 만들려고 해.
서비스 상황:
1. 상담 신청, 상담 상태 변경, 상품 수정, 엑셀 Export, 알림톡 Worker, Webhook 수신, 관리자 권한 기능이 있음
2. 상담 상태 변경은 consults.status 업데이트, consult_status_histories 생성, audit_logs 생성을 하나의 transaction으로 묶음
3. 권한은 PermissionGuard와 ConsultPermissionPolicy로 확인함
4. 에러 응답은 success=false, error.code, error.message, requestId 구조로 표준화함
5. 알림톡/SMS/S3 같은 외부 API는 Adapter로 분리되어야 하고 테스트에서는 Mock해야 함
6. Test DB와 운영 DB는 절대 섞이면 안 됨
7. 현재 시간, requestId, randomUUID는 테스트에서 고정할 수 있으면 좋겠음
8. AI/Codex가 코드를 수정할 때 기존 기능이 깨졌는지 확인할 안전장치가 필요함
요청:
- Unit/Integration/E2E 테스트 구분
- 테스트 가능한 폴더 구조
- Domain Service 테스트 예시
- Use Case Integration Test 예시
- PermissionGuard E2E Test 예시
- GlobalExceptionFilter 표준 에러 응답 테스트 예시
- Prisma Test DB 전략
- Seed Factory 구조
- Clock/IdGenerator Mock 전략
- AlimtalkAdapter/S3Adapter Mock 전략
- Worker 테스트 전략
- CI에서 테스트 실행하는 단계
- 현재 프로젝트 우선순위
를 실무 기준으로 정리해줘.
모든 테스트를 E2E로만 만들라고 하지 않는가?
Domain Service는 Unit Test로, DB 정합성은 Integration Test로 나누는가?
운영 DB와 테스트 DB 분리를 강하게 강조하는가?
외부 API/S3/알림톡을 Mock하라고 하는가?
상담 상태 변경의 이력/Audit Log 저장을 테스트 대상으로 보는가?
권한 없는 요청과 표준 에러 응답 테스트를 포함하는가?
시간/랜덤값 Mock 필요성을 설명하는가?
커버리지 숫자보다 핵심 리스크 중심 테스트를 권장하는가?
new Date(), randomUUID() 같은 시간/랜덤 의존성은 Clock, IdGenerator로 분리하면 테스트에서 고정할 수 있습니다.DONE, FAILED, RETRYING으로 올바르게 바뀌는지 확인하는 것이 핵심입니다.