Study/Test Code

Test Code 작성 시, 규칙정하기 (Node.js, Nest.js, TypeScript)

isjiji 2025. 10. 4. 00:05

 

앞 글 참고 ! 

https://isjiji.tistory.com/103

 

Unit test Testcode 작성하기 - Nestjs, Typescript

디저트타임 배포전, 잔잔바리 오류들이 생겨나고 개발할 때 확인하지 못했던 문제들을 발견하면서 test code의 필요성을 알게되었다. test code의 필요성1. 기획변경 혹은 설정변경, 오류 등으로 인

isjiji.tistory.com

 

 

test code를 작성할 때는 어떤 흐름이나 구조로 생각해야할까? 

testcode를 많이 짜보지 않았기 때문에 늘 이부분이 혼란스럽고 어렵게 다가왔다.

혹시라도 test case가 충분한 검증을 못한다면? mock이 무쓸모한 결과를 가져온다면? 그리고 과거 testcase를 작성할 때, 이렇게 데이터를 직접 주입해주면 testcase의 오류를 검증할 수 있나? 하는 고민도 있었다. 

 

 

이런 질문에 대한 정리! 

 

 

흐름은 다음과 같다. 

목적과 범위를 정의(무엇을 보장할것인지) -> 범위(어떤 종류의 테스트인지) -> 케이스(무엇을 검증할 것인지) -> 어떻게 구현할 것인지 

 

 

 

테스트 작성시 고려해야할 기본내용 정리 

 

목적과 범위를 먼저 정의하라. (무엇을 보장할 것인가?)

 

  • 비즈니스 규칙(도메인 로직): 가장 중요. 오류없이 동작해야 하는 핵심 로직을 우선 테스트한다.
    이유: 여기서 버그나 회귀가 발생하면 사용자에게 바로 영향이 가기 때문.
  • 계약(Contract): 함수/메서드의 입력-출력(반환값, 예외), 외부 호출(Repository, 외부API) 등을 명확히 한다.
    이유: 테스트는 이 계약을 문서화하고 자동으로 검증해준다.
  • 부작용(Side effects): DB 변경, 이벤트 발행, 파일 생성 등도 테스트 대상으로 삼는다.
  • 우선순위: 핵심 로직 > 복잡한 쿼리/변환/계산 > 입력 유효성(Validation) > Thin wrapper

 

 

테스트 종류를 정하라 (unit, intergration, e2e)

  • Unit Test: 한 단위(보통 클래스/함수)만 검증. 모든 외부 의존성(mock)
    언제: service의 비즈니스 로직, DTO 계산식, util 함수
    이유: 빠르고 정확하게 특정 로직만 검증
  • Integration Test: 여러 컴포넌트 통합(예: repository + real DB 또는 in-memory DB)
    언제: 복잡한 QueryBuilder, DB 쿼리 정확성, ORM 매핑 검증
    이유: 실제 동작(쿼리)을 검증해야 할 때
  • E2E Test: 전체 요청 흐름(HTTP) 검증. 보통 supertest, CI에서 실행
    언제: 라우트, 인증 흐름, 전체 플로우
    이유: 실제 사용자 시나리오 보장
  • 참고 ! 테스트 피라미드(유닛 많음 → 통합 적음 → E2E 최소) 원칙을 따르자. unit을 많이 작성하되 integration/e2e로 중요한 시나리오만 검증한다.

 

 

메서드(기능)별로 계약을 정의하라 (실패, 성공, 엣지케이스)

 

테스트는 *예상되는 모든 동작(명세)*과 잘못된 입력에 대해 보장해야 한다. 특히 엣지 케이스를 놓치면 운영에서 버그로 이어진다.

 

예: getUser(id) (Service)

  • 성공: 존재하는 id → 정확한 User 반환.
  • 실패: 존재하지 않는 id → NotFoundException 발생.
  • 오류: repository가 에러를 던질 때 service가 적절히 전달/포장하는가(예외 종류 확인).
  • 권한: 호출자에게 권한 체크가 있다면 권한 없음 케이스.

예: createUser(dto)

  • 성공: 정상 데이터 → 저장 후 반환(생성 id 등)
  • 실패: validation 실패(필수값 누락) → BadRequest / DTO 레벨 검증 필요
  • 실패: unique 제약 위반 → conflict 처리(에러 포장)

예: DTO에 cursor 계산 로직 (getOffset)이 있을 때

  • 정상: page>=1, size>0인 경우 올바른 offset
  • 엣지: page=1 → offset 0
  • 잘못된 입력: page<=0 or size<=0 → throw 또는 방어적 처리

 

 

 

테스트케이스 설계 시, 고려할 체크리스트 (우선순위)

 

  • Happy path (성공 시나리오) — 항상 작성
  • Not-found / error paths — 흔한 실패 시나리오
  • Validation & boundary (0, 1, max값) — off-by-one 실수 방지
  • 외부 의존성 실패 (DB/네트워크) — 예외 흐름 검증
  • 비동기 및 타이밍 문제 (setTimeout, Date.now 등) — fake timers 사용
  • 동시성 또는 상태 변화 (낡은 optimistic lock 등) — 필요시
  • 보안/권한/인증 — 권한 로직이 있다면 검증

 

 

 

테스트의 기본골격잡기 - AAA방식으로 사고하라 (Arrange-Act-Assert) 

 

 

 

AAA 패턴은 테스트 읽기/유지보수를 쉽게 만든다. 누가 봐도 무엇을 준비했고 무엇을 검증하는지 바로 이해된다.

  • Arrange: 테스트 데이터·의존성 세팅 (mock, fixture)
  • Act: 실제로 함수/메서드 실행
  • Assert: 기대값 검증 (반환값, 호출 횟수, 예외 등)

 

 

 

Mocking 전략 - 무엇을 어떻게 mock할 것인가

 

  • 무엇을 mock: DB, 외부 API, 파일시스템, 시계(Date), 랜덤값, 큐/메일 전송 등 외부 의존성.
  • 무엇을 안 mock: 순수한 계산/변환/DTO 로직 — 실제 인스턴스 사용.
  • 부분 mock: 복잡한 객체(예: TypeORM QueryBuilder) 는 체이닝 메서드를 mockReturnThis()로 만들어야 한다.

 

참고

//QueryBuilder는 메서드 체이닝을 사용하므로 각 메서드가 this를 반환하도록 mock 해줘야 체인 호출이 가능하다.

const mockQueryBuilder = {
  where: jest.fn().mockReturnThis(),
  andWhere: jest.fn().mockReturnThis(),
  orderBy: jest.fn().mockReturnThis(),
  getMany: jest.fn().mockResolvedValue([{ id: 1, name: 'A' }]),
};

const repo = { createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder) } as any;


//jest.Mocked<T> 타입을 쓰면 IDE에서 자동완성 되고 타입 안정성이 좋아진다:
let repo: jest.Mocked<UserRepository>;
repo = {
  findById: jest.fn(),
  save: jest.fn(),
  createQueryBuilder: jest.fn(),
} as any;

 

 

 

테스트 데이터 설계 - 반복 사용가능한 빌더/팩터리

  • 매 테스트마다 손으로 객체를 만들면 중복이 많아진다. factory 함수를 만들자
  • 큰 객체는 builder pattern으로 만들면 테스트 가독성이 좋아진다
  • 랜덤값 보다는 고정값(상수)을 사용하라 → 테스트 재현성 보장
  • 일관된 데이터는 디버깅과 유지보수에 유리하다
function makeUser(overrides?: Partial<User>): User {
  return new User(
    overrides?.id ?? 1,
    overrides?.name ?? 'Alice',
  );
}

 

 

 

 

테스트 네이밍 컨벤션 - 읽기 쉬운 이름

  • 좋은 이름은 문서 역할을 한다. CI 실패 시 무엇이 깨졌는지 바로 이해할 수 있다
  • 패턴: methodName_condition_expectedBehavior 또는 자연어로 it('should return user when id exists')
  • describe는 클래스/모듈, it/test는 동작을 설명

 

 

Setup / Teardown — beforeEach / afterEach

  • 각 테스트는 서로 영향받지 않도록 완전한 초기화가 필요하다.
  • 이유 : 테스트 간 상태 공유는 간헐적 실패(Flaky test)의 주된 원인이다.
  • DB를 사용하는 Integration Test는 트랜잭션 롤백 혹은 in-memory DB 재생성으로 격리한다.
 
beforeEach(() => {
  jest.clearAllMocks();
  repository = { findById: jest.fn(), save: jest.fn() } as any;
  service = new UserService(repository);
});

 

 

 

Parameterized / Table-driven tests

동일한 로직에 대해 여러 입력을 간결히 테스트할 때 사용

이유: 중복 코드 줄이고 경계값을 한눈에 확인 가능.

test.each([
  [1, 0],
  [2, 10],
  [3, 20],
])('page %i, size %i => offset %i', (page, size, expectedOffset) => {
  const dto = { page, size } as CursorDto;
  expect(dto.getOffset()).toBe(expectedOffset);
});

 

 

 

비동기 테스트 주의사항

  • async/await 사용하거나 return으로 Promise 반환
  • 예외 검사: await expect(promise).rejects.toThrow(...)
  • 타이머 조작 시: jest.useFakeTimers() + jest.runAllTimers()

 

Flaky(간헐적 실패) 방지

  • 네트워크/시간/랜덤 사용 최소화 → 외부 의존성 mock
  • DB 테스트는 in-memory DB 또는 Testcontainers로 환경 고정
  • 테스트는 가능한 동기적으로 설계(또는 명확한 await 사용)

 

커버리지 전략 — 얼마를 목표로?

  • 목표는 숫자(예: 80%)보다 리스크 기반 접근
    • 중요한 비즈니스 로직은 모두 커버
    • thin wrapper, 자동 매핑 코드에는 낮은 우선순위
  • CI에서 최소 커버리지를 설정할 수 있으나, 숫자만으로 판단하지 말자

 

CI 통합 및 실행 속도

  • unit tests는 빠르게(수 초 ~ 수십 초). integration/e2e는 느리므로 병렬화/분리 실행 권장.
  • PR에서 unit test가 먼저 실패하면 빠르게 피드백을 받을 수 있다.

 

디버깅 팁

  • it.only로 해당 테스트만 실행.
  • 실패 시 스택과 mock 호출 내역(toHaveBeenCalledWith) 확인.
  • console.log는 마지막 수단. 대신 expect로 올바른 값 확인.

 

 

최종 체크리스트 (테스트 짜기 전/후)

  • 이 기능의 비즈니스 핵심 로직은 무엇인가? → 우선 테스트
  • 입력값의 올바른 범위/경계값 정의했는가?
  • 실패 시나리오(존재하지 않음, 권한, DB에러)를 정의했는가?
  • 외부 의존성(네트워크/DB/파일)을 mock할 수 있는가? 어떤 수준에서 mock할 것인가?
  • 테스트 데이터는 재현 가능한가(고정값)? factory가 있는가?
  • 테스트 이름으로 의도를 설명하고 있는가?
  • beforeEach/afterEach로 상태격리를 보장하는가?
  • 테스트가 flaky하지 않도록 외부 요소를 제거했는가?

 

 

마무리(권장 우선순위)

  1. Service 단위 테스트를 집중적으로 작성하라 — 핵심 비즈니스 로직이 여기 있음.
  2. DTO/유틸 함수 테스트 — 계산식, 변환 로직이 있다면 반드시.
  3. Repository는 커스텀 쿼리가 있으면 테스트(통합 또는 QueryBuilder mock). thin wrapper만 있으면 우선순위 낮음.
  4. Controller는 단순 라우팅이면 E2E로 보장(통합 검증). Controller에 복잡한 변환/파이프가 있으면 unit test 추가.
 

 

'Study > Test Code' 카테고리의 다른 글

testcode 작성법 링크 todo  (0) 2025.11.13
Unit test Testcode 작성하기 - Nestjs, Typescript  (0) 2025.09.13