# TK-FB-Project 테스트 가이드 ## 목차 1. [개요](#개요) 2. [테스트 환경 설정](#테스트-환경-설정) 3. [테스트 작성 가이드](#테스트-작성-가이드) 4. [실전 예제](#실전-예제) 5. [테스트 실행](#테스트-실행) 6. [모범 사례](#모범-사례) --- ## 개요 이 프로젝트는 **Jest**를 사용하여 테스트를 작성합니다. ### 테스트 계층 ``` ┌─────────────────────────────────┐ │ API 통합 테스트 (E2E) │ ← supertest로 실제 HTTP 요청 ├─────────────────────────────────┤ │ 컨트롤러 테스트 │ ← req/res 모킹 ├─────────────────────────────────┤ │ 서비스 레이어 테스트 │ ← 비즈니스 로직 (DB 모킹) ├─────────────────────────────────┤ │ 모델 테스트 │ ← DB 쿼리 로직 └─────────────────────────────────┘ ``` ### 우선순위 1. **서비스 레이어 테스트** ⭐ (비즈니스 로직, 가장 중요) 2. **컨트롤러 테스트** (API 엔드포인트) 3. **통합 테스트** (실제 DB 사용) --- ## 테스트 환경 설정 ### 1단계: 필요한 패키지 설치 ```bash cd /Users/hyungiahn/Documents/code/TK-FB-Project/api.hyungi.net npm install --save-dev jest supertest @types/jest npm install --save-dev jest-mock-extended ``` ### 2단계: Jest 설정 파일 생성 **`jest.config.js`** 파일을 프로젝트 루트에 생성: ```javascript module.exports = { testEnvironment: 'node', coverageDirectory: 'coverage', collectCoverageFrom: [ 'controllers/**/*.js', 'services/**/*.js', 'models/**/*.js', '!**/node_modules/**', '!**/coverage/**', '!**/tests/**' ], testMatch: [ '**/tests/**/*.test.js', '**/tests/**/*.spec.js' ], setupFilesAfterEnv: ['/tests/setup.js'], verbose: true, collectCoverage: true, coverageReporters: ['text', 'lcov', 'html'], testTimeout: 10000 }; ``` ### 3단계: 테스트 디렉토리 구조 ``` api.hyungi.net/ ├── tests/ │ ├── setup.js # 전역 테스트 설정 │ ├── helpers/ │ │ ├── dbHelper.js # DB 모킹 헬퍼 │ │ └── mockData.js # 테스트용 더미 데이터 │ ├── unit/ │ │ ├── services/ # 서비스 단위 테스트 │ │ │ ├── workReportService.test.js │ │ │ ├── attendanceService.test.js │ │ │ └── ... │ │ ├── controllers/ # 컨트롤러 테스트 │ │ │ ├── workReportController.test.js │ │ │ └── ... │ │ └── models/ # 모델 테스트 │ │ └── ... │ └── integration/ # 통합 테스트 │ ├── workReport.test.js │ └── ... └── package.json ``` ### 4단계: package.json에 스크립트 추가 ```json { "scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:unit": "jest tests/unit", "test:integration": "jest tests/integration", "test:verbose": "jest --verbose" } } ``` --- ## 테스트 작성 가이드 ### 서비스 레이어 테스트 패턴 서비스 레이어는 **DB를 모킹**하여 테스트합니다. #### 기본 구조 ```javascript const serviceName = require('../../services/serviceName'); const { ValidationError, NotFoundError, DatabaseError } = require('../../utils/errors'); // DB 모킹 jest.mock('../../models/modelName'); const ModelName = require('../../models/modelName'); describe('ServiceName', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('functionName', () => { it('should ... (성공 케이스)', async () => { // Arrange (준비) const mockData = { /* ... */ }; ModelName.methodName.mockResolvedValue(mockData); // Act (실행) const result = await serviceName.functionName(params); // Assert (검증) expect(result).toEqual(expectedResult); expect(ModelName.methodName).toHaveBeenCalledWith(expectedParams); }); it('should throw ValidationError when ... (실패 케이스)', async () => { // Arrange const invalidParams = null; // Act & Assert await expect(serviceName.functionName(invalidParams)) .rejects.toThrow(ValidationError); }); }); }); ``` ### 컨트롤러 테스트 패턴 컨트롤러는 **서비스 레이어를 모킹**하여 테스트합니다. ```javascript const controllerName = require('../../controllers/controllerName'); // 서비스 모킹 jest.mock('../../services/serviceName'); const serviceName = require('../../services/serviceName'); describe('ControllerName', () => { let req, res; beforeEach(() => { // req, res 모킹 객체 초기화 req = { body: {}, params: {}, query: {}, user: { id: 1, role: 'admin' } }; res = { json: jest.fn().mockReturnThis(), status: jest.fn().mockReturnThis() }; jest.clearAllMocks(); }); describe('GET /endpoint', () => { it('should return data successfully', async () => { // Arrange const mockData = { /* ... */ }; serviceName.getData.mockResolvedValue(mockData); // Act await controllerName.getData(req, res); // Assert expect(res.json).toHaveBeenCalledWith({ success: true, data: mockData, message: expect.any(String) }); expect(serviceName.getData).toHaveBeenCalledTimes(1); }); }); }); ``` ### 통합 테스트 패턴 (E2E) 실제 HTTP 요청으로 테스트합니다. ```javascript const request = require('supertest'); const app = require('../../index'); const { getDb } = require('../../dbPool'); describe('WorkReport API Integration Tests', () => { let db; beforeAll(async () => { db = await getDb(); }); afterAll(async () => { await db.end(); }); describe('POST /api/work-reports', () => { it('should create work report', async () => { const response = await request(app) .post('/api/work-reports') .set('Authorization', 'Bearer ' + testToken) .send({ report_date: '2025-12-11', worker_id: 1, // ... 기타 필드 }) .expect(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveProperty('workReport_ids'); }); }); }); ``` --- ## 실전 예제 ### 예제 1: workReportService 테스트 **`tests/unit/services/workReportService.test.js`** ```javascript const workReportService = require('../../../services/workReportService'); const { ValidationError, NotFoundError, DatabaseError } = require('../../../utils/errors'); // 모델 모킹 jest.mock('../../../models/workReportModel'); const workReportModel = require('../../../models/workReportModel'); describe('WorkReportService', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('createWorkReportService', () => { it('단일 보고서를 성공적으로 생성해야 함', async () => { // Arrange const reportData = { report_date: '2025-12-11', worker_id: 1, project_id: 1, work_hours: 8 }; // workReportModel.create가 콜백 형태이므로 모킹 설정 workReportModel.create = jest.fn((data, callback) => { callback(null, 123); // insertId = 123 }); // Act const result = await workReportService.createWorkReportService(reportData); // Assert expect(result).toEqual({ workReport_ids: [123] }); expect(workReportModel.create).toHaveBeenCalledTimes(1); expect(workReportModel.create).toHaveBeenCalledWith( reportData, expect.any(Function) ); }); it('다중 보고서를 성공적으로 생성해야 함', async () => { // Arrange const reportsData = [ { report_date: '2025-12-11', worker_id: 1, work_hours: 8 }, { report_date: '2025-12-11', worker_id: 2, work_hours: 7 } ]; let callCount = 0; workReportModel.create = jest.fn((data, callback) => { callCount++; callback(null, 100 + callCount); }); // Act const result = await workReportService.createWorkReportService(reportsData); // Assert expect(result).toEqual({ workReport_ids: [101, 102] }); expect(workReportModel.create).toHaveBeenCalledTimes(2); }); it('빈 배열이면 ValidationError를 던져야 함', async () => { // Act & Assert await expect(workReportService.createWorkReportService([])) .rejects.toThrow(ValidationError); await expect(workReportService.createWorkReportService([])) .rejects.toThrow('보고서 데이터가 필요합니다'); }); it('DB 오류 시 DatabaseError를 던져야 함', async () => { // Arrange const reportData = { report_date: '2025-12-11', worker_id: 1 }; workReportModel.create = jest.fn((data, callback) => { callback(new Error('DB connection failed'), null); }); // Act & Assert await expect(workReportService.createWorkReportService(reportData)) .rejects.toThrow(DatabaseError); }); }); describe('getWorkReportByIdService', () => { it('ID로 보고서를 조회해야 함', async () => { // Arrange const mockReport = { id: 123, report_date: '2025-12-11', worker_id: 1, work_hours: 8 }; workReportModel.getById = jest.fn((id, callback) => { callback(null, mockReport); }); // Act const result = await workReportService.getWorkReportByIdService(123); // Assert expect(result).toEqual(mockReport); expect(workReportModel.getById).toHaveBeenCalledWith(123, expect.any(Function)); }); it('보고서가 없으면 NotFoundError를 던져야 함', async () => { // Arrange workReportModel.getById = jest.fn((id, callback) => { callback(null, null); }); // Act & Assert await expect(workReportService.getWorkReportByIdService(999)) .rejects.toThrow(NotFoundError); await expect(workReportService.getWorkReportByIdService(999)) .rejects.toThrow('작업 보고서를 찾을 수 없습니다'); }); it('ID가 없으면 ValidationError를 던져야 함', async () => { // Act & Assert await expect(workReportService.getWorkReportByIdService(null)) .rejects.toThrow(ValidationError); }); }); describe('updateWorkReportService', () => { it('보고서를 성공적으로 수정해야 함', async () => { // Arrange const updateData = { work_hours: 9 }; workReportModel.update = jest.fn((id, data, callback) => { callback(null, 1); // affectedRows = 1 }); // Act const result = await workReportService.updateWorkReportService(123, updateData); // Assert expect(result).toEqual({ changes: 1 }); expect(workReportModel.update).toHaveBeenCalledWith( 123, updateData, expect.any(Function) ); }); it('수정할 보고서가 없으면 NotFoundError를 던져야 함', async () => { // Arrange workReportModel.update = jest.fn((id, data, callback) => { callback(null, 0); // affectedRows = 0 }); // Act & Assert await expect(workReportService.updateWorkReportService(999, {})) .rejects.toThrow(NotFoundError); }); }); describe('removeWorkReportService', () => { it('보고서를 성공적으로 삭제해야 함', async () => { // Arrange workReportModel.remove = jest.fn((id, callback) => { callback(null, 1); }); // Act const result = await workReportService.removeWorkReportService(123); // Assert expect(result).toEqual({ changes: 1 }); expect(workReportModel.remove).toHaveBeenCalledWith(123, expect.any(Function)); }); it('삭제할 보고서가 없으면 NotFoundError를 던져야 함', async () => { // Arrange workReportModel.remove = jest.fn((id, callback) => { callback(null, 0); }); // Act & Assert await expect(workReportService.removeWorkReportService(999)) .rejects.toThrow(NotFoundError); }); }); }); ``` ### 예제 2: workReportController 테스트 **`tests/unit/controllers/workReportController.test.js`** ```javascript const workReportController = require('../../../controllers/workReportController'); // 서비스 모킹 jest.mock('../../../services/workReportService'); const workReportService = require('../../../services/workReportService'); describe('WorkReportController', () => { let req, res; beforeEach(() => { req = { body: {}, params: {}, query: {}, user: { id: 1, username: 'test_user', role: 'admin' } }; res = { json: jest.fn().mockReturnThis(), status: jest.fn().mockReturnThis() }; jest.clearAllMocks(); }); describe('createWorkReport', () => { it('단일 작업 보고서를 생성해야 함', async () => { // Arrange req.body = { report_date: '2025-12-11', worker_id: 1, work_hours: 8 }; const mockResult = { workReport_ids: [123] }; workReportService.createWorkReportService.mockResolvedValue(mockResult); // Act await workReportController.createWorkReport(req, res); // Assert expect(workReportService.createWorkReportService).toHaveBeenCalledWith(req.body); expect(res.json).toHaveBeenCalledWith({ success: true, data: mockResult, message: '작업 보고서가 성공적으로 생성되었습니다' }); }); }); describe('getWorkReportsByDate', () => { it('날짜별 작업 보고서를 조회해야 함', async () => { // Arrange req.params = { date: '2025-12-11' }; const mockReports = [ { id: 1, report_date: '2025-12-11', work_hours: 8 }, { id: 2, report_date: '2025-12-11', work_hours: 7 } ]; workReportService.getWorkReportsByDateService.mockResolvedValue(mockReports); // Act await workReportController.getWorkReportsByDate(req, res); // Assert expect(workReportService.getWorkReportsByDateService).toHaveBeenCalledWith('2025-12-11'); expect(res.json).toHaveBeenCalledWith({ success: true, data: mockReports, message: '작업 보고서 조회 성공' }); }); }); describe('getWorkReportById', () => { it('ID로 작업 보고서를 조회해야 함', async () => { // Arrange req.params = { id: '123' }; const mockReport = { id: 123, work_hours: 8 }; workReportService.getWorkReportByIdService.mockResolvedValue(mockReport); // Act await workReportController.getWorkReportById(req, res); // Assert expect(workReportService.getWorkReportByIdService).toHaveBeenCalledWith('123'); expect(res.json).toHaveBeenCalledWith({ success: true, data: mockReport, message: '작업 보고서 조회 성공' }); }); }); describe('updateWorkReport', () => { it('작업 보고서를 수정해야 함', async () => { // Arrange req.params = { id: '123' }; req.body = { work_hours: 9 }; const mockResult = { changes: 1 }; workReportService.updateWorkReportService.mockResolvedValue(mockResult); // Act await workReportController.updateWorkReport(req, res); // Assert expect(workReportService.updateWorkReportService).toHaveBeenCalledWith('123', req.body); expect(res.json).toHaveBeenCalledWith({ success: true, data: mockResult, message: '작업 보고서가 성공적으로 수정되었습니다' }); }); }); describe('removeWorkReport', () => { it('작업 보고서를 삭제해야 함', async () => { // Arrange req.params = { id: '123' }; const mockResult = { changes: 1 }; workReportService.removeWorkReportService.mockResolvedValue(mockResult); // Act await workReportController.removeWorkReport(req, res); // Assert expect(workReportService.removeWorkReportService).toHaveBeenCalledWith('123'); expect(res.json).toHaveBeenCalledWith({ success: true, data: mockResult, message: '작업 보고서가 성공적으로 삭제되었습니다' }); }); }); }); ``` ### 예제 3: 통합 테스트 (E2E) **`tests/integration/workReport.test.js`** ```javascript const request = require('supertest'); const app = require('../../index'); const { getDb } = require('../../dbPool'); describe('WorkReport API Integration Tests', () => { let db; let authToken; beforeAll(async () => { db = await getDb(); // 테스트용 인증 토큰 생성 (실제 로그인 API 호출 또는 JWT 직접 생성) const loginResponse = await request(app) .post('/api/auth/login') .send({ username: 'test_admin', password: 'test_password' }); authToken = loginResponse.body.token; }); afterAll(async () => { // 테스트 데이터 정리 await db.query('DELETE FROM daily_work_reports WHERE report_date = ?', ['2025-12-11']); await db.end(); }); describe('POST /api/work-reports', () => { it('작업 보고서를 생성해야 함', async () => { const response = await request(app) .post('/api/work-reports') .set('Authorization', `Bearer ${authToken}`) .send({ report_date: '2025-12-11', worker_id: 1, project_id: 1, work_type_id: 1, work_hours: 8, work_content: '테스트 작업' }) .expect(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveProperty('workReport_ids'); expect(response.body.data.workReport_ids).toHaveLength(1); }); it('인증 토큰 없이 요청하면 401을 반환해야 함', async () => { await request(app) .post('/api/work-reports') .send({ report_date: '2025-12-11', worker_id: 1 }) .expect(401); }); }); describe('GET /api/work-reports/:date', () => { it('날짜별 작업 보고서를 조회해야 함', async () => { const response = await request(app) .get('/api/work-reports/2025-12-11') .set('Authorization', `Bearer ${authToken}`) .expect(200); expect(response.body.success).toBe(true); expect(Array.isArray(response.body.data)).toBe(true); }); }); }); ``` --- ## 테스트 실행 ### 기본 실행 ```bash # 모든 테스트 실행 npm test # Watch 모드 (파일 변경 시 자동 재실행) npm run test:watch # 커버리지 리포트와 함께 실행 npm run test:coverage # 특정 파일만 테스트 npm test -- tests/unit/services/workReportService.test.js # 특정 describe 블록만 테스트 npm test -- --testNamePattern="createWorkReportService" ``` ### 커버리지 확인 ```bash npm run test:coverage # 커버리지 리포트 HTML 보기 open coverage/lcov-report/index.html ``` **목표 커버리지:** - 서비스 레이어: 80% 이상 - 컨트롤러: 70% 이상 - 전체: 75% 이상 --- ## 모범 사례 ### 1. 테스트 이름 규칙 **Good:** ```javascript it('should create work report when valid data is provided', async () => {}); it('should throw ValidationError when report_date is missing', async () => {}); it('should return 404 when work report not found', async () => {}); ``` **Bad:** ```javascript it('test1', async () => {}); it('works', async () => {}); ``` ### 2. AAA 패턴 (Arrange-Act-Assert) ```javascript it('should ...', async () => { // Arrange: 테스트 데이터 및 모킹 설정 const mockData = { ... }; service.method.mockResolvedValue(mockData); // Act: 실제 테스트 대상 실행 const result = await functionUnderTest(params); // Assert: 결과 검증 expect(result).toEqual(expectedResult); expect(service.method).toHaveBeenCalledWith(expectedParams); }); ``` ### 3. 독립적인 테스트 각 테스트는 다른 테스트에 의존하지 않아야 합니다. ```javascript describe('Service', () => { beforeEach(() => { jest.clearAllMocks(); // 각 테스트 전에 모킹 초기화 }); it('test 1', () => { /* ... */ }); it('test 2', () => { /* ... */ }); // test 1의 영향을 받지 않음 }); ``` ### 4. 엣지 케이스 테스트 ```javascript describe('getWorkReportsByDateService', () => { it('should handle empty date', async () => { /* ... */ }); it('should handle invalid date format', async () => { /* ... */ }); it('should handle null date', async () => { /* ... */ }); it('should handle future date', async () => { /* ... */ }); it('should return empty array when no reports found', async () => { /* ... */ }); }); ``` ### 5. 에러 케이스 테스트 ```javascript it('should throw ValidationError when required field is missing', async () => { await expect(service.method(invalidData)) .rejects.toThrow(ValidationError); await expect(service.method(invalidData)) .rejects.toThrow('보고서 데이터가 필요합니다'); }); ``` ### 6. 비동기 테스트 ```javascript // Good: async/await 사용 it('should ...', async () => { const result = await asyncFunction(); expect(result).toBe(expected); }); // Good: return Promise it('should ...', () => { return asyncFunction().then(result => { expect(result).toBe(expected); }); }); // Bad: Promise를 반환하지 않음 it('should ...', () => { asyncFunction().then(result => { expect(result).toBe(expected); // 실행되지 않을 수 있음! }); }); ``` ### 7. 모킹 복원 ```javascript describe('Service', () => { afterEach(() => { jest.restoreAllMocks(); // 모든 모킹 복원 }); }); ``` --- ## 다음 단계 ### Phase 1: 서비스 레이어 테스트 (우선) ```bash tests/unit/services/ ├── workReportService.test.js ⭐ 시작하기 좋음 ├── attendanceService.test.js ├── dailyWorkReportService.test.js ├── workerService.test.js ├── projectService.test.js ├── issueTypeService.test.js ├── toolsService.test.js ├── dailyIssueReportService.test.js ├── uploadService.test.js └── analysisService.test.js ``` ### Phase 2: 컨트롤러 테스트 ```bash tests/unit/controllers/ ├── workReportController.test.js ├── attendanceController.test.js └── ... ``` ### Phase 3: 통합 테스트 ```bash tests/integration/ ├── workReport.test.js ├── attendance.test.js └── ... ``` --- ## 유용한 Jest 명령어 ```bash # 특정 파일만 watch npm test -- --watch tests/unit/services/workReportService.test.js # 실패한 테스트만 재실행 npm test -- --onlyFailures # 병렬 실행 비활성화 (디버깅 시) npm test -- --runInBand # 상세 출력 npm test -- --verbose # 특정 describe만 실행 npm test -- --testNamePattern="createWorkReportService" ``` --- ## 트러블슈팅 ### 문제 1: "Cannot find module" ```bash # node_modules 재설치 rm -rf node_modules package-lock.json npm install ``` ### 문제 2: 타임아웃 에러 ```javascript // jest.config.js에서 타임아웃 증가 module.exports = { testTimeout: 10000 // 10초 }; // 또는 개별 테스트에서 it('should ...', async () => { // ... }, 15000); // 15초 ``` ### 문제 3: DB 연결 오류 (통합 테스트) ```javascript // tests/setup.js에서 테스트 DB 설정 process.env.DB_HOST = 'localhost'; process.env.DB_NAME = 'test_database'; ``` --- ## 참고 자료 - [Jest 공식 문서](https://jestjs.io/docs/getting-started) - [Supertest GitHub](https://github.com/visionmedia/supertest) - [Testing Best Practices](https://github.com/goldbergyoni/javascript-testing-best-practices) --- **작성일**: 2025-12-11 **작성자**: TK-FB-Project Team **버전**: 1.0