feat: Introduce knex for database migrations
- Adds knex.js to manage database schema changes systematically. - Creates an initial migration file based on `hyungi_schema_v2.sql` to represent the current database state. - Adds npm scripts (`db:migrate`, `db:rollback`, etc.) for easy execution of migration tasks. - Archives legacy SQL files and old migration scripts into the `db_archive/` directory to prevent confusion and clean up the project structure.
This commit is contained in:
917
TESTING_GUIDE.md
Normal file
917
TESTING_GUIDE.md
Normal file
@@ -0,0 +1,917 @@
|
||||
# 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: ['<rootDir>/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
|
||||
@@ -0,0 +1,49 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = function(knex) {
|
||||
const schemaSql = fs.readFileSync(path.join(__dirname, '../../hyungi_schema_v2.sql'), 'utf8');
|
||||
return knex.raw(schemaSql);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function(knex) {
|
||||
// down 마이그레이션은 모든 테이블을 역순으로 삭제하도록 구현합니다.
|
||||
const tables = [
|
||||
'cutting_plans',
|
||||
'daily_issue_reports',
|
||||
'daily_work_reports',
|
||||
'codes',
|
||||
'code_types',
|
||||
'factory_info',
|
||||
'equipment_list',
|
||||
'pipe_specs',
|
||||
'tasks',
|
||||
'worker_groups',
|
||||
'workers',
|
||||
'projects',
|
||||
'password_change_logs',
|
||||
'login_logs',
|
||||
'users'
|
||||
];
|
||||
|
||||
// 외래 키 제약 조건을 먼저 비활성화합니다.
|
||||
return knex.raw('SET FOREIGN_KEY_CHECKS = 0;')
|
||||
.then(() => {
|
||||
// 각 테이블을 순회하며 drop table if exists를 실행합니다.
|
||||
return tables.reduce((promise, tableName) => {
|
||||
return promise.then(() => knex.schema.dropTableIfExists(tableName));
|
||||
}, Promise.resolve());
|
||||
})
|
||||
.finally(() => {
|
||||
// 외래 키 제약 조건을 다시 활성화합니다.
|
||||
return knex.raw('SET FOREIGN_KEY_CHECKS = 1;');
|
||||
});
|
||||
};
|
||||
91
api.hyungi.net/jest.config.js
Normal file
91
api.hyungi.net/jest.config.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Jest 설정 파일
|
||||
*
|
||||
* TK-FB-Project 테스트 환경 설정
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// Node.js 환경에서 테스트 실행
|
||||
testEnvironment: 'node',
|
||||
|
||||
// 커버리지 리포트 저장 디렉토리
|
||||
coverageDirectory: 'coverage',
|
||||
|
||||
// 커버리지 수집 대상 파일
|
||||
collectCoverageFrom: [
|
||||
'controllers/**/*.js',
|
||||
'services/**/*.js',
|
||||
'models/**/*.js',
|
||||
'middlewares/**/*.js',
|
||||
'utils/**/*.js',
|
||||
'!**/node_modules/**',
|
||||
'!**/coverage/**',
|
||||
'!**/tests/**',
|
||||
'!index.js' // 서버 시작 파일 제외
|
||||
],
|
||||
|
||||
// 테스트 파일 패턴
|
||||
testMatch: [
|
||||
'**/tests/**/*.test.js',
|
||||
'**/tests/**/*.spec.js'
|
||||
],
|
||||
|
||||
// 테스트 실행 전 설정 파일
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
|
||||
|
||||
// 상세 출력 모드
|
||||
verbose: true,
|
||||
|
||||
// 커버리지 수집 활성화
|
||||
collectCoverage: false, // 기본은 false, npm run test:coverage에서만 true
|
||||
|
||||
// 커버리지 리포트 형식
|
||||
coverageReporters: ['text', 'lcov', 'html', 'json-summary'],
|
||||
|
||||
// 커버리지 임계값 (이 값 이하면 테스트 실패)
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 50,
|
||||
functions: 50,
|
||||
lines: 50,
|
||||
statements: 50
|
||||
}
|
||||
},
|
||||
|
||||
// 테스트 타임아웃 (밀리초)
|
||||
testTimeout: 10000,
|
||||
|
||||
// 모듈 경로 매핑 (선택사항)
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
'^@controllers/(.*)$': '<rootDir>/controllers/$1',
|
||||
'^@services/(.*)$': '<rootDir>/services/$1',
|
||||
'^@models/(.*)$': '<rootDir>/models/$1',
|
||||
'^@utils/(.*)$': '<rootDir>/utils/$1',
|
||||
'^@middlewares/(.*)$': '<rootDir>/middlewares/$1'
|
||||
},
|
||||
|
||||
// 무시할 경로
|
||||
testPathIgnorePatterns: [
|
||||
'/node_modules/',
|
||||
'/coverage/',
|
||||
'/dist/'
|
||||
],
|
||||
|
||||
// 병렬 테스트 실행 워커 수 (기본값: CPU 코어 수 - 1)
|
||||
// maxWorkers: 4,
|
||||
|
||||
// 실패한 테스트만 재실행 옵션
|
||||
// bail: 1, // 첫 번째 실패 시 중단
|
||||
|
||||
// 테스트 결과 리포터
|
||||
reporters: [
|
||||
'default',
|
||||
// ['jest-junit', { outputDirectory: 'coverage', outputName: 'junit.xml' }]
|
||||
],
|
||||
|
||||
// 글로벌 설정
|
||||
globals: {
|
||||
'NODE_ENV': 'test'
|
||||
}
|
||||
};
|
||||
57
api.hyungi.net/knexfile.js
Normal file
57
api.hyungi.net/knexfile.js
Normal file
@@ -0,0 +1,57 @@
|
||||
require('dotenv').config();
|
||||
|
||||
module.exports = {
|
||||
development: {
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
socketPath: process.env.DB_SOCKET
|
||||
},
|
||||
migrations: {
|
||||
directory: './db/migrations'
|
||||
},
|
||||
seeds: {
|
||||
directory: './db/seeds'
|
||||
}
|
||||
},
|
||||
|
||||
staging: {
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
socketPath: process.env.DB_SOCKET
|
||||
},
|
||||
migrations: {
|
||||
directory: './db/migrations'
|
||||
},
|
||||
seeds: {
|
||||
directory: './db/seeds'
|
||||
}
|
||||
},
|
||||
|
||||
production: {
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
socketPath: process.env.DB_SOCKET
|
||||
},
|
||||
migrations: {
|
||||
directory: './db/migrations'
|
||||
},
|
||||
seeds: {
|
||||
directory: './db/seeds'
|
||||
}
|
||||
}
|
||||
};
|
||||
3758
api.hyungi.net/package-lock.json
generated
3758
api.hyungi.net/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,18 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "pm2-runtime start ecosystem.config.js --env production",
|
||||
"dev": "pm2-runtime start ecosystem.config.js --env development"
|
||||
"dev": "pm2-runtime start ecosystem.config.js --env development",
|
||||
"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:migrate": "knex migrate:latest --knexfile knexfile.js",
|
||||
"db:migrate:make": "knex migrate:make --knexfile knexfile.js",
|
||||
"db:rollback": "knex migrate:rollback --knexfile knexfile.js",
|
||||
"db:seed": "knex seed:run --knexfile knexfile.js",
|
||||
"db:seed:make": "knex seed:make --knexfile knexfile.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.1.1",
|
||||
@@ -19,6 +30,7 @@
|
||||
"express-validator": "^7.2.1",
|
||||
"helmet": "^7.2.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"knex": "^3.1.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.14.1",
|
||||
"node-cache": "^5.1.2",
|
||||
@@ -28,5 +40,10 @@
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
170
api.hyungi.net/tests/README.md
Normal file
170
api.hyungi.net/tests/README.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# 테스트 시작하기
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 1단계: 테스트 패키지 설치
|
||||
|
||||
```bash
|
||||
cd /Users/hyungiahn/Documents/code/TK-FB-Project/api.hyungi.net
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2단계: 첫 번째 테스트 실행
|
||||
|
||||
```bash
|
||||
# 모든 테스트 실행
|
||||
npm test
|
||||
|
||||
# 특정 파일만 테스트
|
||||
npm test -- tests/unit/services/workReportService.test.js
|
||||
|
||||
# Watch 모드 (파일 변경 시 자동 재실행)
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
### 3단계: 커버리지 확인
|
||||
|
||||
```bash
|
||||
# 커버리지와 함께 테스트 실행
|
||||
npm run test:coverage
|
||||
|
||||
# 커버리지 리포트 HTML 보기
|
||||
open coverage/lcov-report/index.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
tests/
|
||||
├── README.md # 이 파일
|
||||
├── setup.js # Jest 전역 설정
|
||||
├── helpers/
|
||||
│ └── mockData.js # 테스트용 더미 데이터
|
||||
├── unit/
|
||||
│ ├── services/ # 서비스 레이어 테스트
|
||||
│ │ └── workReportService.test.js # ✅ 작성 완료 (예제)
|
||||
│ ├── controllers/ # 컨트롤러 테스트 (미작성)
|
||||
│ └── models/ # 모델 테스트 (미작성)
|
||||
└── integration/ # 통합 테스트 (미작성)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계: 테스트 파일 작성
|
||||
|
||||
### 우선순위 1: 서비스 레이어 테스트 작성
|
||||
|
||||
다음 서비스들에 대한 테스트 파일을 작성하세요:
|
||||
|
||||
```bash
|
||||
tests/unit/services/
|
||||
├── workReportService.test.js # ✅ 완료
|
||||
├── attendanceService.test.js # ⬜ TODO
|
||||
├── dailyWorkReportService.test.js # ⬜ TODO
|
||||
├── workerService.test.js # ⬜ TODO (간단)
|
||||
├── projectService.test.js # ⬜ TODO (간단)
|
||||
├── issueTypeService.test.js # ⬜ TODO (간단)
|
||||
├── toolsService.test.js # ⬜ TODO
|
||||
├── dailyIssueReportService.test.js # ⬜ TODO
|
||||
├── uploadService.test.js # ⬜ TODO (간단)
|
||||
└── analysisService.test.js # ⬜ TODO
|
||||
```
|
||||
|
||||
**추천 작성 순서:**
|
||||
1. **workReportService.test.js** ✅ (이미 완료 - 참고용)
|
||||
2. **workerService.test.js** (간단한 CRUD, 시작하기 좋음)
|
||||
3. **projectService.test.js** (간단한 CRUD)
|
||||
4. **attendanceService.test.js** (복잡도 중간)
|
||||
5. **나머지 서비스들...**
|
||||
|
||||
### 우선순위 2: 컨트롤러 테스트
|
||||
|
||||
서비스 레이어 테스트가 어느정도 완료되면 컨트롤러 테스트를 시작하세요.
|
||||
|
||||
### 우선순위 3: 통합 테스트
|
||||
|
||||
마지막으로 실제 HTTP 요청을 테스트하는 통합 테스트를 작성하세요.
|
||||
|
||||
---
|
||||
|
||||
## 예제 테스트 작성 방법
|
||||
|
||||
### 서비스 레이어 테스트 템플릿
|
||||
|
||||
```javascript
|
||||
const serviceName = require('../../../services/serviceName');
|
||||
const { ValidationError, NotFoundError, DatabaseError } = require('../../../utils/errors');
|
||||
|
||||
// 모델 모킹
|
||||
jest.mock('../../../models/modelName');
|
||||
const modelName = require('../../../models/modelName');
|
||||
|
||||
describe('ServiceName', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('methodName', () => {
|
||||
it('should succeed when valid data provided', async () => {
|
||||
// Arrange
|
||||
const mockData = { /* ... */ };
|
||||
modelName.method.mockResolvedValue(mockData);
|
||||
|
||||
// Act
|
||||
const result = await serviceName.methodName(params);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(modelName.method).toHaveBeenCalledWith(expectedParams);
|
||||
});
|
||||
|
||||
it('should throw ValidationError when invalid data', async () => {
|
||||
// Act & Assert
|
||||
await expect(serviceName.methodName(invalidParams))
|
||||
.rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 유용한 명령어
|
||||
|
||||
```bash
|
||||
# 특정 describe 블록만 테스트
|
||||
npm test -- --testNamePattern="createWorkReportService"
|
||||
|
||||
# 실패한 테스트만 재실행
|
||||
npm test -- --onlyFailures
|
||||
|
||||
# 병렬 실행 비활성화 (디버깅 시)
|
||||
npm test -- --runInBand
|
||||
|
||||
# 상세 출력
|
||||
npm run test:verbose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목표 커버리지
|
||||
|
||||
- **서비스 레이어**: 80% 이상
|
||||
- **컨트롤러**: 70% 이상
|
||||
- **전체**: 75% 이상
|
||||
|
||||
---
|
||||
|
||||
## 도움말
|
||||
|
||||
전체 가이드는 프로젝트 루트의 `TESTING_GUIDE.md`를 참고하세요:
|
||||
|
||||
```bash
|
||||
cat /Users/hyungiahn/Documents/code/TK-FB-Project/TESTING_GUIDE.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Happy Testing! 🧪**
|
||||
225
api.hyungi.net/tests/helpers/mockData.js
Normal file
225
api.hyungi.net/tests/helpers/mockData.js
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 테스트용 더미 데이터
|
||||
*
|
||||
* 테스트에서 재사용 가능한 모킹 데이터
|
||||
*/
|
||||
|
||||
// 사용자 더미 데이터
|
||||
const mockUsers = {
|
||||
admin: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
role: 'admin',
|
||||
access_level: 5
|
||||
},
|
||||
groupLeader: {
|
||||
id: 2,
|
||||
username: 'group_leader',
|
||||
role: 'group_leader',
|
||||
access_level: 3
|
||||
},
|
||||
worker: {
|
||||
id: 3,
|
||||
username: 'worker',
|
||||
role: 'worker',
|
||||
access_level: 1
|
||||
}
|
||||
};
|
||||
|
||||
// 작업자 더미 데이터
|
||||
const mockWorkers = [
|
||||
{
|
||||
worker_id: 1,
|
||||
worker_name: '김철수',
|
||||
job_type: 'developer',
|
||||
is_active: 1,
|
||||
created_at: '2025-01-01 00:00:00'
|
||||
},
|
||||
{
|
||||
worker_id: 2,
|
||||
worker_name: '이영희',
|
||||
job_type: 'designer',
|
||||
is_active: 1,
|
||||
created_at: '2025-01-01 00:00:00'
|
||||
},
|
||||
{
|
||||
worker_id: 3,
|
||||
worker_name: '박민수',
|
||||
job_type: 'developer',
|
||||
is_active: 0,
|
||||
created_at: '2025-01-01 00:00:00'
|
||||
}
|
||||
];
|
||||
|
||||
// 프로젝트 더미 데이터
|
||||
const mockProjects = [
|
||||
{
|
||||
project_id: 1,
|
||||
project_name: '프로젝트 A',
|
||||
job_no: 'PRJ-001',
|
||||
created_at: '2025-01-01 00:00:00'
|
||||
},
|
||||
{
|
||||
project_id: 2,
|
||||
project_name: '프로젝트 B',
|
||||
job_no: 'PRJ-002',
|
||||
created_at: '2025-01-15 00:00:00'
|
||||
}
|
||||
];
|
||||
|
||||
// 작업 유형 더미 데이터
|
||||
const mockWorkTypes = [
|
||||
{ id: 1, name: '개발' },
|
||||
{ id: 2, name: '디자인' },
|
||||
{ id: 3, name: '테스트' },
|
||||
{ id: 4, name: '회의' }
|
||||
];
|
||||
|
||||
// 에러 유형 더미 데이터
|
||||
const mockErrorTypes = [
|
||||
{ id: 1, name: '버그 수정' },
|
||||
{ id: 2, name: '재작업' },
|
||||
{ id: 3, name: '긴급 수정' }
|
||||
];
|
||||
|
||||
// 작업 보고서 더미 데이터
|
||||
const mockWorkReports = [
|
||||
{
|
||||
id: 1,
|
||||
report_date: '2025-12-11',
|
||||
worker_id: 1,
|
||||
project_id: 1,
|
||||
work_type_id: 1,
|
||||
work_hours: 8.0,
|
||||
work_content: '기능 개발',
|
||||
error_type_id: null,
|
||||
work_status_id: 1,
|
||||
created_by: 1,
|
||||
created_at: '2025-12-11 09:00:00',
|
||||
updated_at: '2025-12-11 09:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
report_date: '2025-12-11',
|
||||
worker_id: 2,
|
||||
project_id: 1,
|
||||
work_type_id: 2,
|
||||
work_hours: 7.5,
|
||||
work_content: 'UI 디자인',
|
||||
error_type_id: null,
|
||||
work_status_id: 1,
|
||||
created_by: 2,
|
||||
created_at: '2025-12-11 09:00:00',
|
||||
updated_at: '2025-12-11 09:00:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
report_date: '2025-12-11',
|
||||
worker_id: 1,
|
||||
project_id: 2,
|
||||
work_type_id: 1,
|
||||
work_hours: 2.0,
|
||||
work_content: '버그 수정',
|
||||
error_type_id: 1,
|
||||
work_status_id: 2,
|
||||
created_by: 1,
|
||||
created_at: '2025-12-11 14:00:00',
|
||||
updated_at: '2025-12-11 14:00:00'
|
||||
}
|
||||
];
|
||||
|
||||
// 출석 더미 데이터
|
||||
const mockAttendance = [
|
||||
{
|
||||
id: 1,
|
||||
worker_id: 1,
|
||||
attendance_date: '2025-12-11',
|
||||
status: 'work',
|
||||
regular_hours: 8.0,
|
||||
overtime_hours: 0.0,
|
||||
created_by: 1,
|
||||
created_at: '2025-12-11 09:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
worker_id: 2,
|
||||
attendance_date: '2025-12-11',
|
||||
status: 'vacation',
|
||||
regular_hours: 0.0,
|
||||
overtime_hours: 0.0,
|
||||
created_by: 2,
|
||||
created_at: '2025-12-11 09:00:00'
|
||||
}
|
||||
];
|
||||
|
||||
// 분석 데이터 더미
|
||||
const mockAnalysisData = {
|
||||
summary: {
|
||||
total_entries: 100,
|
||||
total_hours: 800.0,
|
||||
unique_workers: 10,
|
||||
unique_projects: 5,
|
||||
working_days: 20,
|
||||
avg_hours_per_entry: 8.0,
|
||||
contributors: 3,
|
||||
error_entries: 5,
|
||||
error_rate: 5.0
|
||||
},
|
||||
dailyStats: [
|
||||
{ report_date: '2025-12-01', daily_hours: 40.0, daily_entries: 5, daily_workers: 5 },
|
||||
{ report_date: '2025-12-02', daily_hours: 35.0, daily_entries: 5, daily_workers: 4 }
|
||||
]
|
||||
};
|
||||
|
||||
// API 응답 형식
|
||||
const createSuccessResponse = (data, message = '성공') => ({
|
||||
success: true,
|
||||
data,
|
||||
message
|
||||
});
|
||||
|
||||
const createErrorResponse = (error, message = '오류 발생') => ({
|
||||
success: false,
|
||||
error,
|
||||
message
|
||||
});
|
||||
|
||||
// 날짜 헬퍼
|
||||
const createDateString = (daysFromToday = 0) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + daysFromToday);
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
// DB 쿼리 결과 모킹 헬퍼
|
||||
const createDbResult = (data, affectedRows = 1, insertId = 1) => {
|
||||
if (Array.isArray(data)) {
|
||||
return [data, []]; // [rows, fields]
|
||||
}
|
||||
return [
|
||||
{
|
||||
affectedRows,
|
||||
insertId,
|
||||
changedRows: affectedRows
|
||||
},
|
||||
[]
|
||||
];
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// 더미 데이터
|
||||
mockUsers,
|
||||
mockWorkers,
|
||||
mockProjects,
|
||||
mockWorkTypes,
|
||||
mockErrorTypes,
|
||||
mockWorkReports,
|
||||
mockAttendance,
|
||||
mockAnalysisData,
|
||||
|
||||
// 헬퍼 함수
|
||||
createSuccessResponse,
|
||||
createErrorResponse,
|
||||
createDateString,
|
||||
createDbResult
|
||||
};
|
||||
67
api.hyungi.net/tests/setup.js
Normal file
67
api.hyungi.net/tests/setup.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Jest 전역 테스트 설정
|
||||
*
|
||||
* 모든 테스트 실행 전 자동으로 로드됩니다
|
||||
*/
|
||||
|
||||
// 환경 변수 설정 (테스트 환경)
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.LOG_LEVEL = 'error'; // 테스트 시 로그 최소화
|
||||
|
||||
// 타임존 설정
|
||||
process.env.TZ = 'Asia/Seoul';
|
||||
|
||||
// 전역 타임아웃 확장 (필요 시)
|
||||
jest.setTimeout(10000);
|
||||
|
||||
// 콘솔 출력 모킹 (선택사항 - 테스트 출력을 깔끔하게)
|
||||
// global.console = {
|
||||
// ...console,
|
||||
// log: jest.fn(),
|
||||
// debug: jest.fn(),
|
||||
// info: jest.fn(),
|
||||
// warn: jest.fn(),
|
||||
// error: jest.fn(),
|
||||
// };
|
||||
|
||||
// 모든 테스트 실행 전
|
||||
beforeAll(() => {
|
||||
// 전역 설정
|
||||
});
|
||||
|
||||
// 각 테스트 파일 실행 전
|
||||
beforeEach(() => {
|
||||
// 각 테스트 전 초기화
|
||||
});
|
||||
|
||||
// 각 테스트 파일 실행 후
|
||||
afterEach(() => {
|
||||
// 모킹 정리
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// 모든 테스트 실행 후
|
||||
afterAll(() => {
|
||||
// 정리 작업
|
||||
});
|
||||
|
||||
// 커스텀 매처 (선택사항)
|
||||
expect.extend({
|
||||
// 날짜 문자열 검증
|
||||
toBeValidDate(received) {
|
||||
const isValid = !isNaN(Date.parse(received));
|
||||
return {
|
||||
message: () => `expected ${received} to be a valid date string`,
|
||||
pass: isValid
|
||||
};
|
||||
},
|
||||
|
||||
// ID 검증
|
||||
toBeValidId(received) {
|
||||
const isValid = Number.isInteger(received) && received > 0;
|
||||
return {
|
||||
message: () => `expected ${received} to be a valid ID (positive integer)`,
|
||||
pass: isValid
|
||||
};
|
||||
}
|
||||
});
|
||||
321
api.hyungi.net/tests/unit/services/workReportService.test.js
Normal file
321
api.hyungi.net/tests/unit/services/workReportService.test.js
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* WorkReportService 테스트
|
||||
*
|
||||
* 작업 보고서 서비스 레이어 단위 테스트
|
||||
*/
|
||||
|
||||
const workReportService = require('../../../services/workReportService');
|
||||
const { ValidationError, NotFoundError, DatabaseError } = require('../../../utils/errors');
|
||||
const { mockWorkReports } = require('../../helpers/mockData');
|
||||
|
||||
// 모델 모킹
|
||||
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_type_id: 1,
|
||||
work_hours: 8.0,
|
||||
work_content: '기능 개발'
|
||||
};
|
||||
|
||||
// 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('getWorkReportsByDateService', () => {
|
||||
it('날짜로 보고서를 조회해야 함', async () => {
|
||||
// Arrange
|
||||
const date = '2025-12-11';
|
||||
const mockReports = mockWorkReports.filter(r => r.report_date === date);
|
||||
|
||||
workReportModel.getAllByDate = jest.fn((date, callback) => {
|
||||
callback(null, mockReports);
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await workReportService.getWorkReportsByDateService(date);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockReports);
|
||||
expect(workReportModel.getAllByDate).toHaveBeenCalledWith(date, expect.any(Function));
|
||||
});
|
||||
|
||||
it('날짜가 없으면 ValidationError를 던져야 함', async () => {
|
||||
// Act & Assert
|
||||
await expect(workReportService.getWorkReportsByDateService(null))
|
||||
.rejects.toThrow(ValidationError);
|
||||
|
||||
await expect(workReportService.getWorkReportsByDateService(null))
|
||||
.rejects.toThrow('날짜가 필요합니다');
|
||||
});
|
||||
|
||||
it('DB 오류 시 DatabaseError를 던져야 함', async () => {
|
||||
// Arrange
|
||||
workReportModel.getAllByDate = jest.fn((date, callback) => {
|
||||
callback(new Error('DB error'), null);
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
await expect(workReportService.getWorkReportsByDateService('2025-12-11'))
|
||||
.rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkReportByIdService', () => {
|
||||
it('ID로 보고서를 조회해야 함', async () => {
|
||||
// Arrange
|
||||
const mockReport = mockWorkReports[0];
|
||||
|
||||
workReportModel.getById = jest.fn((id, callback) => {
|
||||
callback(null, mockReport);
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await workReportService.getWorkReportByIdService(1);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockReport);
|
||||
expect(workReportModel.getById).toHaveBeenCalledWith(1, 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);
|
||||
});
|
||||
|
||||
it('ID가 없으면 ValidationError를 던져야 함', async () => {
|
||||
// Act & Assert
|
||||
await expect(workReportService.updateWorkReportService(null, {}))
|
||||
.rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('ID가 없으면 ValidationError를 던져야 함', async () => {
|
||||
// Act & Assert
|
||||
await expect(workReportService.removeWorkReportService(null))
|
||||
.rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkReportsInRangeService', () => {
|
||||
it('기간으로 보고서를 조회해야 함', async () => {
|
||||
// Arrange
|
||||
const start = '2025-12-01';
|
||||
const end = '2025-12-31';
|
||||
|
||||
workReportModel.getByRange = jest.fn((start, end, callback) => {
|
||||
callback(null, mockWorkReports);
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await workReportService.getWorkReportsInRangeService(start, end);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockWorkReports);
|
||||
expect(workReportModel.getByRange).toHaveBeenCalledWith(
|
||||
start,
|
||||
end,
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('시작일이 없으면 ValidationError를 던져야 함', async () => {
|
||||
// Act & Assert
|
||||
await expect(workReportService.getWorkReportsInRangeService(null, '2025-12-31'))
|
||||
.rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('종료일이 없으면 ValidationError를 던져야 함', async () => {
|
||||
// Act & Assert
|
||||
await expect(workReportService.getWorkReportsInRangeService('2025-12-01', null))
|
||||
.rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSummaryService', () => {
|
||||
it('월간 요약을 조회해야 함', async () => {
|
||||
// Arrange
|
||||
const year = '2025';
|
||||
const month = '12';
|
||||
|
||||
workReportModel.getByRange = jest.fn((start, end, callback) => {
|
||||
callback(null, mockWorkReports);
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await workReportService.getSummaryService(year, month);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockWorkReports);
|
||||
expect(workReportModel.getByRange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('연도가 없으면 ValidationError를 던져야 함', async () => {
|
||||
// Act & Assert
|
||||
await expect(workReportService.getSummaryService(null, '12'))
|
||||
.rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('월이 없으면 ValidationError를 던져야 함', async () => {
|
||||
// Act & Assert
|
||||
await expect(workReportService.getSummaryService('2025', null))
|
||||
.rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('데이터가 없으면 NotFoundError를 던져야 함', async () => {
|
||||
// Arrange
|
||||
workReportModel.getByRange = jest.fn((start, end, callback) => {
|
||||
callback(null, []);
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
await expect(workReportService.getSummaryService('2025', '12'))
|
||||
.rejects.toThrow(NotFoundError);
|
||||
|
||||
await expect(workReportService.getSummaryService('2025', '12'))
|
||||
.rejects.toThrow('해당 기간의 작업 보고서가 없습니다');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user