본 내용은 단위 테스트의 기술을 읽고 정리한 내용입니다.
책의 내용과 함께 개인적인 의견과 생각, 학습을 담아 작성하였습니다.

  • 런타임에 가짜 객체를 생성하고 설정할 수 있는 재사용 가능한 라이브러리
  • 객체나 함수 형태의 목이나 스텁을 동적으로 생성, 구성, 검증할 수 있게 해주는 프로그래밍 가능한 API

  • 작업 단위를 의존성에서 격리시킬 수 있다.
  • 반복적으로 코드를 작성하는 일을 줄여준다.
  • 테스트 지속성을 높여준다.

  • 프레임워크를 남용할 수 있게 된다.
  • 테스트를 읽거나 신뢰할 수 없는 상황이 될 수 있다.

느슨함의 정도로 구분
  • 느슨한 타입
    • 일반적으로 적은 설정과 보일러 플레이트
    • 함수형 스타일 코드에 적합
    • 제스트와 사이넌 등
  • 정적 타입
    • 더 객체 지향적
    • 타입스크립트 친화적
    • 전체 클래스와 인터페이스를 다룰 때 유용
    • substitute.js 등
어떤 의존성을 가짜로 만들어야 하는지가 관건
  • 모듈 의존성
    • import, require
    • 느슨한 타입(ex. jest)
  • 함수형 의존성
    • 단일 함수와 고차 함수, 간단한 매개변수와 값
    • 느슨한 타입(ex. jest)
  • 객체 전체, 객체 계층 구조, 인터페이스
    • 객체지향적 프레임워크(substitute.js)

  • jest에서 mock을 만들고 검증할 수 있는 방법
  • 테스트 파일의 가장 위쪽에 jest.mock([모듈 이름])으로 mock을 만들 대상 지정
  • 테스트에서 가짜 모듈을 불러와(require) 원하는 방식으로 사용
jest.mock("./logger");
jest.mock("./configuration-service");
 
const { stringMatching } = expect;
const { verifyPassword } = require("./password-verifier");
const mockLoggerModule = require("./logger");
const stubConfigModule = require("./configuration-service");
 
describe("테스트 케이스", () => {
  afterEach(jest.resetAllMocks);
 
  test("테스트 내용", () => {
    // 가짜 모듈의 getLogLevel 함수의 반환값이 'info'를 반환하도록 설정
    stubConfigModule.getLogLevel.mockReturnValue("info");
 
    verifyPassword("anything", []);
 
    // 가짜 모듈의 모의 함수 호출 여부 검증
    expect(mockLoggerModule.info).toHaveBeenCalledWith(stringMatching(/PASS/));
  });
 
  test("테스트 내용", () => {
    // 가짜 모듈의 getLogLevel 함수의 반환값이 'info'를 반환하도록 설정
    stubConfigModule.getLogLevel.mockReturnValue("debug");
 
    verifyPassword("anything", []);
 
    // 가짜 모듈의 모의 함수 호출 여부 검증
    expect(mockLoggerModule.debug).toHaveBeenCalledWith(stringMatching(/PASS/));
  });
});

  • jest는 모두 mock이라는 단어를 사용한다.
    • stub이라는 단어를 mock과 동일한 의미로 사용하면 좋다.
  • jest.mock을 최상단에 두는 이유
    • JS 호이스팅 때문

  • jest.mock API의 장점
    • 내장된 의존성 때문에 쉽게 변경할 수 없는 모듈을 테스트하려는 요구사항을 충족할 수 있다.
  • jest.mock API의 단점
    • 제어권이 있는 코드까지 모두 가짜로 만들어 버린다.
  • 직접 의존성이 문제가 되는 이유
    • 추상화된 API가 아닌, 모듈 API를 테스트에서 직접 가짜로 만들어야 한다.
    • 이렇게 되면 모듈의 원래 API 설계가 테스트 구현에 강결합된다.
    • API가 변경될 때마다 수많은 테스트를 함께 변경해야 한다.
    • 포트와 어댑터 아키텍처를 통해 유지보수성을 유지할 수 있다.

// 수동 접근 방식
test("테스트 내용", () => {
  let logged = "";
  const mockLog = { info: (text) => (logged = text) };
  const passVerify = makeVerifier([], mockLog);
 
  passVerify("any input");
 
  expect(logged).toMatch(/PASS/);
});
 
// jest.fn 사용
test("테스트 내용", () => {
  const mockLog = { info: jest.fn()};
  const passVerify = makeVerifier([], mockLog);
 
  passVerify("any input");
 
  expect(mockLog.info).toHaveBeenCalledWith(expect.stringMatching(/PASS/));
});
  • jest.fn()을 사용하여 모의 함수 제작
    • 단일 함수 기반의 mock과 stub에 잘 맞는다.
  • toHaveBeenCalledWith 메서드를 사용하여 모의 함수가 호출되었는지 검증
  • expect.stringMatching은 jest의 Matcher
    • Matcher: 함수에 전달되는 매개변수 값을 검증하는 유틸리티 함수
    • 전체 Matcher는 expect 공식 문서 참고

아래와 같은 복잡한 인터페이스를 살펴보자.

export interface IComplecatedLogger {
  info(text: string, method: string): void;
  debug(text: string, method: string): void;
  warn(text: string, method: string): void;
  error(text: string, method: string): void;
}

이 인터페이스의 모의 객체를 만드려면 이렇게 복잡해진다.

describe("...", () => {
  class FakeLogger implements IComplecatedLogger {
    debugText = "";
    debugMethod = "";
    infoText = "";
    infoMethod = "";
    ...
 
    debug(text: string, method: string) {
      this.debugText = text;
      this.debugMethod = method;
    }
 
    info(text: string, method: string) {
      this.infoText = text;
      this.infoMethod = method;
    }
 
    ...
  }
 
  test("...", () => {
    const mockLogger = new FakeLogger();
    const verifier = new PasswordVerifier([], mockLogger);
 
    verifier.verify("any input");
 
    expect(mockLogger.infoText).toMatch(/PASS/);
  })
})

이를 jest.fn을 사용하면 이렇게 개선할 수 있다.

describe("...", () => {
  test("...", () => {
    const mockLogger: IComplecatedLogger = {
      info: jest.fn(),
      debug: jest.fn(),
      warn: jest.fn(),
      error: jest.fn(),
    };
 
    const verifier = new PasswordVerifier([], mockLogger);
 
    verifier.verify("any input");
 
    expect(mockLogger.info).toHaveBeenCalledWith(expect.stringMatching(/PASS/));
  })
})
  • 단순히 객체를 정의하고 인터페이스의 각 함수에 jest.fn()으로 만든 모의 함수를 할당
  • 주의사항 : 인터페이스가 변경되는 경우 모의 객체 정의 코드를 수정해주어야 한다.
  • 저의 의견 : 이건 위의 상황도 같지 않나요?
  • 해결방안 : 모의 객체를 생성하는 팩토리 함수를 만들어 한 곳에서 처리

Jest는 모듈과 함수 의존성의 반환 값을 조작하는 기능을 제공한다.

test("...", () => {
  const stubFunc = jest.fn().mockReturnValue("abc");
  expect(stubFunc()).toBe("abc");
  expect(stubFunc()).toBe("abc");
  expect(stubFunc()).toBe("abc");
});

test("...", () => {
  const stubFunc = jest.fn()
          .mockReturnValueOnce("a")
          .mockReturnValueOnce("b")
          .mockReturnValueOnce("c");
 
  expect(stubFunc()).toBe("a");
  expect(stubFunc()).toBe("b");
  expect(stubFunc()).toBe("c");
  expect(stubFunc()).toBe(undefined); // 더 이상 반환할 값이 없으면 undefined
});

const mockFn = jest.fn((scalar: number) => 42 + scalar);
 
mockFn(0); // 42
mockFn(1); // 43
 
mockFn.mockImplementation(scalar => 36 + scalar);
 
mockFn(2); // 38
mockFn(3); // 39

test("...", () => {
  const mockFn = jest
    .fn(() => 'default')
    .mockImplementationOnce(() => 'first call')
    .mockImplementationOnce(() => 'second call');
 
  mockFn(); // 'first call'
  mockFn(); // 'second call'
  mockFn(); // 'default'
  mockFn(); // 'default'
});

  • 더 쉬운 가짜 모듈 생성
  • 더 쉬운 값/오류 생성
  • 더 쉬운 가짜 객체 생성

  • 대부분의 경우 모의 객체가 불필요하다.
    • 모의 객체는 대부분의 단위 테스트에서 기본적으로 사용하면 안 된다.
    • 반환값을 검증하거나, 작업 단위의 동작 변화를 외부에서 확인하라.
    • 이 밖에도 더 쉬운 방법으로 우회할 수 있는지 확인하라.
  • 읽고 이해하기 어려운 테스트 코드
    • 검증 단계를 너무 많이 추가하지 말라.
    • 테스트를 더 작은 하위 테스트로 쪼개라.
  • 잘못된 대상 검증
    • 할 수 있기 때문이 아니라, 필요하고 중요한 부분을 검증하라.
  • 테스트당 하나 이상 목을 사용
    • 하나의 테스트는 하나의 관심사를 검증하라.
    • 각 종료점마다 별도의 테스트를 작성하라.
    • 테스트 이름을 일반적으로 짓지 말고 기능적으로 명확히 지어라.
    • 만약 그럴 수 없다면 테스트를 분리하라.
  • 테스트의 과도한 명세화
    • 테스트에 검증 항목이 너무 많으면 깨지기 쉽다.
    • mock 대신 stub을 사용하라.
    • 가능한 stub을 mock으로 사용하지 말라.

격리 프레임워크... 보다는 그냥 jest의 fn()이라든지, matcher 등을 더 알아볼 수 있는 기회로 보였어요. 아직도 사실 격리 프레임워크라는 용어가 좀 와닿지만은 않는 것 같습니다.

격리 프레임워크의 함정이라고 소개된 부분에서, 제 생각엔 모든 테스트의 기저를 관통하는 가장 중요한 개념이 등장했습니다. "단지 가능하기 때문에 검증하지 말고, 실제로 의미 있는 동작을 검증하라."

이게 진짜 중요한 것 같아요. 현업에서 스토리북 인터렉션 테스트를 작성하는데, 실제로 테스트 작성에 심취해서 불필요한 것들을 검증하는 경우가 더러 있었어요. 제가 딱 그런 테스트 초보자들이 하는 실수를 저지르고 있지 않았나 생각해봤습니다.

가독성이 좋고, 꼭 필요한 것들을 검증하는 테스트를 짜는 것에 집중해야 된다는 사실을 다시 되새겼습니다.