상호작용 테스트와 목
상호 작용 테스트는 작업 단위가 외부 의존성과 어떻게 상호작용 하는지를 확인하는 방법입니다. 서드 파트 모듈 및 객체와 관련이 있으며, 어떤 매개변수로 호출이 되었는지, 어떤 호출이 실행되었는지를 검증합니다.
상호 작용 테스트를 위해서는 외부로 나가는 의존성과 연결 고리를 끊어야 하며, 이때 목(mock)을 사용하여 의존성을 대체합니다. 목은 하나의 요구사항을 의미하기 때문에 하나의 테스트에서는 하나의 목만 사용하여, 한 가지의 요구사항만 검증합니다. 스텁과 마찬가지로 목을 작업 단위에 주입하는 방식은 아래와 같으며, 하나씩 차근차근 살펴보도록 하겠습니다.
- 표준 방식 - 매개변수를 추가하여 주입
- 함수형 방식 - 부분 적용 또는 팩토리 함수를 사용하여 주입
- 모듈 방식 - 모듈 의존성을 추상화하여 주입
- 객체 지향 방식 - 타입스크립트 같은 언어로 타입이 지정된 인터페이스를 사용하여 주입
로거함수 의존성
아래처럼 전달 받은 인수를 콘솔로 출력하는 로거함수가 있을 때, 테스트를 통해 해당 함수의 호출 여부를 확인하는 것은 쉽지 않습니다.
샘플 코드는 해당 도서에서 계속 사용 중인 비밀번호 검증 함수(verifyPassword)로 진행하겠습니다.
// 로거 모듈
const info = (text) => {
console.log(`INFO: ${text}`);
};
// 함수 코드
const verifyPassword = (input, rules) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result === false);
console.log(failed);
if (failed.length === 0) {
log.info('PASSED');
return true;
}
log.info('FAIL');
return false;
};
매개변수 주입하기
의존성을 함수의 매개변수로 넣어주면 해당 의존성을 테스트 코드에서 설정하여 호출 여부를 검증할 수 있습니다. 로거 함수를 세 번째 매개변수로 넣어주었습니다.
const verifyPassword = (input, rules, logger) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
logger.info("PASSED");
return true;
}
logger.info("FAIL");
return false;
};
줄바꿈을 통해 AAA 패턴(준비-실행-검증)을 나타내었습니다. 의존성을 대체한 mockLog를 넘겨주었고, verifyPassword에서 정상적으로 호출이 되었다면 written 변수에는 “PASS”가 할당될 것입니다.
describe("password verifier", () => {
describe("given logger, and passing scenario", () => {
it("calls the logger with PASS", () => {
let written = "";
const mockLog = { info: (text) => (written = text) };
verifyPassword("anything", [], mockLog);
expect(written).toMatch(/PASS/);
});
});
});
모듈 의존성을 추상화하여 주입하기
스텁에서 의존성을 모듈로 주입했던 것처럼 기존 의존성과 가짜 의존성을 덮어쓰기 하여 저장할 수 있는 변수를 설정합니다. 그리고 테스트를 마친 후 다시 기존 의존성으로 되돌리는 함수도 설정해줍니다. verifyPassword는 로거에 대한 매개변수가 없이 그대로 사용합니다.
테스트에서는 로거에 대한 의존성을 목으로 대체하여 사용한 후에는 다시 원복을 해야 하므로, afterEach() 함수를 사용해서 각 테스트를 실행한 이후 기존 의존성으로 원복을 시켰습니다. 또한, log.info() 형식을 사용하기 때문에 덮어쓰는 목의 형태도 이에 맞춰 {log: mockLog}로 구성해줍니다.
const originalDependencies = {
log: require("./complicated-logger"),
};
let dependencies = { ...originalDependencies };
const resetDependencies = () => {
dependencies = { ...originalDependencies };
};
const injectDependencies = (fakes) => {
Object.assign(dependencies, fakes);
};
const verifyPassword = (input, rules) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
dependencies.log.info("PASS");
return true;
}
dependencies.log.info("FALSE");
return false;
};
// 테스트 코드
describe("password verifier", () => {
afterEach(resetDependencies);
describe("given logger, and passing scenario", () => {
it("calls the logger with PASS", () => {
let written = "";
const mockLog = { info: (text) => (written = text) };
injectDependencies({ log: mockLog });
verifyPassword("anything", []);
expect(written).toMatch(/PASS/);
});
});
});
함수형 방식으로 주입하기
함수형 방식은 커링 스타일과 부분 적용을 사용하는 스타일로 구현할 수 있습니다.
커링 스타일
커링 스타일을 lodash 라이브러리에서 curry 메서드를 활용하면 보다 쉽게 구현할 수 있습니다. 테스트 코드에서 함수에 매개변수를 순차적으로 넣으면서 테스트를 수행할 수 있습니다.
const verifyPassword = _.curry((rules, logger, input) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
logger.info("PASSED");
return true;
}
logger.info("FAIL");
return false;
});
// 테스트 코드
describe("password verifier", () => {
describe("given logger, and passing scenario", () => {
it("calls the logger with PASS", () => {
let written = "";
const mockLog = { info: (text) => (written = text) };
const verifyWithRules = verifyPassword([]);
const verifyWithLogger = verifyWithRules(mockLog);
verifyWithLogger("anything");
expect(written).toMatch(/PASS/);
});
});
});
부분 적용 스타일
매개변수를 하나씩 받는 커링과 다르게 2개, 1개 식으로 매개변수를 설정하는 방식입니다. 반환되는 함수에 매개변수가 1개 들어가고, 테스트 코드에서도 2개, 1개 라는 매개변수를 준수하면 됩니다.
const verifyPassword = (rules, logger) => {
return (input) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
logger.info("PASSED");
return true;
}
logger.info("FAIL");
return false;
};
};
// 테스트 코드
describe("password verifier", () => {
describe("given logger, and passing scenario", () => {
it("calls the logger with PASS", () => {
let written = "";
const mockLog = { info: (text) => (written = text) };
const passVerify = verifyPassword([], mockLog);
passVerify("anything");
expect(written).toMatch(/PASS/);
});
});
});
객체 지향 방식으로 주입하기
클래스 기반으로 코드를 짜면 verifyPassword 함수의 매개변수를 클래스 생성자 함수의 매개변수로 설정해주면 됩니다. 아래 코드에서처럼 인스턴스 프로퍼티를 활용할 수도 있습니다. 테스트는 new 키워드를 통해 인스턴스를 만들어주고, verify 메서드를 호출해줍니다.
class PasswordVerifier {
constructor(rules, logger) {
this.rules = rules;
this.logger = logger;
}
verify(input) {
const failed = this.rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
this.logger.info("PASSED");
return true;
}
this.logger.info("FAIL");
return false;
}
}
// 테스트 코드
describe("password verifier", () => {
test("given logger and passing scenario, calls logger with PASS", () => {
let written = "";
const mockLog = { info: (text) => (written = text) };
const verifier = new PasswordVerifier([], mockLog);
verifier.verify("anything");
expect(written).toMatch(/PASS/);
});
});
타입스크립트 활용 - 인터페이스 주입
타입스크립트의 파라미터 프로퍼티를 활용하여 필드에 매개변수를 할당하는 것을 간략하게 표현해주고, 매개변수들에 원시타입이나 인스턴스를 타입으로 설정해줍니다.
// 로거 인터페이스
export interface ILogger {
info(text: string);
}
// 클래스
export class PasswordVerifier {
constructor(private rules: any[], private logger: ILogger) { }
verify(input: string): boolean {
const failed = this.rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
this.logger.info('PASSED');
return true;
}
this.logger.info('FAIL');
return false;
}
}
// 테스트 코드
describe('password verifier', () => {
test("given logger and passing scenario, calls logger with PASS", () => {
let written: string;
const mockLog: ILogger = { info: (text: string) => written = text}
const verifier = new PasswordVerifier([], mockLog);
verifier.verify('anything');
expect(written).toMatch(/PASS/);
});
});
인터페이스 분리 원칙
인터페이스가 복잡해져서 필요한 것보다 더 많은 기능이 포함되어 있다면 필요한 기능만 포함된 더 작은 어댑터 인터페이스를 만들어야 한다는 것입니다. 함수를 더 작게, 이름을 명확하게, 매개변수를 적게 한다면 더 간단한 테스트가 될 수 있습니다.
부분 모의 객체
함수형 방식
여러 메서드가 있는 복잡한 인터페이스를 구현한 실제 의존성을 활용하는 클래스가 있을 때, 이 클래스의 인터페이스를 만들고 필요한 메서드를 덮어쓰는 방식입니다.
const testableLog = new RealLogger();
let written= "";
testableLog.info = (text) => (written = text);
객체 지향 방식
마찬가지로 복잡한 인터페이스를 구현한 클래스가 있을 때, 이를 상속받아서 필요한 메서드를 덮어쓰는 방식입니다.
class TestableLogger extends RealLogger {
written = "";
info(text) {
this.written = text;
}
}
참고자료
- 단위 테스트의 기술, 길벗
- 도서 코드 예제 깃헙
'Dev > 테스트 코드' 카테고리의 다른 글
비동기 코드 단위 테스트 (0) | 2025.03.16 |
---|---|
격리 프레임워크 (0) | 2025.03.07 |
의존성 분리와 스텁 (0) | 2025.03.02 |
JEST 활용 단위 테스트 작성 (0) | 2025.02.25 |
단위 테스트 기초 (0) | 2025.02.23 |