Files
TK-FB-Project/TESTING_GUIDE.md
Hyungi Ahn b67362a733 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.
2025-12-19 09:43:09 +09:00

24 KiB

TK-FB-Project 테스트 가이드

목차

  1. 개요
  2. 테스트 환경 설정
  3. 테스트 작성 가이드
  4. 실전 예제
  5. 테스트 실행
  6. 모범 사례

개요

이 프로젝트는 Jest를 사용하여 테스트를 작성합니다.

테스트 계층

┌─────────────────────────────────┐
│  API 통합 테스트 (E2E)          │  ← supertest로 실제 HTTP 요청
├─────────────────────────────────┤
│  컨트롤러 테스트                │  ← req/res 모킹
├─────────────────────────────────┤
│  서비스 레이어 테스트           │  ← 비즈니스 로직 (DB 모킹)
├─────────────────────────────────┤
│  모델 테스트                    │  ← DB 쿼리 로직
└─────────────────────────────────┘

우선순위

  1. 서비스 레이어 테스트 (비즈니스 로직, 가장 중요)
  2. 컨트롤러 테스트 (API 엔드포인트)
  3. 통합 테스트 (실제 DB 사용)

테스트 환경 설정

1단계: 필요한 패키지 설치

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 파일을 프로젝트 루트에 생성:

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에 스크립트 추가

{
  "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를 모킹하여 테스트합니다.

기본 구조

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);
    });
  });
});

컨트롤러 테스트 패턴

컨트롤러는 서비스 레이어를 모킹하여 테스트합니다.

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 요청으로 테스트합니다.

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

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

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

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);
    });
  });
});

테스트 실행

기본 실행

# 모든 테스트 실행
npm test

# Watch 모드 (파일 변경 시 자동 재실행)
npm run test:watch

# 커버리지 리포트와 함께 실행
npm run test:coverage

# 특정 파일만 테스트
npm test -- tests/unit/services/workReportService.test.js

# 특정 describe 블록만 테스트
npm test -- --testNamePattern="createWorkReportService"

커버리지 확인

npm run test:coverage

# 커버리지 리포트 HTML 보기
open coverage/lcov-report/index.html

목표 커버리지:

  • 서비스 레이어: 80% 이상
  • 컨트롤러: 70% 이상
  • 전체: 75% 이상

모범 사례

1. 테스트 이름 규칙

Good:

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:

it('test1', async () => {});
it('works', async () => {});

2. AAA 패턴 (Arrange-Act-Assert)

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. 독립적인 테스트

각 테스트는 다른 테스트에 의존하지 않아야 합니다.

describe('Service', () => {
  beforeEach(() => {
    jest.clearAllMocks(); // 각 테스트 전에 모킹 초기화
  });

  it('test 1', () => { /* ... */ });
  it('test 2', () => { /* ... */ }); // test 1의 영향을 받지 않음
});

4. 엣지 케이스 테스트

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. 에러 케이스 테스트

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. 비동기 테스트

// 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. 모킹 복원

describe('Service', () => {
  afterEach(() => {
    jest.restoreAllMocks(); // 모든 모킹 복원
  });
});

다음 단계

Phase 1: 서비스 레이어 테스트 (우선)

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: 컨트롤러 테스트

tests/unit/controllers/
├── workReportController.test.js
├── attendanceController.test.js
└── ...

Phase 3: 통합 테스트

tests/integration/
├── workReport.test.js
├── attendance.test.js
└── ...

유용한 Jest 명령어

# 특정 파일만 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"

# node_modules 재설치
rm -rf node_modules package-lock.json
npm install

문제 2: 타임아웃 에러

// jest.config.js에서 타임아웃 증가
module.exports = {
  testTimeout: 10000 // 10초
};

// 또는 개별 테스트에서
it('should ...', async () => {
  // ...
}, 15000); // 15초

문제 3: DB 연결 오류 (통합 테스트)

// tests/setup.js에서 테스트 DB 설정
process.env.DB_HOST = 'localhost';
process.env.DB_NAME = 'test_database';

참고 자료


작성일: 2025-12-11 작성자: TK-FB-Project Team 버전: 1.0