Intro
내가 경험했던 팀에서는 TDD를 성공적으로 도입해본 적이 없다. 테스트코드 도입을 위한 리소스가 너무 컸기 때문이다.
서비스의 핵심 구조를 변경해야 할 때마다, 테스트코드 없이 사람이 직접 모든 상황을 테스트했었는데… 이 과정에서 테스트 코드의 필요성을 뼈저리게 느꼈다.
이제는 그 팀을 떠나 다시 TDD를 돌아보며, 다음에 새 프로젝트를 시작한다면 TDD를 자신있게 도입할 수 있는 준비된 개발자가 되어야겠다고 생각했다.
테스트 코드를 도입하기 어려운 이유
테스트코드의 필요성을 이해하는 것은 어렵지 않지만, 실제로 도입하는 과정에서는 진입장벽이 꽤 높다고 느꼈다.
“이 코드를 테스트를 하려면 어떻게 짜야 하지?”
“테스트 코드 종류는 뭐고 라이브러리, 툴은 뭘 써야하지?”
“뭐부터 해야 하지?”
…
“아… 지금은 도입하기 어려울 것 같다”
TDD를 통해 코드의 안정성을 확보할 수 있다고 하지만, 정작 테스트코드에 대한 막연함은 TDD 도입에 대한 불확실성을 증가시키고 결국 포기하게 만든다.
그래서 일단 시작이 중요한 것 같다. 이번 글을 쓰면서 TDD의 찍먹이라도 해보면서 시작해 보기로 했다.
테스트하기 좋은 코드의 특성
어떤 코드가 테스트하기 좋은가?
테스트하기 좋은 코드가 무엇인지 알고 싶어서 여러 테스트 관련 자료를 찾아보았다.
가장 핵심적인 원칙은 “외부 상태에 영향을 받지 않는 것”이다.
즉, DB나 API같은 외부 요소에 영향을 받지 않고 같은 입력에 항상 같은 결과를 반환하는 것이다.
그런데 대부분의 서비스는 DB에서 데이터를 가져오거나 기록하고, 외부 API를 사용한다. 그 외에도 시간, 위치, 디바이스 등의 외부 상황에 영향을 받는다. 그렇다면 어떻게 테스트하기 좋은 코드를 만들 수 있을까?
답은 테스트하기 좋은 코드와 테스트하기 어려운 코드를 분리하는 것이다.
다시 말해, 순수한 비즈니스 로직과 외부 의존성(사이드 이펙트가 있는 코드)을 분리하는 것이다.
이를 위해 외부 의존성이 있는 값은 파라미터로 넘겨주거나 Mocking 함수와 Mock DB를 활용하는 등 다양한 기법이 사용된다.
하지만 이런 개념은 글로만 읽어서는 완전히 와닿지 않을 수 있다. 내가 경험했던 실제 상황을 예시로 가져와 적용해보겠다.
실제 코드로 살펴보기
비교적 간단한 테스트
목표: 결제수단을 입력하는 칸에 사용자가 신용카드 번호를 입력하면, 4자리씩 띄어서 표시해주어야 한다.
입력: 1234567889099892
출력: 1234 5678 8909 9892
추가로 고려할 조건은 16자리를 다 채우지 않더라도 동일한 형식으로 띄어쓰기를 해주어야 하며, 8자리 미만인 경우는 아무런 조치를 하지 않는다.
입력: 123456788
출력: 1234 5678 8
입력: 123456
출력: 123456
테스트하기 좋은 코드를 위해 먼저 생각해야 할 것은, 어떤 문자열이 입력되면 이것을 “카드 형식으로 변환”만 하는 함수를 분리하는 것이다. 이 페이지에는 카드 번호를 입력하지 않았을 때, 잘못 입력했을 때, 적절하게 입력했을 때의 작동에 대한 스펙이 더 있지만, 여기서는 단순히 신용카드 번호라는 “string”에만 집중한다.
만약 다른 로직까지 함께 테스트해야 한다면 코드가 더 복잡해지고, 테스트가 실패했을 때 어느 부분을 수정해야 할지 파악하기도 어려워질 것이다.
이 정도면 비교적 간단한 분리라고 할 수 있다.
테스트 코드 작성하기
이제 formatCreditCard
라는 함수의 테스트를 먼저 작성해보자.
describe("formatCreditCard", () => {
test("16자리 숫자는 4자리 단위로 띄워준다", () => {
expect(formatCreditCard("1234567812345678")).toBe("1234 5678 1234 5678");
expect(formatCreditCard("9876543210987654")).toBe("9876 5432 1098 7654");
});
test("8자리 이상 입력시 띄워준다", () => {
expect(formatCreditCard("12345678")).toBe("1234 5678");
expect(formatCreditCard("987654321")).toBe("9876 5432 1");
expect(formatCreditCard("1357924680")).toBe("1357 9246 80");
expect(formatCreditCard("24681357911")).toBe("2468 1357 911");
expect(formatCreditCard("123456789012")).toBe("1234 5678 9012");
expect(formatCreditCard("9876543210987")).toBe("9876 5432 1098 7");
expect(formatCreditCard("12345678901234")).toBe("1234 5678 9012 34");
expect(formatCreditCard("567812349876543")).toBe("5678 1234 9876 543");
});
test("8자리 미만 숫자는 띄우지 않는다", () => {
expect(formatCreditCard("1234567")).toBe("1234567");
expect(formatCreditCard("98765")).toBe("98765");
expect(formatCreditCard("42")).toBe("42");
});
});
이를 충족하는 코드를 일단 작성해보았다.
// formatCreditCard.js
function formatCreditCard(number) {
return number.replace(/(\d{4})(?=\d)/g, "$1 ");
}
module.exports = formatCreditCard;
테스트 결과는…
실패했다! 사실 일부러 틀리게 작성했다. 테스트를 실행해보니 8자리 미만일 때 기대한 대로 작동하지 않는다는 것을 확인할 수 있었다.
그럼 이 부분을 보완해보자.
// formatCreditCard.js
function formatCreditCard(number) {
// 8자리 미만이면 포맷팅하지 않고 그대로 반환
if (number.length < 8) {
return number;
}
return number.replace(/(\d{4})(?=\d)/g, "$1 ");
}
module.exports = formatCreditCard;
좋다. 테스트가 성공했다! 이제 다음 단계로 넘어가보자.
테스트가 쉬운 코드 지키기
이렇게 개발하고 서비스는 잘 운영되고 있었는데, 코드를 개선해야 하는 상황이 발생했다.
AMEX 카드 중에는 15자리인 카드도 있으며, 이런 카드는 띄어쓰기 방식이 다르다는 것을 알게 되었다.
그러면 15자리면 띄워쓰기를 다르게 하면 되냐? 그건 아니다. 15자리까지 입력한 상태의 카드가 16자리를 입력하지 이전일수도 있으니까. 4자리씩 띄워주는게 맞을 수도 있다.
이 상황에서는 해당 카드가 AMEX 카드인지 판별해야 하는데, 그러기 위해서는 카드 번호를 PG사 API로 보내 카드 종류를 확인해야 한다.
자, 그럼 AMEX 카드 케이스는 어떻게 테스트 코드를 작성해야 할까?
AMEX 카드 여부는 외부 상황인데, 외부 상황은 테스트 코드에 넣으면 좋지 않다고 했다.
가장 먼저 생각한 것은 “외부 API 사용을 이 함수에서 분리하자”는 것이다.
function formatCreditCard(number, type) {
...
}
카드 종류 판별은 다른 곳에서 처리하고, type이 “AMEX”로 들어오면 15자리 포맷팅을 적용하고, 나머지 경우는 기존과 동일하게 포맷팅한다. 이렇게 하면 formatCreditCard
함수의 테스트는 케이스가 추가되지만 크게 복잡해지지 않는다!
먼저 실패하는 테스트를 작성해보자.
describe("formatCreditCard", () => {
(중략...)
describe("AMEX 카드 (15자리) - 4-6-5 포맷 적용", () => {
test("AMEX 카드(15자리)는 4-6-5 포맷으로 변환된다", () => {
expect(formatCreditCard("371234567890123", "Amex")).toBe(
"3712 345678 90123"
);
});
test("AMEX 카드지만 15자리가 안 찼을 때에도 4-6-5 포맷으로 변환", () => {
expect(formatCreditCard("3712345678", "Amex")).toBe("3712 345678");
expect(formatCreditCard("371234567890", "Amex")).toBe("3712 345678 90");
});
test("AMEX 카드이지만 8자리 미만이면 그대로 반환", () => {
expect(formatCreditCard("3712", "Amex")).toBe("3712");
expect(formatCreditCard("371234", "Amex")).toBe("371234");
});
});
});
테스트를 충족시키는 코드를 작성해보자.
// formatCreditCard.js
function formatCreditCard(number, type) {
if (number.length < 8) return number;
if (type === "Amex") {
return number.replace(/^(\d{4})(\d{0,6})?(\d{0,5})?$/, (_, p1, p2, p3) =>
[p1, p2, p3].filter(Boolean).join(" ")
);
}
return number.replace(/(\d{4})(?=\d)/g, "$1 ");
}
module.exports = formatCreditCard;
이렇게 복잡한 모킹이나 E2E 테스트 개념을 다루지 않고도 어떤 코드를 작성해야 할지 감을 잡을 수 있었다.
과연 필요한 테스트일까?
신용카드 번호를 포맷팅하는 간단한 유틸함수에 대한 테스트 코드를 만들고 나니 이런 말들이 떠오르며 다시 혼란이 생겼다.
“테스트를 추가할 때마다 ‘이 테스트가 가치 있는가?’ 고민해야 한다.”
특히 단순한 유틸리티 함수는 테스트에서 과감히 제외하라는 이야기도 있어서 이 테스트의 가치에 대해 다시 생각해보게 되었다.
이 기준에 대해 고민하다가 결국 GPT의 도움을 받아보았다. 단위 테스트(블라디미르 코리코프)라는 책에 따르면 회귀 방지, 리팩터링 내성, 빠른 피드백과 유지 보수성이 중요하다고 하여 이 기준으로 지금까지 작성한 테스트 코드를 제시하고 평가를 요청했더니 흥미로운 개선 포인트를 제안해주었다.
먼저 예외 처리에 대한 개선 사항:
그리고 반복되는 테스트를 test.each로 리팩터링하는 방법:
마무리하며…
이 글을 작성하면서 TDD에 대한 이해가 아직 많이 부족하다고 느꼈다. Jest를 비롯한 테스팅 라이브러리에 대한 경험도 부족하고, TDD 관련 용어와 개념들(모킹, 목 서버, 단위 테스트, 통합 테스트, E2E 등)은 익숙하지만 실제 적용 시에는 헷갈리는 부분이 많았다.
TDD를 도입하지 못하는 주요 이유 중 하나는 TDD를 충분히 이해하지 못했기 때문이라고 생각한다. TDD의 필요성 정도만 이해한 상태에서 막상 도입하려고 하니 알아야 할 것도 많고 코드 작성 방식도 바꿔야 한다. 그러다 보니 “필요성은 알겠는데 지금 당장은 못하겠어”, “해봤는데 시간이 너무 많이 들고 별로였어” 같은 반응이 나오게 된다. 실제로 빠르게 개발이 진행되는 상황에서 TDD의 필요성을 설득하고 도입하는 것은, 특히 어떻게 테스트 가능한 코드를 작성해야 하는지 알기가 매우 어렵다.
TDD를 잘 실천하는 개발자들은 분명 이 방식으로 많은 이점을 얻고 있다. 그렇기에 TDD에 대해 더 깊이 알아볼 필요가 있다고 생각한다. 이 글에서는 테스트에 관한 모든 내용을 다루지 못했다. 모킹 활용 기법이나 E2E 테스트, 컴포넌트 렌더링 테스트 등에 대해서도 앞으로 더 공부해보려 한다.