이번에는 일관된 테스트를 진행하기 위해 의존성을 해소하는 방법 중 “스텁”을 사용하는 방법을 살펴보려고 합니다. 그전에 먼저 의존성의 유형을 먼저 알아보고, 이후 작업 단위에 스텁을 주입하는 방법을 살펴보겠습니다.
의존성 유형
작업 단위에서 사용할 수 있는 의존성은 내부로 들어오는지, 외부로 나가는지에 따라 스텁(stub)과 목(mock)으로 나뉩니다. 그리고 이런 가짜 의존성을 포괄하여 테스트 더블 이라고 합니다.
스텁: 내부로 들어오는 의존성
스텁은 종료점을 나타내지 않는 의존성으로, 테스트에 필요한 특수한 데이터나 동작을 작업 단위에 제공하는 역할을 합니다. 예를 들면, 특정 라이브러리를 활용한 데이터나 데이터베이스의 쿼리 결과, 네트워크 응답 결과 등이 있으며 이전 작업의 결과들이라고 볼 수 있습니다. 스텁은 가짜 모듈이나 객체, 함수 등으로 내부로 들어오는 의존성을 끊어주기 때문에 외부 시스템에 의존하지 않고 테스트를 할 수 있습니다. 더불어, 스텁은 검증하지 않기 때문에 하나의 테스트에서 여러 스텁을 사용할 수 있습니다.
목: 외부로 나가는 의존성
목은 작업 단위의 종료점을 나타내는 의존성으로, 일종의 비동기 작업처럼 작업의 결과를 기다리지 않고 바로 다음 작업을 수행하는 방식과 유사합니다. 예를 들면, 데이터베이스에 저장하거나 이메일 발송, 로거 함수 호출 등이 제대로 호출되었는지, 어떤 인수로 호출되었는지를 검증하는 경우가 있습니다. 목은 단위 테스트에서 종료점을 나타내기 때문에 하나의 테스트에서 하나만 사용하는 것일 일반적입니다.
작업 단위에서 스텁 사용하기
스텁을 사용하는 이유
테스트는 언제 실행하든 이전 실행과 같은 결과를 보장해야 합니다. 의존성을 사용하여 특정 조건에만 테스트가 가능해지는 것과 같이 테스트를 구성하는 여러 변수나 환경을 통제하지 못한다면 불안정한 테스트가 됩니다. E2E 테스트 중 발생하는 네트워크 문제, 데이터베이스 연결 문제, 다양한 서버 문제 등을 통제하여 안정적이고 일관된 테스트를 구현하기 위해 스텁을 활용한 의존성 주입이 필요합니다.
스텁으로 의존성을 주입하는 방법
매개변수로 주입하는 간단한 케이스를 비롯하여 스텁을 활용하는 다양한 방법이 있습니다. 예제를 통해 하나씩 살펴보도록 하겠습니다.
- 함수를 사용한 방식
- 함수를 매개변수로 사용
- 부분 적용(커링)
- 팩토리 함수
- 생성자 함수
- 모듈을 이용한 방식
- 모듈 주입
- 객체 지향을 이용한 방식
- 클래스 생성자 주입
- 객체를 매개변수로 사용(덕 타이핑)
- 공통 인터페이스를 매개변수로 사용(타입스크립트 활용)
실제 시간에 대한 의존성 처리하기
moment 라는 시간 관련 라이브러리를 사용했습니다. 이 경우 해당 함수는 주말과 주말이 아닌 경우에 대한 분기처리가 실제 요일에 따라 동작하게 됩니다.
const moment = require("moment");
const SUNDAY = 0;
const SATURDAY = 6;
const verifyWeekday1 = (input) => {
const dayOfWeek = moment().day();
if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
throw Error("It's the weekend!");
} else {
return "It's the weekday";
}
};
그래서 아래와 같이 테스트 코드를 작성해도 두 테스트 모두 실제 요일이 무엇이냐에 따라 검증 로직이 실행되지 않을 수도 있습니다.
describe("Weekday verifier", () => {
const today = moment().day();
test("on the weekday, works fine", () => {
const isWeekend = [SATURDAY, SUNDAY].includes(today);
if (!isWeekend) {
expect(verifyWeekday1("anything")).toMatch("weekday");
}
});
test("on the weekend, throws an error", () => {
const isWeekend = [SATURDAY, SUNDAY].includes(today);
if (isWeekend) {
expect(() => verifyWeekday1("anything")).toThrow(
"It's the weekend"
);
}
});
});
1. 매개변수로 주입하기
가장 간단한 스텁 주입으로, 시간 매개변수를 따로 받는 것입니다. 시간 값의 통제권을 함수 호출자에게 넘겨주며 의존성 역전(dependency inversion)을 구현했습니다.
const verifyWeekday2 = (input, currentDay) => {
if ([SATURDAY, SUNDAY].includes(currentDay)) {
throw Error("It's the weekend!");
} else {
return "It's the weekday";
}
};
테스트에서는 일요일을 나타내는 인수를 넣어주어 주말이란 조건을 만족시켰습니다.
describe("Weekday verifier - dummy object", () => {
test("on the weekend, throws an error", () => {
expect(() => verifyWeekday2("anything", SUNDAY)).toThrow(
"It's the weekend"
);
});
});
2. 함수 - 함수를 매개변수로 주입하기
앞서 봤던 시간 값을 매개변수로 넣는 경우와 유사하게 데이터를 반환하는 함수를 매개변수로 넣어서 구현할 수도 있습니다.
함수를 사용할 경우, 특정 상황에서 예외를 만들어 내거나 테스트 내에서 특정한 동작을 하도록 만들 수도 있기 때문에 값을 넣는 경우보다 유용합니다.
const verifyWeekday3 = (input, getDayFn) => {
const dayOfWeek = getDayFn();
if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
throw Error("It's the weekend!");
} else {
return "It's the weekday";
}
};
테스트에서는 일요일과 월요일을 리턴하는 함수를 넣어서 주말과 주중 조건을 구현했습니다.
describe("Weekday verifier - dummy function", () => {
test("on the weekend, throws an error", () => {
const alwaysSunday = () => SUNDAY;
expect(() => verifyWeekday3("anything", alwaysSunday)).toThrow(
"It's the weekend"
);
});
test("on the weekend, throws an error", () => {
const alwaysMonday = () => MONDAY;
expect(verifyWeekday3("anything", alwaysMonday)).toMatch("weekday");
});
});
3. 팩토리 함수 사용하기(feat. 커링)
위에서 정의했던 주말 여부를 판별하는 함수를 반환하는 고차함수인 팩토리 함수를 커링을 활용하여 구현하고 이를 테스트하면 아래와 같습니다.
- 고차함수: 함수를 인자로 받거나, 함수를 반환하는 함수
- 커링(Currying): 인자를 한번에 받지 않고 나눠서 받도록 함수의 체인으로 구성하는 기법
const makeVerifier = (dayOfWeek) => {
return function (input) {
if ([SATURDAY, SUNDAY].includes(dayOfWeek())) {
throw Error("It's the weekend!");
} else {
return "It's the weekday";
}
};
};
// 테스트 코드
describe("vefifier", () => {
test("factory method: on weekends, throws an error", () => {
const alwaysSunday = () => SUNDAY;
const vefifyWeekday = makeVerifier(alwaysSunday);
expect(() => vefifyWeekday("anything")).toThrow("weekend");
});
});
5. 생성자 함수 사용하기
new 키워드를 통해 인스턴스를 생성하면, this는 새로 생성된 객체를 가리킵니다. 그래서 new 키워드 사용할 수 있도록 화살표 함수가 아니라, 함수 선언식 또는 함수 표현식으로 함수를 정의해야 해줍니다.
const Verifier = function (dayOfWeekFn) {
this.verify = (input) => {
if ([SATURDAY, SUNDAY].includes(dayOfWeekFn())) {
throw new Error("It's the weekend!");
} else {
return "It's the weekday";
}
};
};
// 테스트 코드
describe("constructor function", () => {
test("on weekends, throws an error", () => {
const alwaysSunday = () => SUNDAY;
const verifier = new Verifier(alwaysSunday);
expect(() => verifier.verify("anything")).toThrow("weekend");
});
});
6. 모듈 이용 - 모듈 주입하기
라이브러리와 사용하는 함수 자체를 스텁으로 대체하여 사용하는 방법입니다. 이는 함수 실행 전에 가짜 의존성으로 대체하는 것이므로, 테스트 종료 후에는 기존 의존성으로 초기화를 해야 합니다.
dependencies 변수를 통해 의존성을 관리할 수 있도록 세팅했습니다. inject 함수는 가짜 의존성을 부여합니다. 리턴값으로 기존 의존성으로 돌리는 함수를 반환하기 때문에 테스트를 마친 후(가짜 의존성을 사용한 후), 반환된 함수를 실행해줘야 합니다.
const originalDependencies = {
moment: require("moment"),
};
let dependencies = { ...originalDependencies };
const inject = (fakes) => {
Object.assign(dependencies, fakes);
return function reset() {
dependencies = { ...originalDependencies };
};
};
const SUNDAY = 0;
const SATURDAY = 6;
const verifyWeekday = (input) => {
const dayOfWeek = dependencies.moment().day();
if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
throw Error("It's the weekend!");
} else {
return "It's the weekday";
}
};
inject에 대체할 함수를 넣어보겠습니다. 위의 코드를 보면 moment().day() 형태로 사용되고 있기 때문에 moment, day 모두 함수 형태로 적어줘야 함을 알 수 있습니다.
그래서 moment 키값에 값으로 함수를 갖는 객체를 만들고, 이후 넣은 날짜를 그대로 반환해야 하기 때문에 day 메서드는 input 값을 그대로 반환하도록 설정합니다.
const injectData = (newDay) => {
const reset = inject({
moment: () => {
return {
day: () => newDay,
};
},
});
return reset;
};
describe("Weekday verifier", () => {
describe("on the weekend", () => {
it("throws an error", () => {
const reset = injectData(SAUTRDAY);
expect(() => verifyWeekday("anything")).toThrow("weekend");
reset();
});
});
});
7. 객체 지향 - 클래스 생성자 주입
5.생성자 함수에서 살펴봤던 방법은 클래스를 활용해 구현할 수도 있습니다. 생성자 주입은 클래스 생성자를 이용하여 의존성을 주입하는 설계를 의미합니다.
생성자 주입으로 요일을 매개변수로 전달해줍니다. 이후 테스트를 하며 new 키워드로 인스턴스를 생성할 때 타겟인 요일을 넣어줍니다.
class WeekDayVerifier {
constructor(dayOfWeekFn) {
this.dayOfWeekFn = dayOfWeekFn;
}
verify(input) {
if ([SATURDAY, SUNDAY].includes(this.dayOfWeekFn())) {
throw new Error("It's the weekend!");
} else {
return "It's the weekday";
}
}
}
// 테스트 코드
describe("class constructor", () => {
test("on weekends, throws an error", () => {
const alwaysSunday = () => SUNDAY;
const verifier = new WeekDayVerifier(alwaysSunday);
expect(() => verifier.verify("anything")).toThrow("weekend");
});
});
클래스를 만드는 과정을 팩토리 함수로 분리하여 유지 보수성을 높일 수도 있습니다. 이렇게 하면 생성자 함수의 로직이 변경되더라도 팩토리 함수의 수정만으로 테스트를 복구할 수 있습니다.
describe("refactored with class constructor", () => {
const makeVerifier = (dayFn) => new WeekDayVerifier(dayFn);
test("on weekends, throws an error", () => {
const alwaysSunday = () => SUNDAY;
const verifier = makeVerifier(alwaysSunday);
expect(() => verifier.verify("anything")).toThrow("weekend");
});
});
8. 객체 지향 - 객체를 매개변수로 사용(덕 타이핑)
동일한 함수 시그니처를 넣으면 대체가 가능한 자바스크립트의 특징과 캡슐화를 좀 더 활용하여 코드를 구성할 수도 있습니다.
요일을 제공하는 함수(RealTimeProvider)를 구성하고, 테스트에서는 동일한 함수 시그니처인 FakeTimeProvider를 사용할 수 있습니다. 팩토리에서는 이것을 받아서 검증 함수를 만들도록 구성했습니다.
// 요일 제공 함수 분리
const RealTimeProvider = function () {
this.getDay = () => moment().day();
};
// 검증 함수 팩토리
const weekdayVerifierFactory = (timeProvider) => {
return new WeekDayVerifier(timeProvider);
};
// 클래스
class WeekDayVerifier {
constructor(timeProvider) {
this.timeProvider = timeProvider;
}
verify(input) {
if ([SATURDAY, SUNDAY].includes(this.timeProvider.getDay())) {
throw new Error("It's the weekend!");
} else {
return "It's the weekday";
}
}
}
// 테스트 코드
function FakeTimeProvider(fakeDay) {
this.getDay = () => fakeDay;
}
describe("class constructor", () => {
test("on weekends, throws an error", () => {
const verifier = weekdayVerifierFactory(new FakeTimeProvider(6));
expect(() => verifier.verify("anything")).toThrow("weekend");
});
});
9. 객체 지향 - 공통 인터페이스를 매개변수로 사용(타입스크립트 활용)
앞선 방법들에서 TypeScript와 같은 정적 언어를 사용하면, 타입을 명시하고 의존성의 역할을 명확히 정의하여 컴파일러 수준에서 타입을 검증할 수 있습니다. 바로 앞에서 봤던 8번 코드를 타입스크립트로 구현하면 다음과 같습니다.
// 요일 제공 인터페이스
export interface TimeProviderInterface {
getDay(): number;
}
// 실제 시간 제공
export class RealTimeProvider implements TimeProviderInterface {
getDay(): number {
return moment().day();
}
}
// 요일 검증 팩토리
export const weekdayVerifierFactory = (timeProvider: TimeProviderInterface) => {
return new WeekDayVerifier(timeProvider);
};
// 요일 검증 클래스
export class WeekDayVerifier {
constructor(private timeProvider: TimeProviderInterface) {}
verify(input: string): string{
if ([SATURDAY, SUNDAY].includes(this.timeProvider.getDay())) {
throw new Error("It's the weekend!");
} else {
return "It's the weekday";
}
}
}
// 테스트 코드
class FakeTimeProvider implements TimeProviderInterface {
constructor(private fakeDay: number){}
getDay(): number {
return this.fakeDay;
}
}
describe('verifier with interfaces', () => {
test('on weekends, throws an error', () => {
const stubTimeProvider = new FakeTimeProvider(SUNDAY);
const verifier = weekdayVerifierFactory(stubTimeProvider)
expect(() => verifier.verify('anything')).toThrow("weekend");
});
});
참고자료
- 단위 테스트의 기술, 길벗
- 도서 코드 예제 깃헙
'Dev > 테스트 코드' 카테고리의 다른 글
비동기 코드 단위 테스트 (0) | 2025.03.16 |
---|---|
격리 프레임워크 (0) | 2025.03.07 |
모의 객체를 사용한 상호 작용 테스트 (0) | 2025.03.04 |
JEST 활용 단위 테스트 작성 (0) | 2025.02.25 |
단위 테스트 기초 (0) | 2025.02.23 |