TDD 1편: TDD에 대해서
TDD는 Kent Beck에 의해 대중화된 개발 방법론이자 실천법이며, 테스트 주도하에 개발을 진행하는 것이다. 이 방법은 보통 우리가 처음 프로그래밍 언어를 배웠을 때와 사뭇 다른 방법으로 진행한다. 보통은 다음과 같이 수행한다. 프로그래밍을 처음 배우는 사람뿐만 아니라 오랫동안 프로그램을 작성한 사람도 이런 습관을 지닌다.
- 구현하고자 하는 기능을 개발한다.
- 해당 기능을 실행해 보고 결괏값을 검증한다.
- 검증이 올바르면 해당 로직이 자동으로 검증될 수 있도록 자동화된 테스트를 작성한다.
하지만 TDD는 다음과 같이 수행한다.
- 새롭게 만들어야 할 또는 개선되어야 할 기능이 어떻게 동작할 것인지를 나타낼 실패 테스트를 만든다.
- 테스트가 통과할 만큼 충분한 코드를 작성한다. 이 말은 코드가 깨끗하게 즉, 가독성 높게 작성될 필요는 없다는 것이다. 이 작업은 모든 테스트를 통과한 코드가 만들어졌을 때 수행한다.
- 1번을 반복하거나 다음 4번을 수행한 후 필요한 경우 1번으로 되돌아가 반복한다.
- 테스트를 통과한 코드를 리팩토링한다. 여기서 리팩토링하더라도 테스트 자체가 통과하면 해당 기능은 여전히 목적을 달성하기 때문에 가독성 높은 코드를 작성하는 데 주력하면 된다.
도식화하면 다음과 같다.
Test-driven development, Wikipedia, https://en.wikipedia.org/wiki/Test-driven_development
여기까지만 보면 “테스트를 작성하고 프로덕션용 코드를 작성하면 되는구나?” 라고 단순히 생각할 수 있다. TDD를 수행하는 행동이라는 관점에서는 맞는 말이지만 TDD의 효과를 제대로 이용하려면 목적도 제대로 알아야 한다.
TDD는 앞에서 개발 방법론이라고 말했다. 여기에 더불어 TDD는 디자인(설계) 기법이기도 하다. TDD 방법을 취함으로 시스템 설계에 영향을 주기 때문이다.
그렇다면 시스템 설계에 어떤 영향을 줄까? TDD는 유지보수성이 높은 설계로 시스템이 구축되도록 영향을 준다. 이게 가장 큰 핵심이다. TDD를 함으로써 견고하고 쉽게 깨지지 않으며 유지보수성이 높은 시스템을 구축할 수 있다는 것이다. TDD가 이와 같은 역할을 수행할 수 있는 이유는 다음과 같다.
첫 번째, 테스트가 가능한 형태로 코드의 구조가 만들어진다. 특정 동작을 기대하는 테스트 코드를 만든 후 그 테스트를 통과시키기 위한 프로덕션용 코드를 작성하기 때문에 테스트할 수 있는 구조 내에서 코드가 작성되기 때문이다.
예로 사각형의 두 변의 길이를 받아 사각형의 넓이를 특정 공간에 저장하는 기능을 작성한다고 하자. 이를 테스트 하기 위해 어떻게 만들까? 아마 TDD를 사용하지 않는 대부분의 상황에서는 다음과 같이 특정 목(mock)의 행위를 검증하는 형식으로 코드를 작성할 것이다.
@Mock
private SquareRepository squareRepository;
...
@Test
public void testSave_success() {
// given
var square = new Square(4, 5);
// when
sut.save(square);
// then
verify(squareRepository, atMostOnce()).save(any(Square.class));
}
이와 같은 테스트가 작성된 이유는 sut.save(..)
를 제대로 수행했는지 검증할 방법이 없기 때문이다. 그래서 목의 행위를 검증하는 식으로 테스트 통과 여부를 확인했다. 이런 테스트의 문제점은 다음과 같다.
sut
구현체의 내부를 알아야 한다.sut
구현체가Square
를 저장하기 위해squareRepository
의save
메서드를 호출해야 한다는 것을 알아야 하고, 저장하기 위해 단 한 번만 저장해야 한다는 것도 알아야 한다. 만약 어떤 경우에 의해save
메서드가 2번 이상 호출하게 되었거나save
메서드가 아닌 다른 메서드로 변경되었을 때 이 테스트 코드도 같이 수정되어야 한다.- 테스트가 쉽게 깨진다. 1번과 유사한 내용이다.
sut
구현체를 알고 있는 테스트 코드이기 때문에sut
구현체 내용이 변경될 경우 이 테스트 코드도 반드시 수정될 수밖에 없다. 이것의 문제점은 테스트 코드가 수정되기 때문에 수정된 프로덕션 코드가 제대로 동작하는지 확신할 수 없다는 것이다. 잘못된 프로덕션 코드를 만들고 그 잘못된 프로덕션 코드를 성공으로 통과시키는 테스트 코드를 작성할 가능성이 있다는 얘기다.
TDD를 사용할 경우 프로덕션 코드가 없기 때문에 아래와 같이 테스트할 수 있는 구조로 테스트를 작성하게 된다.
@Test
public void testSave_success() {
// given
var square = new Square(4, 5);
// when
var result = sut.save(square);
// then
assertThat(result, is(true));
}
위와 같이 테스트 코드를 만들고 이 테스트를 통과시키기 위해 프로덕션 코드를 작성할 때 결괏값을 고려하면서 작성하게 된다. 즉, 저장이 성공했을 경우 true
를 반환할 것이고 그렇지 않을 경우 false
를 반환하도록 운영 코드를 작성할 것이다. 이처럼 작성할 때 sut
구현체의 세부 내용을 모르기 때문에 sut
구현 내용이 어떤식으로 바뀌어도 이 테스트 코드가 수정될 일은 없다. 이 테스트 코드는 언제든지 재사용이 가능하기 때문에 수정된 코드가 기대대로 동작하는지를 안심하며 확인할 수 있다.
두 번째, 작성되는 모든 코드는 검증되는 형태로 유지된다. 실패 테스트 케이스를 만들고 그 테스트 케이스를 동작시키기 위한 코드만이 작성되기 때문이다. TDD 과정 중 리팩터링 단계에서 테스트 케이스 통과와 필요 없는 프로덕션 코드는 삭제된다. 그렇기 때문에 실제로 유효한, 살아있는 코드들만 유지보수된다.
TDD는 시스템 디자인뿐만 아니라 다양한 부분에서도 장점을 제공한다. 개발 과정에서의 빠른 피드백을 받을 수 있다. 그리고 테스트 코드가 메서드, 서비스 또는 API를 사용하는 방식에 대한 문서 지침으로 활용할 수 있게 된다. 테스트 코드로 커버되는 반드시 필요한 코드들만 있기 때문에 디버깅에도 더 많은 도움을 준다. 또한 견고한 테스트 코드 덕분에 개발자는 프로덕션 코드를 수정할 때 걱정 없이 개발할 수 있도록 해주는 환경도 구축해준다. 장기적으로는 유지보수 비용을 낮추는 효과도 보여준다(참고: TDD Research Findings).
하지만 대부분의 현장에서 TDD를 적용하기에는 쉽지 않다. TDD에 대한 학습 곡선이 생각보단 작지 않다. 대부분의 책은 위와 같은 간단한 코드를 통해 TDD를 보여준다. 하지만 실제 비즈니스는 이보다 훨씬 복잡하기 때문에 일반적인 코드 작성 방식보다 많은 시간과 노력이 필요하다.
즉, TDD는 초기 비용이 많이 든다. 그래서 마감일을 매우 중요시하는 프로젝트에서는 이를 쉽게 선택하지 못한다. 심지어 마감일을 지키기 위해 프로덕션 코드만 작성하고 테스트 코드를 작성하지 않는 사례도 많이 경험했을 정도이니 말이다. 이렇게 마감일을 매우 중요하게 생각하는 곳이라면 TDD의 비용이 탐탁지 않을 것이다.
TDD가 만능(silver bullet)은 아니다. TDD 방식을 취한다고 해서 모든 버그를 찾아낼 수 있는 것도 아니다. 좋은 시스템 디자인으로 구축되는 것을 100% 보장하지도 않는다. 그런데도 TDD가 여전히 중요한 이유는 단일 프로덕트를 긴 기간 동안 제공하는 경우 큰 유지보수 비용과 소프트웨어의 회귀성을 줄일 수 있는 좋은 수단이기 때문이다.
마지막으로 TDD와 관련된 책인 Kent Beck의 Test Driven Development: By Example와 Robert C. Martin의 Clean Craftsmanship: Disciplines, Standards, and Ethics을 추천한다. 책을 읽다 보면 TDD의 양면을 느낄 수가 있다.