테스트 코드
코드를 수정할 때마다 '이거 다른 데 안 깨지겠지?' 하는 불안함이 있었다. 테스트 코드는 그 불안함을 해소해주는 장치다.
이번 글에서는 프론트엔드 테스트 코드 중 단위 테스트, 그리고 Jest를 활용한 테스트에 대해 다루고자 한다.
프론트엔드와 테스트 코드
프론트엔드 개발에도 테스트 코드가 중요할까? 요구사항이 비교적 자주 변화하는 프론트엔드 개발의 모든 테스트 코드를 작성하는 것은 불가능한 일이다. 하지만 비교적 자주 변화하지 않는 함수, 컴포넌트의 테스트 코드는 어떨까?
프론트엔드 개발에서 테스트 코드는 여러 이점을 제공한다. 특히 공통 유틸리티 함수, 핵심 비즈니스 로직, 재사용 가능한 컴포넌트와 같이 애플리케이션의 중요하고 안정적인 부분에 대한 테스트는 코드의 신뢰성을 높이고 리팩토링 시 안전망 역할을 한다. 다른 개발자가 코드의 의도를 이해하는 데 도움이 되고, 문서화 역할도 수행한다.
TDD
TDD의 핵심은 테스트가 아니다. TDD의 핵심은 설계다. 테스트 코드를 먼저 작성함으로써 코드의 인터페이스를 먼저 고민하고 이를 통해 더 나은 설계를 이끌어낼 수 있다. 이는 결과적으로 더 나은 품질의 코드를 만들어내는 데 도움을 준다.
TDD는 테스트, 개발, 리팩토링의 짧은 사이클을 반복하는 소프트웨어 개발 프로세스로, 테스트 실패 → 테스트 성공 → 리팩토링의 과정을 통해 점진적으로 기능을 구현한다. 이 방식은 코드의 품질뿐만 아니라 개발자의 설계 능력도 향상시킨다.
예를 들어 뒤에서 다룰 getIntersectCount를 TDD로 짠다면 이렇게 흘러간다. 먼저 Red — '두 배열에서 일치하는 개수를 센다'는 테스트부터 쓰면, 아직 함수가 없으니 실패한다. 다음 Green — 테스트를 통과시키는 것만 목표로 가장 단순하게 구현한다. 마지막 Refactor — 통과 상태를 유지하면서 중복을 걷어내고 이름을 다듬는다. 테스트를 먼저 쓰니 '어떻게 호출하고 싶은지'부터 고민하게 되고, 그게 곧 설계가 된다.
개념 학습
given-when-then
: 테스트 코드를 작성하는 방법
- given : 테스트를 준비한다.
- 테스트에 사용하는 변수, 입력 값을 정의하거나, 모킹 객체를 정의한다.
- when : 실제로 액션을 하는 테스트를 실행한다.
- when은 가장 중요한 구문이지만 가장 짧다. 보통 한 줄이면 끝난다.
- then : 테스트를 검증한다.
setup, teardown
: beforeAll, afterAll, beforeEach, afterEach
beforeAll(() => console.log('1 - beforeAll'));
afterAll(() => console.log('1 - afterAll'));
beforeEach(() => console.log('1 - beforeEach'));
afterEach(() => console.log('1 - afterEach'));
test('', () => console.log('1 - test'));
describe('Scoped / Nested block', () => {
beforeAll(() => console.log('2 - beforeAll'));
afterAll(() => console.log('2 - afterAll'));
beforeEach(() => console.log('2 - beforeEach'));
afterEach(() => console.log('2 - afterEach'));
test('', () => console.log('2 - test'));
});
// 1 - beforeAll
// 1 - beforeEach
// 1 - test
// 1 - afterEach
// 2 - beforeAll
// 1 - beforeEach
// 2 - beforeEach
// 2 - test
// 2 - afterEach
// 1 - afterEach
// 2 - afterAll
// 1 - afterAll
Mock, Stub, Spy
- Mock : 기대한대로 잘 호출되나?
Mock은 반환값이 없는 함수를 테스트할 때, 특정 객체에서 특정 함수가 호출되었는지 테스트할 때 사용한다. 다시 말해, 내가 바라는 호출에 대한 기대를 명시해두고, 명시한 내용에 따라 잘 작동하는지 프로그래밍된 객체이다.
- Stub : 기대한 값이 잘 반환되나?
Stub은 특정 객체에서 특정 함수를 호출할 때, 특정한 값이 반환대기를 기대하며 더미 데이터를 지정할 때 사용한다.
- Spy : 함수가 잘 사용되고 있나?
Spy는 특정 함수만 실제 함수를 호출하게 하고 싶을 때, 기존의 객체나 함수를 감시할 때 사용한다. 이 때 함수 호출, 전달된 인자, 반환값 등을 기록하지만 실제 로직의 동작을 변경하지 않는다.
개념은 셋으로 나뉘지만, 막상 Jest로 코드를 짜다 보면 이게 좀 헷갈린다. 개념과 API가 1:1로 딱 떨어지지 않기 때문이다. 대략 이렇게 대응된다. Mock은 jest.fn()으로 빈 함수를 새로 만들어 호출 여부·인자를 검증하고, Stub은 jest.mock()으로 모듈을 통째로 대체하며 반환값을 고정하고, Spy는 jest.spyOn()으로 실제 함수는 그대로 둔 채 감시한다. '가짜 함수를 새로 만드는' jest.fn(), '모듈을 통째로 대체하는' jest.mock(), '실제 함수를 감시하는' jest.spyOn()이 각각 Mock/Stub/Spy에 대응된다고 보면 된다.
Matcher
https://jestjs.io/docs/using-matchers, https://inpa.tistory.com/entry/JEST-%F0%9F%93%9A-jest-%EA%B8%B0%EB%B3%B8-%EB%AC%B8%EB%B2%95-%EC%A0%95%EB%A6%AC
Matcher는 테스트하는데 사용하는 메서드이다. toBe(), toEqual(), toBeDefined() 등이 있으며 사용 방법은 다음과 같다.
const { getUser } = require('user'); // 테스트할 함수를 가져온다.
test("return a user object", () => {
// getUser(1)의 리턴 결과값이 { 객체 } 값이 같은 경우 true
expect(getUser(1)).toEqual({
id: 1,
email: `user1@test.com`,
});
});
여기서 toBe와 toEqual을 헷갈리기 쉬운데, toBe는 Object.is 기반이라 원시값(숫자·문자열) 비교에 쓰고, toEqual은 객체·배열을 재귀적으로 값 비교한다. 예를 들어 expect({id:1}).toBe({id:1})는 서로 다른 참조라 실패하지만 toEqual은 통과한다. 숫자·문자열은 toBe, 객체·배열은 toEqual로 외워두면 편하다. 위 getUser 예제가 toEqual인 것도 반환값이 객체이기 때문이다.
실전
애플리케이션 테스트 코드
애플리케이션에는 사용자에게서 전달받는 입력과 사용자에게 보여주는 출력이 있다. 독립된 환경에서 테스트를 하기 위해서는 사용자의 입력을 모킹하고, 사용자에게 보여주는 출력을 추적할 필요가 있다.
이를 위해 스텁과 스파이 함수를 만들어보자.
- 스텁
const mockReadLineAsync = (mockValues) => {
mockValues.forEach((mockValue) => jest.spyOn(utils, 'readLineAsync').mockResolvedValueOnce(mockValue));
};
const mockGenerateUniqueNumbers = (mockValues) => {
mockValues.forEach((mockValue) => jest.spyOn(utils, 'generateUniqueNumbers').mockReturnValueOnce(mockValue));
};
위 코드에서 mockReadLineAsync와 mockGenerateUniqueNumbers는 각각 사용자의 입력과 로또 번호 생성을 모킹하는 스텁 함수이다. 이를 통해 테스트 시 실제 사용자 입력이나 랜덤 번호 생성 없이도 예측 가능한 값을 반환하도록 할 수 있다.
- 스파이
consoleLogSpy = jest.spyOn(console, 'log');
사용자의 출력은 console.log을 통해 출력되며, 이를 스파이 함수를 통해 추적할 수 있다. 스파이 함수는 실제 함수의 동작은 그대로 두고 호출 여부만 추적하므로, 실제 출력을 방해하지 않으면서도 테스트가 가능하다.
스텁과 스파이 함수를 만들었으니 이제 실제 테스트 코드에서 활용해보자.
- setup & teardown
describe('Application', () => {
let consoleLogSpy;
beforeEach(() => {
consoleLogSpy = jest.spyOn(console, 'log');
});
afterEach(() => {
jest.restoreAllMocks();
});
// ..
- given
describe('Application', () => {
// ..
describe('기능 요구사항 테스트', () => {
test('로또 구입 금액을 입력하면 구입 금액에 해당하는 만큼 로또를 발행해야 한다.', async () => {
mockReadLineAsync(['1000', '1,2,3,4,5,6', '7', NO]);
// ..
}
})
})
given에서는 사용자 입력값(로또 구매금액, 당첨 번호, 보너스 번호, 종료 여부)을 미리 지정해둔다.
- when
describe('Application', () => {
// ..
describe('기능 요구사항 테스트', () => {
// ..
const app = new App();
await app.run();
})
})
when은 한 줄이다. 앱을 실행한다.
- then
describe('Application', () => {
// ..
describe('기능 요구사항 테스트', () => {
test('로또 구입 금액을 입력하면 구입 금액에 해당하는 만큼 로또를 발행해야 한다.', async () => {
['1개를 구매했습니다.'].forEach((expectedConsoleLogMessage) => {
expect(consoleLogSpy).toHaveBeenCalledWith(expectedConsoleLogMessage);
});
});
});
});
then에서는 consoleLogSpy로 '1개를 구매했습니다.'가 출력되었는지 확인한다.
자세한 코드는 아래를 참고 바랍니다.
단위 테스트
- given
describe('utils', () => {
describe('getIntersectCount', () => {
test('두 배열을 비교해 일치하는 개수를 반환한다.', () => {
const lottoNumbers = [1, 2, 3, 4, 5, 6];
const userNumbers = [1, 2, 3, 7, 8, 9];
// ..
});
});
두 배열을 정의한다. 여기서는 [1, 2, 3]이 공통 요소로 3개가 일치하는 상황을 설정했다.
- when
describe('utils', () => {
describe('getIntersectCount', () => {
test('두 배열을 비교해 일치하는 개수를 반환한다.', () => {
// ..
const intersectCount = getIntersectCount(lottoNumbers, userNumbers);
// ..
});
});
getIntersectCount에 두 배열을 전달해 호출한다.
- then
describe('utils', () => {
describe('getIntersectCount', () => {
test('두 배열을 비교해 일치하는 개수를 반환한다.', () => {
// ..
expect(intersectCount).toBe(3);
});
});
toBe 매처로 반환값이 3인지 확인한다.
자세한 코드는 아래를 참고 바랍니다.
맺으며
이번 글에서는 프론트엔드 개발에서의 테스트 코드ㄷ 작성에 대해 알아보았다.
특히 TDD(테스트 주도 개발)는 테스트 자체보다 더 나은 설계를 이끌어내는 데 초점을 맞추고 있음을 강조했다. given-when-then 패턴을 통해 테스트 코드의 구조를 명확하게 하고, 스텁과 스파이, 목 등의 개념을 활용해 효과적인 테스트를 작성하는 방법도 살펴보았다.
실제 애플리케이션 테스트와 단위 테스트 예제를 통해 테스트 코드 작성 방법을 구체적으로 확인했다.
당장은 일이 느는 것 같아도, 길게 보면 버그가 줄어 이득이다. 특히 자주 변하지 않는 핵심 로직이나 재사용 컴포넌트부터 테스트를 붙여보자.