포스트

NestJS 테스트 첫걸음: 백엔드 초보를 위한 백엔드 초보의 테스트 가이드

NestJS 테스트 첫걸음: 백엔드 초보를 위한 백엔드 초보의 테스트 가이드

🚀 단위 테스트 작성부터 JWT 인증 검증까지 한방에 끝내기 🚀

안녕하세요! 오늘은 백엔드 개발의 핵심! 테스트에 대해 이야기해보려 합니다. 특히, 많은 개발자분들이 어려워하시는 백엔드 테스트 아키텍처를 중심으로, 실제 NestJS 코드 예시와 함께 백엔드 초보인 제가 어떻게 테스트를 설계하고 작성하는지 알려드릴게요. 👩‍💻👨‍💻

잘 짜여진 테스트는 탄탄한 백엔드 서비스를 만드는 데 필수적입니다. 마치 건물을 짓기 전에 설계도를 꼼꼼히 확인하는 것처럼, 테스트는 우리가 작성한 코드의 안정성과 신뢰성을 보장해 줍니다. 💪

왜 백엔드 테스트 아키텍처가 중요할까요? 🤔

  • 코드 품질 향상: 테스트를 작성하는 과정에서 자연스럽게 코드 설계를 다시 생각하게 되고, 더 견고하고 유지보수하기 쉬운 코드를 만들 수 있습니다.
  • 버그 조기 발견: 개발 초기 단계에서 버그를 발견하고 수정하여, 서비스 릴리즈 후 발생할 수 있는 심각한 문제들을 예방할 수 있습니다. 🐛
  • 리팩토링 안정성 확보: 코드 구조를 변경하거나 기능을 개선할 때, 기존 기능을 망가뜨리지 않았는지 테스트를 통해 빠르게 확인할 수 있습니다. 🔄
  • 개발 속도 향상: 자동화된 테스트는 개발 과정의 반복 작업을 줄여주고, 빠르게 피드백을 얻을 수 있게 하여 전체 개발 속도를 향상시킵니다. 🚀
  • 팀 협업 효율 증대: 테스트 코드는 문서 역할도 하기 때문에, 팀원들이 코드를 이해하고 협업하는 데 큰 도움이 됩니다. 🤝

주로 테스트를 어떻게 접근해야 할까요? 🏢

테스트 피라미드 라는 개념, 들어보셨나요? 기업들은 효과적인 테스트 전략을 위해 피라미드 모델을 많이 참고합니다.

테스트 피라미드

테스트 피라미드

  • 단위 테스트: 70% (빠르고 집중적)
  • 통합 테스트: 20% (모듈 간 상호작용)
  • E2E 테스트: 10% (전체 시스템 검증)

단위 테스트 (Unit Test): 피라미드의 가장 밑단이자 핵심!

  • 가장 작고 독립적인 코드 단위 (함수, 메서드, 클래스 등) 를 격리하여 테스트합니다.
  • 빠르고 쉽게 작성 및 실행 가능해야 합니다.
  • 모든 기능의 핵심 로직을 꼼꼼하게 검증하는 데 집중합니다.
  • Mocking (모킹) 기법을 사용하여 외부 의존성 (다른 모듈, DB, API 등)을 가짜 객체로 대체하고, 오직 테스트 대상 코드에만 집중합니다.
  • 예시: 서비스 레이어의 특정 메서드, 유틸리티 함수, 모델의 로직 등

  • 통합 테스트 (Integration Test): 중간 레벨!
    • 여러 개의 단위 모듈이 함께 작동하는 것을 테스트합니다.
    • 단위 테스트보다는 느리지만, 시스템 전체를 테스트하는 것보다는 빠릅니다.
    • 실제 환경과 유사한 환경에서 테스트하여, 모듈 간의 상호작용을 검증합니다.
    • 실제 DB, API, 외부 서비스 등을 연동하여 테스트할 수 있습니다.
    • 예시: 컨트롤러와 서비스 레이어의 통합, 서비스와 리포지토리 레이어의 통합, API 엔드포인트 통합 등
  • E2E 테스트 (End-to-End Test): 피라미드의 최상단!
    • 실제 사용자 시나리오를 기반으로, 시스템 전체를 처음부터 끝까지 테스트합니다.
    • 가장 느리고 비용이 많이 드는 테스트이지만, 사용자 관점에서 시스템의 전체적인 기능을 검증합니다.
    • UI 테스트, API 테스트 등을 포함하며, 실제 사용 환경과 최대한 유사하게 구성합니다.
    • 예시: 로그인부터 특정 기능 사용 완료까지의 전체 흐름, 사용자 주문 프로세스 등
구분단위 테스트통합 테스트E2E 테스트
목적함수/클래스 검증모듈 간 연결 검증사용자 시나리오 검증
속도빠름중간느림
의존성Mocking 사용실제 DB/API 부분 사용전체 시스템 사용
예시AuthService.loginAuthController ↔ AuthService로그인 → 프로필 조회

피라미드 형태인 이유?

  • 단위 테스트는 빠르고 격리된 환경에서 실행되므로, 개발 초기에 많이 작성하여 빠르게 버그를 잡는 데 집중합니다.
  • 통합 테스트E2E 테스트는 단위 테스트보다 느리고 환경 설정이 복잡하므로, 핵심 기능 위주로 적절한 수를 유지합니다.

NestJS 코드 예시로 단위 테스트 아키텍처 살펴보기 🔍

자, 이제 실제 코드를 보면서 좀 더 자세히 알아볼까요? 여러분이 올려주신 AuthControllerAuthService 테스트 코드를 예시로 살펴보겠습니다.

1. AuthController 단위 테스트 (AuthController.spec.ts)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { Gender } from '../users/enums/user.enum';

describe('AuthController', () => {
  let controller: AuthController;
  let authService: AuthService;

  // ✅ AuthService를 Mocking 합니다. (진짜 AuthService 대신 가짜 객체 사용)
  const mockAuthService = {
    register: jest.fn(), // jest.fn(): Jest Mock 함수 생성
    login: jest.fn(),
    deleteAccount: jest.fn(),
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [AuthController],
      providers: [
        {
          provide: AuthService, // AuthController가 의존하는 AuthService를
          useValue: mockAuthService, // mockAuthService로 대체
        },
      ],
    }).compile();

    controller = module.get<AuthController>(AuthController);
    authService = module.get<AuthService>(AuthService);

    jest.clearAllMocks(); // 각 테스트 케이스 시작 전에 Mock 함수 호출 기록 초기화
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });

  describe('register', () => {
    it('should register a new user successfully', async () => {
      // Arrange (준비): 테스트에 필요한 데이터와 Mock 설정
      const registerDto: RegisterDto = {
        email: 'test@example.com',
        password: 'Test123!@#',
        name: '홍길동',
        gender: Gender.MALE,
        country: 'KR',
      };
      // ✅ mockAuthService.register() 메서드가 호출될 때 'mock-token' 반환하도록 설정
      mockAuthService.register.mockResolvedValue({ accessToken: 'mock-token' });

      // Act (실행): 테스트 대상 코드 실행 (AuthController의 register 메서드)
      const result = await controller.register(registerDto);

      // Assert (검증): 실행 결과 및 Mock 함수 호출 여부 검증
      expect(result).toEqual({ accessToken: 'mock-token' }); // 반환값이 예상과 일치하는지 확인
      // ✅ mockAuthService.register()가 registerDto와 함께 호출되었는지 확인
      expect(mockAuthService.register).toHaveBeenCalledWith(registerDto);
    });
  });

  // ... (login, getProfile, deleteAccount 테스트 생략) ...
});

주요 포인트:

  • 컨트롤러 단위 테스트: AuthController 자체의 로직만 테스트하고, AuthService의 동작은 Mocking으로 대체합니다.
  • mockAuthService: AuthService를 흉내내는 가짜 객체를 생성하여, 테스트를 격리하고 예측 가능한 환경을 만듭니다.
  • jest.fn(): Jest에서 제공하는 Mock 함수를 생성하는 메서드입니다. Mock 함수의 동작을 mockResolvedValue, mockRejectedValue, mockImplementation 등으로 설정할 수 있습니다.
  • toHaveBeenCalledWith(): Mock 함수가 특정 인수로 호출되었는지 검증하는 Jest Matcher입니다.
  • AAA 패턴 (Arrange-Act-Assert): 테스트 구조를 명확하게 나누어 가독성을 높입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { User } from '../users/entities/user.entity';
import { JwtService } from '@nestjs/jwt';
import { getRepositoryToken } from '@nestjs/typeorm';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { Gender } from '../users/enums/user.enum';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';

jest.mock('bcryptjs'); // ✅ bcryptjs 모듈 전체를 Mocking

describe('AuthService', () => {
  let service: AuthService;
  let jwtService: JwtService;

  // ✅ UserRepository를 Mocking 합니다.
  const mockUserRepository = {
    findOne: jest.fn(),
    create: jest.fn(),
    save: jest.fn(),
    delete: jest.fn(),
  };

  // ✅ JwtService를 Mocking 합니다.
  const mockJwtService = {
    sign: jest.fn(),
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        AuthService,
        {
          provide: getRepositoryToken(User), // UserRepository를
          useValue: mockUserRepository, // mockUserRepository로 대체
        },
        {
          provide: JwtService, // JwtService를
          useValue: mockJwtService, // mockJwtService로 대체
        },
      ],
    }).compile();

    service = module.get<AuthService>(AuthService);
    jwtService = module.get<JwtService>(JwtService);

    jest.clearAllMocks();
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('register', () => {
    // ... (register 성공 테스트 케이스) ...

    it('should throw ConflictException if email already exists', async () => {
      // Arrange
      const registerDto: RegisterDto = { /* ... */ };
      // ✅ mockUserRepository.findOne()이 사용자 정보를 반환하도록 설정 (이메일 중복 상황)
      mockUserRepository.findOne.mockResolvedValue({
        id: '123',
        email: registerDto.email,
      });

      // Act & Assert (실행 및 예외 검증)
      await expect(service.register(registerDto)).rejects.toThrow(
        ConflictException, // 예상되는 예외: ConflictException
      );
    });
  });

  describe('login', () => {
    it('should successfully authenticate user and return access token', async () => {
      // Arrange (준비): 테스트 환경 및 예상 데이터 설정
      const loginDto: LoginDto = { // ✅ 로그인에 필요한 DTO (Data Transfer Object) 생성
        email: 'test@example.com',
        password: 'Test123!@#',
      };
      const mockUser = { // ✅ 가짜 사용자 객체 생성 (UserRepository Mocking)
        id: '123',
        email: loginDto.email,
        password: 'hashed-password', //  DB에 저장된 해싱된 비밀번호라고 가정
      };
      const mockToken = 'mock.jwt.token'; // ✅ Mock JwtService에서 반환될 가짜 JWT 토큰

      // ✅ UserRepository의 findOne 메서드가 호출될 때 mockUser를 반환하도록 Mock 설정
      mockUserRepository.findOne.mockResolvedValue(mockUser);
      // ✅ bcrypt.compare 메서드가 호출될 때 true (비밀번호 일치)를 반환하도록 Mock 설정
      (bcrypt.compare as jest.Mock).mockResolvedValue(true);
      // ✅ JwtService의 sign 메서드가 호출될 때 mockToken을 반환하도록 Mock 설정
      mockJwtService.sign.mockReturnValue(mockToken);

      // Act (실행): 테스트 대상 메서드 (AuthService의 login 메서드) 실행
      const result = await service.login(loginDto); // ✅ loginDto를 인자로 AuthService의 login 메서드 호출

      // Assert (검증): 실행 결과 및 Mock 객체 상호작용 검증
      expect(result).toEqual({ accessToken: mockToken }); // ✅ 반환된 결과 (accessToken)이 예상 값과 일치하는지 검증
      // ✅ UserRepository의 findOne 메서드가 올바른 인수로 호출되었는지 검증 (이메일과 isDeleted: false 조건으로 사용자 검색)
      expect(mockUserRepository.findOne).toHaveBeenCalledWith({
        where: { email: loginDto.email, isDeleted: false },
      });
      // ✅ bcrypt.compare 메서드가 올바른 인수로 호출되었는지 검증 (입력 비밀번호와 DB 비밀번호 비교)
      expect(bcrypt.compare).toHaveBeenCalledWith(
        loginDto.password,
        mockUser.password,
      );
      // ✅ JwtService의 sign 메서드가 올바른 인수로 호출되었는지 검증 (JWT payload - sub, email 생성)
      expect(mockJwtService.sign).toHaveBeenCalledWith({
        sub: mockUser.id,
        email: mockUser.email,
      });
    });

    it('should throw UnauthorizedException when user not found', async () => {
      // Arrange (준비): 사용자 정보가 없는 상황 설정
      const loginDto: LoginDto = { // 로그인 DTO (가짜 이메일)
        email: 'nonexistent@example.com',
        password: 'Test123!@#',
      };
      // ✅ UserRepository.findOne()이 null을 반환하도록 Mock 설정 (사용자 찾을 수 없음)
      mockUserRepository.findOne.mockResolvedValue(null);

      // Act & Assert (실행 및 예외 검증): login 메서드 실행 시 UnauthorizedException 발생 검증
      await expect(service.login(loginDto)).rejects.toThrow(
        UnauthorizedException, // ✅ 예상대로 UnauthorizedException 예외가 발생하는지 검증
      );
    });

    it('should handle special characters in email during login', async () => {
      const loginDto: LoginDto = {
        email: 'invalid-email-format',
        password: 'Test123!@#'
      };
      await expect(service.login(loginDto)).rejects.toThrow(BadRequestException); // ✅ 400 에러 발생 기대
      
     // 추가 검증: 실제 로직이 실행되지 않았는지 확인
      expect(mockUserRepository.findOne).not.toHaveBeenCalled(); // ✅ DB 조회 X
      expect(bcrypt.compare).not.toHaveBeenCalled(); // ✅ 비밀번호 비교 X
      expect(mockJwtService.sign).not.toHaveBeenCalled(); // ✅ 토큰 생성 X
    });

    it('should throw UnauthorizedException when password is incorrect', async () => {
      // Arrange (준비): 비밀번호가 틀린 상황 설정
      const loginDto: LoginDto = { // 로그인 DTO
        email: 'test@example.com',
        password: 'WrongPassword123!@#', // 틀린 비밀번호
      };
      const mockUser = { // 가짜 사용자 객체 (정상 사용자)
        id: '123',
        email: loginDto.email,
        password: 'hashed-password', // DB에 저장된 해싱된 비밀번호
      };

      // ✅ UserRepository.findOne()은 mockUser 반환 (사용자는 존재)
      mockUserRepository.findOne.mockResolvedValue(mockUser);
      // ✅ bcrypt.compare()는 false 반환 (비밀번호 불일치)
      (bcrypt.compare as jest.Mock).mockResolvedValue(false);

      // Act & Assert (실행 및 예외 검증): login 메서드 실행 시 UnauthorizedException 발생 검증
      await expect(service.login(loginDto)).rejects.toThrow(
        UnauthorizedException, // ✅ 예상대로 UnauthorizedException 예외가 발생하는지 검증
      );
    });
  });
});

✨ 코드 예시 상세 해설: AuthService login 테스트 ✨

이제 조금 더 깊이 들어가서, AuthServicelogin 테스트 코드를 Arrange-Act-Assert (AAA) 패턴에 맞춰 자세히 해설해 드릴게요. 테스트 코드를 더 쉽고 명확하게 이해하는 데 도움이 될 거예요.

🤔 왜 AAA 패턴을 사용해야 할까요?

AAA 패턴은 테스트 코드의 구조를 명확하게 만들고, 가독성을 높여주며, 유지보수를 용이하게 해주는 아주 유용한 패턴입니다. 마치 요리 레시피처럼, 각 단계를 명확하게 구분하여 테스트의 목적과 과정을 쉽게 파악할 수 있게 도와줍니다.

  • Arrange (준비): 테스트를 실행하기 위한 모든 사전 준비 단계입니다. 테스트에 필요한 입력 데이터(request, parameter 등), Mock 객체 설정, 테스트 환경 구성 등을 포함합니다. “이러이러한 상황을 만들고”에 해당합니다.
  • Act (실행): 실제로 테스트하고자 하는 코드 조각을 실행하는 단계입니다. 주로 테스트 대상 메서드를 호출합니다. “이것을 실행했을 때” 에 해당합니다.
  • Assert (검증): Act 단계에서 실행된 코드의 결과를 검증하는 단계입니다. 예상 결과와 실제 결과를 비교하고, Mock 객체가 예상대로 호출되었는지 확인합니다. “이러이러한 결과가 나와야 한다” 에 해당합니다.

자, 그럼 AuthServicelogin 테스트 코드를 AAA 패턴에 맞춰 분석해 볼까요?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
  describe('login', () => {
    it('should successfully authenticate user and return access token', async () => {
      // Arrange (준비): 테스트 환경 및 예상 데이터 설정
      const loginDto: LoginDto = { // ✅ 로그인에 필요한 DTO (Data Transfer Object) 생성
        email: 'test@example.com',
        password: 'Test123!@#',
      };
      const mockUser = { // ✅ Mock UserRepository에서 반환될 가짜 사용자 객체
        id: '123',
        email: loginDto.email,
        password: 'hashed-password', //  DB에 저장된 해싱된 비밀번호라고 가정
      };
      const mockToken = 'mock.jwt.token'; // ✅ Mock JwtService에서 반환될 가짜 JWT 토큰

      // ✅ UserRepository의 findOne 메서드가 호출될 때 mockUser를 반환하도록 Mock 설정
      mockUserRepository.findOne.mockResolvedValue(mockUser);
      // ✅ bcrypt.compare 메서드가 호출될 때 true (비밀번호 일치)를 반환하도록 Mock 설정
      (bcrypt.compare as jest.Mock).mockResolvedValue(true);
      // ✅ JwtService의 sign 메서드가 호출될 때 mockToken을 반환하도록 Mock 설정
      mockJwtService.sign.mockReturnValue(mockToken);

      // Act (실행): 테스트 대상 메서드 (AuthService의 login 메서드) 실행
      const result = await service.login(loginDto); // ✅ loginDto를 인자로 AuthService의 login 메서드 호출

      // Assert (검증): 실행 결과 및 Mock 객체 상호작용 검증
      expect(result).toEqual({ accessToken: mockToken }); // ✅ 반환된 결과 (accessToken)이 예상 값과 일치하는지 검증
      // ✅ UserRepository의 findOne 메서드가 올바른 인수로 호출되었는지 검증 (이메일과 isDeleted: false 조건으로 사용자 검색)
      expect(mockUserRepository.findOne).toHaveBeenCalledWith({
        where: { email: loginDto.email, isDeleted: false },
      });
      // ✅ bcrypt.compare 메서드가 올바른 인수로 호출되었는지 검증 (입력 비밀번호와 DB 비밀번호 비교)
      expect(bcrypt.compare).toHaveBeenCalledWith(
        loginDto.password,
        mockUser.password,
      );
      // ✅ JwtService의 sign 메서드가 올바른 인수로 호출되었는지 검증 (JWT payload - sub, email 생성)
      expect(mockJwtService.sign).toHaveBeenCalledWith({
        sub: mockUser.id,
        email: mockUser.email,
      });
    });

    it('should throw UnauthorizedException when user not found', async () => {
      // Arrange (준비): 사용자 정보가 없는 상황 설정
      const loginDto: LoginDto = { // 로그인 DTO (가짜 이메일)
        email: 'nonexistent@example.com',
        password: 'Test123!@#',
      };
      // ✅ UserRepository.findOne()이 null을 반환하도록 Mock 설정 (사용자 찾을 수 없음)
      mockUserRepository.findOne.mockResolvedValue(null);

      // Act & Assert (실행 및 예외 검증): login 메서드 실행 시 UnauthorizedException 발생 검증
      await expect(service.login(loginDto)).rejects.toThrow(
        UnauthorizedException, // ✅ 예상대로 UnauthorizedException 예외가 발생하는지 검증
      );
    });

    it('should throw UnauthorizedException when password is incorrect', async () => {
      // Arrange (준비): 비밀번호가 틀린 상황 설정
      const loginDto: LoginDto = { // 로그인 DTO
        email: 'test@example.com',
        password: 'WrongPassword123!@#', // 틀린 비밀번호
      };
      const mockUser = { // 가짜 사용자 객체 (정상 사용자)
        id: '123',
        email: loginDto.email,
        password: 'hashed-password', // DB에 저장된 해싱된 비밀번호
      };

      // ✅ UserRepository.findOne()은 mockUser 반환 (사용자는 존재)
      mockUserRepository.findOne.mockResolvedValue(mockUser);
      // ✅ bcrypt.compare()는 false 반환 (비밀번호 불일치)
      (bcrypt.compare as jest.Mock).mockResolvedValue(false);

      // Act & Assert (실행 및 예외 검증): login 메서드 실행 시 UnauthorizedException 발생 검증
      await expect(service.login(loginDto)).rejects.toThrow(
        UnauthorizedException, // ✅ 예상대로 UnauthorizedException 예외가 발생하는지 검증
      );
    });
  });

효과적인 백엔드 테스트 작성을 위한 팁 꿀팁 🍯

  • 테스트 코드도 코드다!: 테스트 코드 또한 깨끗하고 유지보수 가능하게 작성해야 합니다.
    • 명확한 테스트 케이스 이름 (예: should register a new user successfully)
    • AAA 패턴, 중복 제거, 적절한 주석 활용
  • 경계값 테스트 (Boundary Value Test): 입력값의 경계 조건 (최소값, 최대값, null, empty 등) 에 대한 테스트를 꼼꼼하게 작성하여 예상치 못한 에러를 방지합니다.
  • 예외 케이스 테스트: 정상적인 흐름 뿐만 아니라, 예외 상황 (잘못된 입력, 외부 서비스 오류 등) 에 대한 테스트도 충분히 작성하여 안정성을 확보합니다.
  • 테스트 커버리지 (Test Coverage) 측정: 코드 커버리지 도구 (예: Jest Coverage) 를 사용하여 테스트가 코드의 어느 부분을 얼마나 커버하는지 확인하고, 부족한 부분을 보완합니다. (하지만 커버리지 숫자에만 매몰되지 않고, 실제 중요한 로직 위주로 테스트하는 것이 더 중요합니다!)
  • 자동화된 테스트 환경 구축: CI/CD 파이프라인에 테스트를 통합하여, 코드 변경 시 자동으로 테스트가 실행되도록 합니다.

📝 회고: 테스트 코드 작성 후 깨달은 점과 개선 방향

1. 예시 코드의 한계점 인식

작성된 테스트 코드가 계층별 책임 분리에 완벽히 부합하지 않았습니다. 주요 문제점은 다음과 같습니다:

문제점

  • 서비스 레이어에서의 유효성 검사 검증 서비스 테스트(AuthService)에서 이메일 형식 검증을 수행했습니다. → 이는 컨트롤러의 책임이며, 서비스 테스트는 비즈니스 로직 검증에 집중해야 합니다.
  • 통합 테스트와 단위 테스트의 모호한 경계 AuthService 테스트에서 UserRepository를 모킹했지만, 실제 의존성과의 상호작용 검증이 부족했습니다.

2. 개선을 위한 실천 방안

(1) 계층별 테스트 철저한 분리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 컨트롤러 테스트: 유효성 검사 책임
it('should reject invalid email in controller', async () => {
const invalidDto = { email: 'wrong-format', password: 'Test123!@#' };
const response = await request(app.getHttpServer())
.post('/auth/login')
.send(invalidDto);

expect(response.status).toBe(400);
expect(response.body.message).toContain('email must be an email');
});

// 서비스 테스트: 비즈니스 로직 책임 (모킹 활용)
it('should handle login logic without validation', async () => {
const validDto = { email: 'test@example.com', password: 'Test123!@#' };
mockUserRepository.findOne.mockResolvedValue({ /* ... */ });
// 유효성 검사는 컨트롤러에서 완료되었다고 가정
});

(2) Contract Testing 도입

컨트롤러-서비스 간 계약 정의:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// auth.contract.ts
export interface AuthServiceContract {
login(dto: LoginDto): Promise<{ accessToken: string }>;
}

// 테스트에서 계약 검증
it('should fulfill controller-service contract', async () => {
const dto = validLoginDto;
const expectedResult = { accessToken: 'token' };

mockAuthService.login.mockResolvedValue(expectedResult);
const result = await controller.login(dto);

expect(result).toEqual(expectedResult);
});`

(3) E2E 테스트로 Validation 보강

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// e2e/auth.e2e-spec.ts
describe('Auth API Validation (e2e)', () => {
  it('should block registration with weak password', async () => {
    const weakPasswordDto = {
    email: 'test@example.com',
    password: '1234', // 6자 미만
    name: '홍길동',
    gender: Gender.MALE,
    country: 'KR'
    };
  
    const response = await request(app.getHttpServer())
    .post('/auth/register')
    .send(weakPasswordDto);
    
    expect(response.status).toBe(400);
    expect(response.body.message).toContain('password is too weak');
  });
});

3. 깨달은 교훈

테스트 전략 수립의 중요성: 테스트 코드 작성 전에 계층별 테스트 범위를 명확히 정의해야 합니다.

  • 단위 테스트: 함수/클래스 단위 격리 검증
  • 통합 테스트: 모듈 간 상호작용 검증
  • E2E 테스트: 사용자 시나리오 검증

Mocking의 양면성 과도한 모킹은 테스트와 실제 코드의 괴리를 만듭니다. → 의존성 주입 프레임워크 활용이나 계약 테스트로 완화 가능합니다.

유지보수성 고려: 테스트 코드도 리팩토링이 필요합니다. → 중복 코드 제거, 테스트 픽스처(factory.ts) 활용, 가독성 개선 필수!

결론

  1. 테스트는 어렵지만 필수적입니다.
  2. 완벽한 테스트는 없으며 지속적인 개선이 필요합니다.
  3. 테스트 하기 싫다 ㅜㅜㅜ
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.