TDD에 대한 오해: 테스트는 구현이 아니라 동작을 지켜야 한다
처음 TDD를 접했을 때는 테스트가 굉장히 엄격한 규칙처럼 느껴졌다.
기능을 만들기 전에 테스트를 작성하고,
테스트가 통과할 만큼만 구현하고,
리팩토링을 반복한다.
개념은 이해했지만 실제 프로젝트에서는 테스트를 작성하는 것이 꽤 부담스러웠다.
특히 처음에는 테스트가 코드를 검증하는 것이라고 생각했다.
그리고 그 생각 때문에 테스트를 자주 깨뜨렸다.
테스트가 구현을 알고 있으면 생기는 일
예전에는 내부 구현까지 테스트하려고 했던 적이 많았다.
예를 들어 이런 코드가 있다고 해보자.
export function getFullName(firstName: string, lastName: string) {
return `${firstName} ${lastName}`;
}
그리고 테스트를 작성한다.
expect(getFullName("Garam", "Lee")).toBe("Garam Lee");
여기까지는 괜찮다.
문제는 구현 세부사항까지 검증하려고 할 때였다.
const mockJoin = jest.spyOn(Array.prototype, "join");
getFullName("Garam", "Lee");
expect(mockJoin).toHaveBeenCalled();
이 테스트는 함수의 결과가 아니라 구현 방식을 검증한다.
만약 내부 구현을 바꿔도 결과가 동일하다면 사용자는 아무런 차이를 느끼지 못한다.
하지만 테스트는 실패한다.
리팩토링이 무서운 테스트
좋은 테스트는 리팩토링을 도와준다.
반대로 구현에 의존하는 테스트는 리팩토링을 방해한다.
예전에 테스트 코드가 너무 촘촘하게 구현을 검증하던 프로젝트를 경험한 적이 있다.
기능은 정상 동작하는데 코드 구조를 조금만 바꿔도 테스트가 수십 개씩 깨졌다.
결국 테스트를 신뢰하기보다 무서워하게 됐다.
테스트가 버그를 막는 것이 아니라 개발을 느리게 만드는 느낌이었다.
그때부터 이런 질문을 하게 됐다.
사용자가 이 구현 방식을 알고 있을까?
대부분의 경우 답은 아니었다.
사용자가 보는 것은 결과다
결국 중요한 것은 함수 내부가 아니다.
사용자가 기대하는 결과다.
예를 들어 로그인 기능이라면
it("올바른 계정으로 로그인할 수 있다", () => {
const result = login("admin", "1234");
expect(result.success).toBe(true);
});
이 테스트는 사용자가 경험하는 동작을 검증한다.
반면 아래 테스트는 구현을 검증한다.
expect(hashPassword).toHaveBeenCalled();
expect(validateUser).toHaveBeenCalled();
expect(createToken).toHaveBeenCalled();
물론 필요한 경우도 있다.
하지만 대부분의 경우 사용자는 함수 호출 여부보다 로그인 성공 여부에 관심이 있다.
TDD를 바라보는 시선이 바뀌었다
예전에는 TDD를 테스트를 먼저 작성하는 기법이라고만 생각했다.
지금은 조금 다르게 생각한다.
TDD는
이 기능은 어떤 동작을 해야 하는가?
를 먼저 고민하게 만드는 도구에 가깝다.
테스트를 작성하다 보면 자연스럽게 요구사항을 정리하게 된다.
예외 상황은 없는지,
입력은 무엇인지,
결과는 무엇인지.
생각보다 구현 전에 얻는 것이 많다.
지금도 모든 곳에 TDD를 적용하지는 않는다
솔직히 말하면 지금도 모든 기능을 TDD로 개발하지는 않는다.
빠르게 검증해야 하는 실험적인 기능도 있고,
간단한 UI 수정도 있다.
하지만 중요한 비즈니스 로직이나 오래 유지될 기능은 테스트를 작성하려고 노력한다.
나중에 코드를 수정하는 사람에게 가장 좋은 문서는 테스트인 경우가 많기 때문이다.
마무리
처음에는 테스트가 코드를 검증하는 도구라고 생각했다.
하지만 시간이 지나면서 테스트가 지켜야 하는 것은 구현이 아니라 동작이라는 것을 알게 됐다.
구현은 바뀔 수 있다.
리팩토링도 한다.
구조도 변한다.
하지만 사용자가 기대하는 동작은 쉽게 변하지 않는다.
그래서 요즘 테스트를 작성할 때 가장 먼저 생각하는 것은 이것이다.
이 테스트는 코드가 아니라 사용자의 기대를 검증하고 있는가?
좋은 테스트는 구현을 묶어두지 않는다.
대신 기능이 해야 할 일을 지켜준다.