Mock 이란?

정의

소프트웨어 테스트에서 실제 객체를 대신하여 사용하는 '가짜 객체'를 의미

 

사용하는 이유

  • 테스트 환경을 구현하기 힘든 경우
  • 테스트 대상이 외부 시스템과의 의존성이 높아 직접 테스트하기 힘든 경우 
  • 테스트하기 힘든 상황이 기대한 대로 상호작용하는지 검증하기 위한 경우 
  • 테스트 실행에 많은 시간이 걸리는 경우

사용되는 경우

  • 데이터베이스 연동 테스트
  • 외부 API 호출 테스트
  • 네트워크 통신 테스트
  • 파일 시스템 접근 테스트

사용하지 말아야하는 경우

  • 순수 함수 : reduce, map 등등 은 입력이 같으면 항상 같은 결과를 반환
  • 간단한 상태 관리 로직 
  • 단순한 의존성 또는 데이터 구조를 사용하는 경우
  • 실제 동작 검증이 필요한 경우
  • 과도한 mocking 으로 인해 테스트 유지보수가 어려운 경우
  • Mock 객체의 설정이 실제 환경과 크게 다를 경우
  • 상태 검증 기반이 더 적합한 경우

종류

  • 스텁(stub) : 특정한 입력에 대해 미리 정해진 결과를 반환하도록 하드 코딩된 객체
  • 페이크 객체 (fake object) : 실제로 동작하는 것처럼 보이지만, 프로덕션 환경에서 사용하기에는 단순화된 구현을 가진 객체
  • 스파이 (spy) : 실제 객체를 부분적으로 목킹하여, 호출된 메서드나 파라미터 등을 기록하고 검증할 수 있는 객체
  • Mock 객체 (mock object) : 실제 객체의 행위를 흉내내어 행위 기반 테스트를 위해 사용. 호출 여부, 호출 횟수, 전달된 파라미터 등을 검증가능

이점

  • 외부 의존성(API, 데이터베이스 등) 없이 테스트 가능
  • 테스트 실행 속도 향상
  • 특정 상황(에러, 타임아웃 등)을 쉽게 시뮬레이션
  • 함수 호출 여부, 인자, 횟수 등을 검증 가능

Mock 객체 사용 여부 판단 기준

  1. 이 코드가 외부 리소스에 의존하는가?
    • API 호출
    • 데이터베이스 접근
    • 파일 시스템 사용 : 파일 읽기/쓰기 가 필요한 경우
  2. 테스트 실행이 느리거나 비용이 드는가?
    • 무거운 연산
    • 네트워크 지연
    • API 호출 비용
  3. 테스트 결과가 비결정적인가?
    • 랜덤 값 사용 : 무작위 값에 따라 결과가 달라지는 경우
    • 시간에 의존적인 로직 : 현재 시간이나 날짜에 따라 동작이 변하는 경우
    • 동시성 이슈 : 멀티스레딩 등으로 인해 겨로가가 일정하지 않은 경우 
  4. 테스트 셋업이 복잡한가?
    • 많은 의존성 필요 : 여러 모듈이나 서비스에 의존하는 경우
    • 복잡한 상태 설정 필요

 

(단순한 예시) 테스트를 위해서 만들어진 user 객체 ?

▶ user 는 테스트를 위해서 임의로 만들어진 단순한 데이터 객체이지 행위는 없다. 그러므로 Mock 객체라고 할 수 없다. 

 

signUp service code

@Transactional()
  async signUp(signUpRequestDto: SignUpRequestDto): Promise<any> {
    const { email, name, password } = signUpRequestDto;
    const user = await this.userRepository.findOneUserByEmailLockMode(email);
    if (user) {
      throw new ConflictException('The email is already in use');
    }
    const saltRounds = this.configService.get<string>('SALT_ROUNDS');
    if (saltRounds === undefined) {
      throw new Error('SALT_ROUNDS is not defined in the configuration.');
    }
    const hashedPassword = await bcrypt.hash(password, parseInt(saltRounds));

    const newUserEntity = new User({ name, email, password: hashedPassword });
    const newUser = await this.userRepository.signUp(newUserEntity);
    return new SignUpResponseDto({ ...newUser });
  }

 

고려해야 할 것 들이 많아서 한참 고생을 했다.

다음에 참고하기 위해서 기록을 남김

 

목적

  • signUp 안에 구현된 코드들이 원하는대로 작동하는 가만 확인하기

테스트시 확인해야 하는 사항들

  • configService.get
    • 'SALT_ROUNDS' 를 정상적으로 받는가?
    • undefined 이면 에러를 throw 하는가?
  • bcrypt
    • password, parseInt(saltRounds) 변수를 받아서 사용하고 있는가?
    • hashedPassword 를 잘 반환하는가?
  • userRepository
    • 중복되는 email 이 있을시 에러를 throw 하는가?

 

bcrypt mocking

import * as bcrypt from 'bcrypt';

// import 한 함수를 중 사용할 method 를 테스트하기위해서 mocking
jest.mock('bcrypt', () => ({
  hash: jest.fn(),
}));

// 값을 반환 받기 위한 코드
jest.spyOn(bcrypt, 'hash').mockImplementation(async () => 'hashedpassword');

 

transactional 을 사용하고 있기 때문에 이 부분을 mocking

jest.mock('typeorm-transactional', () => ({
  Transactional: () => jest.fn(),
  initializeTransactionalContext: jest.fn(),
}));

 

 .env 파일을 읽어오는 configureService 를 mocking

const mockConfigService = {
  get: jest.fn(),
};

// 원하는 값이 없는 경우
configService.get.mockReturnValue(undefined);

// 원하는 값이 있는 경우
const saltRounds = '10';
configService.get.mockReturnValue(saltRounds);

 

 

 

 

완성된 코드

import { UserService } from '../application/user.service';
import { UserRepository } from '../domain/user.repository';
import { Test, TestingModule } from '@nestjs/testing';
import { USER_REPOSITORY } from '../../common/const/inject.constant';
import { SignUpRequestDto } from '../dto/signUp.request.dto';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../../auth/application/auth.service';
import { User } from '../domain/User.entity';
import * as bcrypt from 'bcrypt';
import { ConflictException } from '@nestjs/common';

jest.mock('typeorm-transactional', () => ({
  Transactional: () => jest.fn(),
  initializeTransactionalContext: jest.fn(),
}));

jest.mock('bcrypt', () => ({
  hash: jest.fn(),
}));

const mockUserRepository: jest.Mocked<UserRepository> = {
  signUp: jest.fn(),
  findOneUserByEmailLockMode: jest.fn(),
  findOneUserByEmail: jest.fn(),
  findOneUserById: jest.fn(),
  softDeleteUser: jest.fn(),
};

const mockConfigService = {
  get: jest.fn(),
};

describe('UserRepository', () => {
  let userRepository: jest.Mocked<typeof mockUserRepository>;
  let userService: UserService;
  let configService: jest.Mocked<typeof mockConfigService>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserService,
        {
          provide: USER_REPOSITORY,
          useValue: mockUserRepository,
        },
        {
          provide: ConfigService,
          useValue: mockConfigService,
        }
      ],
    }).compile();

    userService = module.get<UserService>(UserService);
    configService = module.get(ConfigService);
    userRepository = module.get(USER_REPOSITORY);
  });

  describe('signUp', () => {
    it('should throw ConflictException if email is already in use', async () => {
      const usedEmail = 'useremail@email.com';
      const signUpRequestDto: SignUpRequestDto = { name: 'tester', email: usedEmail, password: '12345678' };
      const { email, name, password } = signUpRequestDto;
      const user: User = new User({ email, name, password });
      user.id = 1;

      userRepository.findOneUserByEmailLockMode.mockResolvedValue(user);

      await expect(userService.signUp(signUpRequestDto)).rejects.toThrow(ConflictException);
    });

    it('should throw Error if saltRounds is not set in configService', async () => {
      const usedEmail = 'useremail@email.com';
      const signUpRequestDto: SignUpRequestDto = { name: 'tester', email: usedEmail, password: '12345678' };
      const { email, name, password } = signUpRequestDto;
      const user: User = new User({ email, name, password });
      user.id = 1;

      userRepository.findOneUserByEmailLockMode.mockResolvedValue(null);
      configService.get.mockReturnValue(undefined);

      await expect(userService.signUp(signUpRequestDto)).rejects.toThrow(Error);
    });

    it('should sign up a new user ', async () => {
      const signUpRequestDto: SignUpRequestDto = { name: 'tester', email: 'test@email.com', password: '12345678' };
      const { email, name, password } = signUpRequestDto;
      const newUser: User = new User({ email, name, password });
      newUser.id = 1;
      const saltRounds = '10';

      userRepository.findOneUserByEmailLockMode.mockResolvedValue(null);
      configService.get.mockReturnValue(saltRounds);
      jest.spyOn(bcrypt, 'hash').mockImplementation(async () => 'hashedpassword');
      userRepository.signUp.mockResolvedValue(newUser);

      const result = await userService.signUp(signUpRequestDto);
      console.log(result);
      expect(bcrypt.hash).toHaveBeenCalledWith(password, parseInt(saltRounds));
    });
  });
});

목차

런던파

주요 특징

  • Mock 사용: Mock 객체로 외부 의존성을 대체하여 단위 테스트를 독립적으로 수행
    • 특정 객체나 메서드의 동작을 검증하기 위해, 의존성을 실제 구현 대신 Mock 객체로 대체하여 테스트
  • 행동 기반 테스트 (Behavior Verification): 객체가 올바른 메서드를 호출했는지, 호출된 횟수는 적절한지 등을 확인
  • 격리성: 테스트 대상 객체는 의존성과 완전히 격리
  • 단위 테스트에 집중: 특정 기능이나 메서드의 동작을 작게 쪼개어 검증

장점

  • 다른 모듈/시스템의 변경에 영향을 받지 않음
  • 특정 메서드 호출이나 내부 동작 검증에 적합

단점

  • Mock이 많아지면 테스트 코드가 복잡해지고, 유지보수가 어려워질 수 있음
  • 실제 시스템과의 상호작용을 놓칠 가능성 있음
  • Mock 객체에 의존하여 테스트가 비현실적

고전파

주요 특징

  • 상태 기반 테스트 (State Verification): 테스트 결과로 반환된 상태나 값을 검증.
  • 실제 구현 사용: 의존성에 Mock을 사용하는 대신, 가능한 실제 구현체나 시스템을 사용
  • 결과 중심: 행동(메서드 호출)이 아닌 결과 상태(출력, 반환값 등)를 검증
  • 통합 테스트 지향: 시스템 전체 흐름을 테스트하는 데 적합

장점

  • 실제 의존성을 사용하기 때문에 현실에 가까운 테스트를 수행가능
  • 시스템 전반의 통합 및 작동 여부를 확인하기에 적합

단점

  • 외부 시스템의 변경에 민감하며, 테스트의 안정성이 떨어질 수 있음
  • 특정 메서드 호출이나 내부 동작을 검증하기 어려움
  • 의존성 때문에 테스트 속도가 느려질 수 있음

 

런던파와 고전파의 비교

  런던파 고전파
핵심 특징 Mock을 사용하여 독립적이고 행동 중심의 테스트 수행 실제 의존성을 사용하여 상태 중심의 테스트 수행
검증 방식 메서드 호출, 행동 검증 상태와 출력검증
테스트 범위 작은 단위 (메서드 단위) 통합 또는 큰 단위
속도 빠름 ( 외부 시스템 미사용) 느림(외부 시스템 사용 시)
테스트 안정성 외부 변화에 둔감 외부 시스템 변경시 취약
유지 보수성 Mock 이 많아지면 복잡 간결하지만 외부 의존성 변경시 취약
적용 사례 단위 테스트, 서비스 내부 로직 테스트 통합 테스트, 시스템 흐흠 검증
적합한 경우 테스트 속도가 중요할 때
객체의 행동을 검증할 경우
외부 의존성이 복잡하거나 불안정한 경우
전체 시스템의 실제 동작과 통합을 검증할 경우
현실적인 테스트 환경을 유지하려는 경우
주요 관심사가 결과 상태와 출력 일때

 

현대적인 테스트 접근 방식에서는 두 방법론을 혼합하여 사용하는 경우가 많으며, 테스트의 목적과 환경에 따라 적절히 선택하는 것이 중요

'TDD > 개념' 카테고리의 다른 글

[ TDD ] [ Fixture ] Test Fixture?  (0) 2024.11.16
@Post('path')
@UseGuards(JwtAuthGuard)
@HttpCode(201)
postRoutine(@Body() saveRoutines: SaveRoutinesRequestDto, @Request() req: any) {
return this.routineService.bulkInsertRoutines(req.user, saveRoutines);
}

 

주어진 코드에서 데코레이션이 정확하게 작동하는지 테스트하는 방법은

Reflect.getMetadata(metadataKey, target) 를 사용하면 된다.

 

사용방법

 

@UseGaurds(JwtAuthGuard)

import {JwtAuthGuard} from '../common/jwtPassport/'
import {GUARDS_METADATA} from '@nestjs/common/constants';

const guards = Reflect.getMetadata(GUARDS_METADATA, controller.postRoutine);
expect(guards[0]).toBe(JwtAuthGuard);
  • __guards__   NestJS에서 @UseGuards() 데코레이터를 사용하면, 해당 메서드에 가드가 설정됩니다. 이 정보는 __guards__라는 메타데이터 키로 저장됩니다.
  • __guards__ 를 이용해서 postRoutine 에서 사용된 @UseGaurds(JwtAuthGuard) 의 동작여부 확인

@HttpCode(201)

import {HTTP_CODE_METADATA} from '@nestjs/common/constants';

const httpCode = Reflect.getMetadata(HTTP_CODE_METADATA, controller.postRoutine);
expect(httpCode).toBe(201);
  • __httpCode__ : NestJS에서 @HttpCode() 데코레이터를 사용하면, 해당 메서드의 http 상태 코드가 설정됩니다.이 정보는 __httpCode__라는 메타데이터키로 저장됩니다.
  • __httpCode__ 를 이용해서 postRoutine 에서 사용된 @HttpCode(201) 의 동작여부를 확인

const path = Reflect.getMetadata(ROUTE_METADATA, controller.getAllRoutineByUser); const method = Reflect.getMetadata(METHOD_METADATA, controller.getAllRoutineByUser);

 

@Post('path')

import { PATH_METADATA, METHOD_METADATA } from '@nestjs/common/constants';
import { RequestMethod } from '@nestjs/common';


const path = Reflect.getMetadata(PATH_METADATA, controller.postRoutine);
const method = Reflect.getMetadata(METHOD_METADATA, controller.postRoutine);

expect(method).toBe(RequestMethod.POST);
expect(path).toBe('path');
  • METHOD_METADATA =  NestJS에서 HTTP 메서드를 나타내는 상수로, RequestMethod 열거형(enum) 값을 반환
export enum RequestMethod {
  GET = 0,
  POST = 1,
  PUT = 2,
  DELETE = 3,
  PATCH = 4,
  ALL = 5,
  OPTIONS = 6,
  HEAD = 7,
}

 

mockResolvedValue

  • 값을 자동으로 Promise.resolve()로 감쌉니다
  • 내부적으로 Promise.resolve(mockResult)로 처리

 

mockReturnValue

  • 값을 그대로 반환
  • 호출자가 Promise를 기대할 경우 직접 Promise.resolve()로 감싸야 한다.

+ Recent posts