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));
    });
  });
});
@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()로 감싸야 한다.

목차

toBe

방식 : 엄격한 동일성 검사(strict equality)

  • Object.is를 사용하여 검사
    • +0-0을 구분
    • NaN === NaN 은 false 지만 Object.is(NaN, NaN) 은 true

용도

  • 원시 값(primitive value) 비교 - 숫자, 문자열, null, undefined 등
  • 동일한 객체 참조 확인 - 객체나 배열이 같은 메모리 주소를 가리키는지 확인
  • 배열의 요소들이 순서와 내용까지 동일한지 확인

특징 : 값이 같더라도 서로 다른 객체나 배열인 경우 실패

 

예시

 expect(10).toBe(10); // 통과
 expect('hello').toBe('hello'); // 통과

 const obj1 = { a: 1 };
 const obj2 = { a: 1 };
 expect(obj1).toBe(obj2); // 실패 (서로 다른 참조)
 
const obj3 = obj1;
expect(obj3).toBe(obj1) // 통과 (서로 같은 참조)

expect([1,2,3].toBe([1,3,2]) // 실패
expect([1,2,3].toBe([1,2,3]) // 성공

 

toEqual

방식: 깊은 비교 (Deep Equality)

  • 객체나 배열의 구조와 내용을 비교
  • 부모 클래스에서 상속된 속성은 무시

용도

  • 객체나 배열의 구조와 내용을 비교할 때
  • 부모 클래스에서 상속된 속성은 무시해도 괜찮을 때.

특징

  • 참조를 무시하고 값만 비교
  • 객체 내부에 중첩된 값도 비교

 

const obj1 = { a: 1 };
const obj2 = { a: 1 };
expect(obj1).toEqual(obj2); // 통과 (내용이 같음)

const array1 = [1, 2, 3];
const array2 = [1, 2, 3];
expect(array1).toEqual(array2); // 통과 (배열의 내용이 같음)

const obj1 = { a: 1, b: { c: 2 } };
const obj2 = { a: 1, b: { c: 2 } };
expect(obj1).toEqual(obj2); // 통과 (내용이 동일)

toStrictEqual

방식 : 객체의 구조(프로퍼티와 값) 그리고 타입(클래스 여부) 가 동일한지 비교 검사, 객체가 직접 소유한 프로퍼티만 비교

용도 

  • 더 엄격한 객체 검증이 필요할 때
    • 객체의 키와 값이 같고, 구조가 완전히 동일한지 확인
    • 명시되지 않은 속성(undefined)까지 검사해야 할때
    • 단순히 {} 생성된 객체인지 클래스 인스턴스인지 비교가 필요한 경우
    • 배열의 요소들이 순서와 내용까지 동일한지 확인

특징

  • 객체의 추가적인 프로퍼티나 순서의 차이도 확인
  • 상속받은 프로퍼티도 비교 대상에서 제외
  • 상속받은 프로퍼티를 포함하려면 객체를 복사하거나 평탄화(flatten)필요

 

예시

const obj1 = { a: 1, b: 2 };
const obj2 = { a: 1, b: 2 };
expect(obj1).toStrictEqual(obj2); // 통과 (내용 동일)

const obj3 = { a: 1, b: 2, c: 3 };
expect(obj1).toStrictEqual(obj3); // 실패 (추가된 프로퍼티 c)

const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
const arr3 = [1, 3, 2];
expect(arr1).toStrictEqual(arr2); // 통과 (배열 내용 동일)
expect(arr1).toStrictEqual(arr3) // 실패


/* 상속 프로퍼티 */
// 부모 클래스 정의
class Parent {
  constructor() {
    this.parentProp = 'parent';
  }
}

// 자식 클래스 정의
class Child extends Parent {
  constructor() {
    super();
    this.childProp = 'child';
  }
}

const obj1 = new Child();
const obj2 = { childProp: 'child', parentProp: 'parent' };

// `toStrictEqual`은 obj1의 상속 프로퍼티(parentProp)를 비교하지 않음
expect(obj1).toStrictEqual(obj2); // 실패

// obj1의 모든 프로퍼티를 명시적으로 포함한 객체와 비교
const obj1Flattened = Object.assign({}, obj1);
expect(obj1Flattened).toStrictEqual(obj2); // 통과

 

* 평탄화 추가 설명

원래 obj1 객체 (상속 포함):

Child {
  childProp: 'child',
  __proto__: Parent {
    parentProp: 'parent',
    __proto__: Object
  }
}

 

Object.assign({}, obj1)로 평탄화한 객체

{
  childProp: 'child',
  parentProp: 'parent'
}

 

 

차이점 요약

  toBe toEqual toStrictEqual
비교 방식 Object.is를 사용하여 참조와 값을 비교 구조와 내용의 깊은 비교
- toEqual의 기능 + 더 엄격한 검증
- 객체의 타입(인스턴스 여부)까지 검증
- 스파스 요소까지 포함
원시 값 비교 값이 같으면 true 값이 같으면 true 값이 같으면 true
객체 비교 참조가 같아야 true 객체의 내용이 같으면 true 객체의 내용 + 타입이 같아야 true
배열 비교 참조가 같아야 true 배열의 내용과 순서가 같으면 true 배열의 내용, 순서, 스파스 요소가 같아야 true
클래스 인스턴스 비교

동일 참조일 때만 true 구조가 같으면 true 구조와 클래스 타입이 같아야 true

스파스 요소 비교하지 않음 스파스 요소와 undefined를 동일하게 간주 스파스 요소와 undefined를 다르게 간주
사용 사례 - 원시 값 비교
- 객체 참조 확인
- 동일한 메모리 위치인지 확인
- 객체의 구조나 내용을 검증할 때
- 배열, 객체의 내용 확인
- 객체의 구조와 타입까지 검증해야 할 때
- 배열의 스파스 요소 확인

+ Recent posts