TOC
참고
본 내용은 단위 테스트의 기술을 읽고 정리한 내용입니다.
책의 내용과 함께 개인적인 의견과 생각, 학습을 담아 작성하였습니다.
격리 프레임워크
격리 프레임워크의 정의
- 런타임에 가짜 객체를 생성하고 설정할 수 있는 재사용 가능한 라이브러리
- 객체나 함수 형태의 목이나 스텁을 동적으로 생성, 구성, 검증할 수 있게 해주는 프로그래밍 가능한 API
격리 프레임워크의 효용
- 작업 단위를 의존성에서 격리시킬 수 있다.
- 반복적으로 코드를 작성하는 일을 줄여준다.
- 테스트 지속성을 높여준다.
격리 프레임워크 주의사항
- 프레임워크를 남용할 수 있게 된다.
- 테스트를 읽거나 신뢰할 수 없는 상황이 될 수 있다.
격리 프레임워크의 유형
느슨함의 정도로 구분
- 느슨한 타입
- 일반적으로 적은 설정과 보일러 플레이트
- 함수형 스타일 코드에 적합
- 제스트와 사이넌 등
- 정적 타입
- 더 객체 지향적
- 타입스크립트 친화적
- 전체 클래스와 인터페이스를 다룰 때 유용
- substitute.js 등
어떤 의존성을 가짜로 만들어야 하는지가 관건
- 모듈 의존성
- import, require
- 느슨한 타입(ex. jest)
- 함수형 의존성
- 단일 함수와 고차 함수, 간단한 매개변수와 값
- 느슨한 타입(ex. jest)
- 객체 전체, 객체 계층 구조, 인터페이스
- 객체지향적 프레임워크(substitute.js)
동적으로 가짜 모듈 만들기
jest.mock
- 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 API 주의점
- 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는 모듈과 함수 의존성의 반환 값을 조작하는 기능을 제공한다.
mockReturnValue
- 역할: 테스트 사이클동안 동일한 값을 반환
- mockFn.mockReturnValue(value)
test("...", () => {
const stubFunc = jest.fn().mockReturnValue("abc");
expect(stubFunc()).toBe("abc");
expect(stubFunc()).toBe("abc");
expect(stubFunc()).toBe("abc");
});
mockReturnValueOnce
- 역할: 함수 실행 후 최초 한 번만 정해진 값 반환
- mockFn.mockReturnValueOnce(value)
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
});
mockImplementation
- 역할: 모의 함수의 구현을 재정의
- mockFn.mockImplementation(fn)
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
mockImplementationOnce
- 역할: 모의 함수의 구현을 최초 한 번만 재정의
- mockFn.mockImplementationOnce(fn)
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 메서드 학습?
격리 프레임워크... 보다는 그냥 jest의 fn()
이라든지, matcher 등을 더 알아볼 수 있는 기회로 보였어요. 아직도 사실 격리 프레임워크라는 용어가 좀 와닿지만은 않는 것 같습니다.
테스트의 함정에 집중하라
격리 프레임워크의 함정이라고 소개된 부분에서, 제 생각엔 모든 테스트의 기저를 관통하는 가장 중요한 개념이 등장했습니다. "단지 가능하기 때문에 검증하지 말고, 실제로 의미 있는 동작을 검증하라."
이게 진짜 중요한 것 같아요. 현업에서 스토리북 인터렉션 테스트를 작성하는데, 실제로 테스트 작성에 심취해서 불필요한 것들을 검증하는 경우가 더러 있었어요. 제가 딱 그런 테스트 초보자들이 하는 실수를 저지르고 있지 않았나 생각해봤습니다.
가독성이 좋고, 꼭 필요한 것들을 검증하는 테스트를 짜는 것에 집중해야 된다는 사실을 다시 되새겼습니다.