
이 포스트를 시작하기에 앞서 한 가지 분명히 밝혀두고 싶은 점이 있다. 나는 컨트롤러(혹은 GraphQL 기준으로는 리졸버) 와 레포지토리 클래스에 대한 테스트를 거의 작성하지 않는다.
이유는 단순하다. 애플리케이션에서 가장 많은 비즈니스 규칙이 응집되어 있는 곳은 서비스 계층이며, 테스트 비용 대비 효율을 고려했을 때 이 계층을 집중적으로 검증하는 것이 가장 낫다고 생각하기 때문이다. 컨트롤러는 요청을 받아 서비스로 위임하고 응답을 반환하는 역할에 가깝고, 레포지토리는 데이터 접근 로직에 가깝다. 이 둘은 서비스 계층이 정상적으로 동작한다는 전제하에 상대적으로 변동 가능성이 낮다. 그래서 내가 작성하는 테스트는 대부분 서비스 ∙ 헬퍼 ∙ 유틸리티 클래스에 집중되어 있다.
환경 변수 설정
테스트 환경에서도 환경 변수가 필요한 경우가 많다. 물론 테스트 코드에서 값을 직접 하드코딩할 수도 있지만, 아래와 같은 방식을 사용하면 환경 변수 노출 없이 .env.test 파일의 값을 가져올 수 있다.
describe('', () => {
let jwt: ConfigType<typeof jwtConfig>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
envFilePath: '.env.test',
}),
],
providers: [
{
provide: jwtConfig.KEY,
useValue: jwtConfig(),
},
],
}).compile();
jwt = module.get(jwtConfig.KEY);
});
});이 방식의 장점은 다음과 같다.
테스트 환경과 실제 환경을 명확히 분리할 수 있다.
민감한 설정 값을 코드에 직접 노출하지 않아도 된다.
ConfigService를 직접 모의하지 않고도 설정 값을 주입할 수 있다.
프로바이더 모의(Mock)
Nest.js는 DI 컨테이너를 기반으로 동작한다. 따라서 테스트를 작성할 때 가장 중요한 것은 _어떤 프로바이더를 실제 구현으로 쓰고, 어떤 프로바이더를 모의 객체로 대체할 것인가_를 결정하는 일이다.
Nest.js는 이를 위해 Test.createTestingModule이라는 전용 API를 제공한다. 이 메서드를 통해 테스트에 필요한 모듈을 구성하고, 각종 프로바이더를 자유롭게 등록하거나 대체할 수 있다.
유닛 테스트에서는 보통 테스트 대상이 아닌 의존성은 모두 모의 객체로 대체한다. 이를 구현하는 방식으로는 크게 세 가지가 있다.
useValue
useValue는 정적인 객체를 그대로 주입하는 방식이다. 가장 단순하면서도 실무에서 가장 자주 쓰게 되는 방식이다.
describe('BookService', () => {
let bookService: BookService;
let bookRepository: BookRepository;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BookService,
{
provide: BookRepository,
useValue: {
getBookByIsbn: jest.fn(),
createBook: jest.fn(),
getBookFromAladin: jest.fn(),
getBookListFromAladin: jest.fn(),
},
},
],
}).compile();
bookService = module.get(BookService);
bookRepository = module.get(BookRepository);
});
});테스트 중 특정 메서드가 호출되었는지 확인하거나, 반환값을 자유롭게 바꿔야 할 때 매우 유용하다.
useClass
useClass는 실제 클래스 대신 대체 클래스를 주입하는 방식이다. 모의 클래스 내부에 기본 동작을 정의해두고, 테스트마다 그 동작을 활용할 수 있다.
class MockBookRepository {
getBookByIsbn = jest.fn((isbn13: string) => {
if (isbn13 === '') throw new NotFoundException();
return Book;
});
getBookListByKeyWord = jest.fn();
createBook = jest.fn();
}describe('BookService', () => {
let bookService: BookService;
let bookRepository: BookRepository;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BookService,
{
provide: BookRepository,
useClass: MockBookRepository,
},
],
}).compile();
bookService = module.get(BookService);
bookRepository = module.get(BookRepository);
});
});여러 테스트에서 동일한 기본 동작을 재사용하고 싶을 때 적합하다.
useFactory
useFactory는 팩토리 함수를 사용해 런타임에 값을 생성하는 방식이다. 테스트마다 서로 다른 의존성을 주입해야 하거나, 초기화 과정이 복잡한 경우에 사용할 수 있다.
const MockBookRepository = (book: Book) => ({
getBookByIsbn: jest.fn((isbn13: string) => {
if (isbn13 === '') throw new NotFoundException('Book not found');
return book;
}),
getBookListByKeyWord: jest.fn(),
createBook: jest.fn(),
});beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BookService,
{
provide: BookRepository,
useFactory: () => MockBookRepository(book),
},
],
}).compile();
});개인적으로는 가장 덜 사용하는 방식이지만, 필요할 때는 확실히 쓸모가 있다.
기본 구조
반드시 이렇게 해야 하는 것은 아니지만, 나는 모든 테스트 전에 beforeEach로 모듈을 새로 초기화하는 방식을 선호한다. 이렇게 하면 테스트 간에 발생할 수 있는 side effect를 확실히 차단할 수 있다.
describe('AuthService', () => {
let authService: AuthService;
let authRepository: AuthRepository;
let jwt: ConfigType<typeof jwtConfig>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
envFilePath: '.env.test',
}),
],
providers: [
AuthService,
{
provide: AuthRepository,
useValue: {
createUser: jest.fn(),
getUser: jest.fn(),
},
},
{
provide: jwtConfig.KEY,
useValue: jwtConfig(),
},
],
}).compile();
authService = module.get(AuthService);
authRepository = module.get(AuthRepository);
jwt = module.get(jwtConfig.KEY);
});
describe('signIn', () => {});
describe('signUp', () => {});
});모든 테스트에 test.only를 남겨두는 것보다, 이 방식이 훨씬 안전하고 예측 가능하다. 테스트는 언제나 독립적으로 실행될 수 있어야 한다고 생각하기 때문이다.
더 읽어보기
2025.05.21
14. Redis를 사용한 세션 관리 및 캐싱
새로운 프로젝트를 준비하면서 인증 방식부터 다시 고민하게 되었다. 이전 프로젝트에서는 JWT를 사용해 유저 인증과 상태 관리를 처리했지만, 이번에는 Redis를 활용한 세션 방식으로 전환하기로 결정했다. 이 방식은 유저 상태를 서버에서 직접 관리할 수 있어 보안 측면에서도 유리하고, 필…
2025.01.30
비즈니스 로직은 어디에 있어야 할까
Nest.js에서 컨트롤러와 각각의 프로바이더는 분명한 책임을 갖는다. 하지만 막상 서버를 개발하다 보면, 이 책임들을 깊이 고민하지 않은 채 코드를 작성하게 되고, 그 결과 클래스들이 서로의 영역을 침범하는 상황이 반복된다. 나 역시 컨트롤러에 비즈니스 로직이 섞이거나, 서비스가 지나…
2024.12.07
라이브러리를 죽여버릴 수야 없겠지만
상황 Nest.js 서버에서 jest를 사용한 테스트 코드를 작성하고 있었다. 평소에는 아래와 같이 ConfigService를 모의하여 configService.get으로 환경 변수를 처리했다. 그런데 이런 방식이 마음에 들지 않았다. 가장 큰 이유는 필요한 문자열이 하드코딩 되어있어…
2024.11.14
12. GraphQL로 요청 처리
GraphQL은 페이스북에서 개발한 데이터 쿼리 언어이자 런타임으로, 기존 REST API가 가진 구조적 한계를 보완하기 위해 등장했다. 전통적인 REST API에서는 각 엔드포인트마다 반환되는 데이터 구조가 고정되어 있고, 동일한 리소스에 대해 서로 다른 작업을 수행하기 위해 HTTP…
2026.06.01
React Server Components를 위한 컴포넌트 아키텍처
이 포스트는 Vercel의 Next.js 팀 소속 개발자 Aurora Scharff가 자신의 블로그에 올린 Component Architecture for React Server Components 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는…
2026.05.26
차트는 멈췄는데 윈도우가 움직인다
상황 어느날 서비스를 살펴보시던 팀장님께서 이런 말씀을 slack에 남기셨다. 진호님, 예측 차트에서 zoom을 계속하면 어느 순간 라인 차트가 아니라 단일 스캐터 차트처럼 보이는 데, 이거 수정하면 좋을 거 같아요. 어느정도 zoom을 하면 그 이후로는 zoom이 안 되도록 할 수 없…
2026.05.21
피자가게로 이해하는 디자인 패턴
에이든 피자는 처음부터 복잡한 시스템을 만들 생각이 없었다. 처음에는 메뉴 몇 개만 만들면 됐다. 그런데 손님은 커스텀 주문을 넣기 시작했고, 주방은 상태를 나눠야 했고, 결제와 배달앱과 알림이 하나씩 붙었다. 코드도 가게를 닮는다. 장사가 잘될수록 이상하게 더 쉽게 망가진다. 디자인…
2026.05.21
7. Decorator — 토핑 추가할 때마다 클래스를 새로 만들 수 없다
에이든 피자에서 주문서를 객체로 만들자 취소와 재주문은 한결 편해졌다. 그런데 주문이 편해지자 손님들도 한결 편해졌다. 편해진 손님은 더 많은 요구를 한다. "치즈 추가요", "올리브도 추가요", "소스 많이요", "조금 더 바삭하게 구워주세요" 같은 요청이 주문대 위로 쌓이기 시작했다…
댓글
댓글을 불러오는 중...