앞 글 참고 !
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하지 않도록 외부 요소를 제거했는가?
마무리(권장 우선순위)
- Service 단위 테스트를 집중적으로 작성하라 — 핵심 비즈니스 로직이 여기 있음.
- DTO/유틸 함수 테스트 — 계산식, 변환 로직이 있다면 반드시.
- Repository는 커스텀 쿼리가 있으면 테스트(통합 또는 QueryBuilder mock). thin wrapper만 있으면 우선순위 낮음.
- Controller는 단순 라우팅이면 E2E로 보장(통합 검증). Controller에 복잡한 변환/파이프가 있으면 unit test 추가.
'Study > Test Code' 카테고리의 다른 글
| testcode 작성법 링크 todo (0) | 2025.11.13 |
|---|---|
| Unit test Testcode 작성하기 - Nestjs, Typescript (0) | 2025.09.13 |