TDD란?
TDD의 개념에 대해서 정리
TDD (Test-Driven Development)란 무엇인가?
TDD란?
TDD(Test-Driven Development, 테스트 주도 개발)는 소프트웨어 개발 방법론 중 하나로, 테스트 코드를 먼저 작성한 후 실제 코드를 구현하는 방식으로 개발을 진행한다.
이 접근법은 코드를 작성하기 전에 요구사항을 명확히 하고, 코드가 올바르게 작동하는지 지속적으로 검증할 수 있게 해준다.
TDD의 주요 과정
TDD는 “Red-Green-Refactor” 라는 세 가지 단계를 반복하면서 개발이 진행된다.
- Red (실패하는 테스트 작성)
- 구현하려는 기능에 대한 테스트 코드를 작성한다.
- 아직 기능이 구현되지 않았으므로 테스트는 실패한다.
- 이 과정에서 요구사항을 구체화하고 개발 목표를 명확히 한다.
- Green (테스트 통과를 위한 최소한의 구현)
- 테스트를 통과하기 위한 최소한의 코드를 작성한다.
- 테스트가 성공할 때까지 코드를 수정하며, 이 단계에서는 간결하고 단순한 코드를 목표로 한다.
- Refactor (리팩토링)
- 테스트가 통과한 코드를 리팩토링하여 품질을 개선한다.
- 중복을 제거하고, 가독성과 유지보수성을 높인다.
- 리팩토링 후에도 모든 테스트가 통과해야 한다.
TDD의 장점
- 코드 품질 개선
- 버그 감소: 모든 기능이 테스트로 검증되므로 코드의 안정성이 높아진다.
- 가독성 향상: 리팩토링 과정에서 코드 구조를 개선하므로, 유지보수와 협업이 쉬워진다.
- 요구사항에 대한 명확한 이해
- 개발 전에 테스트를 작성하면서 요구사항을 다시 검토하고 구체화할 수 있다.
- 이를 통해 개발 중 요구사항을 오해하거나 놓치는 일이 줄어든다.
- 디버깅 시간 단축
- 문제가 발생할 경우, 실패하는 테스트를 통해 빠르게 원인을 파악할 수 있다.
- 작은 단위의 테스트로 문제가 발생한 코드를 쉽게 찾을 수 있다.
- 안정적인 리팩토링 가능
- 테스트가 작성된 상태에서 리팩토링하면 기존 기능이 깨지지 않음을 보장할 수 있다.
- 코드 변경이 잦은 프로젝트에서 특히 유용하다.
- 효율적인 협업
- 명확한 테스트 코드가 있는 프로젝트는 새로운 팀원이 쉽게 이해하고 참여할 수 있다.
- 테스트가 문서 역할을 하여 코드의 의도를 설명해준다.
- 장기적인 개발 생산성 향상
- 초기에는 시간이 더 소요될 수 있지만, 디버깅 시간과 유지보수 비용이 줄어들어 장기적으로 개발 속도가 빨라진다.
- 기존 코드의 안정성이 확보되므로 새로운 기능 추가가 쉬워진다.
TDD의 단점
- 초기 개발 속도 감소
- 테스트 작성에 추가적인 시간이 소요되어 처음에는 개발 속도가 느리게 느껴질 수 있다.
- 테스트 작성의 어려움
- 경험이 부족한 개발자는 좋은 테스트 코드를 작성하는 데 어려움을 겪을 수 있다.
- 복잡한 테스트 관리
- 프로젝트가 커질수록 테스트 코드도 함께 증가하여 관리가 어려워질 수 있다.
TDD의 적용 사례
- 새로운 기능 개발
- 기능을 추가할 때 TDD를 활용하면 안정성을 확보하면서 요구사항에 맞는 구현이 가능하다.
- 버그 수정
- 버그를 발견했을 때 이를 재현하는 테스트를 작성하고, 테스트를 통과하도록 코드를 수정할 수 있다.
TDD가 적합한 상황
- 복잡한 요구사항이 있는 경우
- 요구사항을 테스트로 명확히 정의하여 구현을 돕는다.
- 장기적인 프로젝트
- 코드의 유지보수가 중요하고, 기능 추가가 지속적으로 이뤄지는 프로젝트에 적합하다.
- 협업이 많은 프로젝트
- 테스트 코드는 명확한 기준을 제공하여 팀원 간 의사소통을 원활히 한다.
TDD 시작하기
- 작은 단위로 시작하기
- 테스트는 작은 기능이나 메서드부터 시작해 점진적으로 큰 단위로 확장하는 것이 좋다.
- 테스트 커버리지 목표 설정
- 코드의 테스트 커버리지를 목표로 설정해 점진적으로 TDD 적용 범위를 늘려간다.
- 테스트 도구 사용
- 언어에 맞는 테스트 도구와 프레임워크를 활용하면 TDD를 더 쉽게 도입할 수 있다.
예: JUnit(자바), Kotest(코틀린), pytest(파이썬)
- 언어에 맞는 테스트 도구와 프레임워크를 활용하면 TDD를 더 쉽게 도입할 수 있다.
예시
위에서 말한 Red-Green-Refactor 단계를 반복하며 개발 하며 아래의 요구사항에 맞는 TDD를 어떻게 진행하는지에 대한 예시이다.
요구사항: 숫자 두 개를 더하는 기능 구현
Red
: 실패하는 테스트 작성
먼저 구현할 기능의 테스트 코드를 작성한다.
1
2
3
4
5
6
7
8
9
public class CalculatorTest {
@Test
public void testAdd() {
Calculator calculator = new Calculator();
int result = calculator.add(2, 3);
assertEquals(5, result); // 2 + 3 = 5
}
}
그러나 이 코드는 아직 기능이 구현되지 않았으므로 아래와 같은 오류 메시지를 만나며 실행 시 실패한다.
1
java.lang.NullPointerException: add 메서드가 구현되지 않음
Green
: 테스트 통과를 위한 최소한의 구현
테스트를 통과하기 위한 최소한의 코드를 작성한다.
여기서는 Calculator 클래스와 add 메서드를 추가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 요구사항을 만족시키는 간단한 구현
public class Calculator {
public int add(int a, int b) {
return 5;
}
}
public class CalculatorTest {
@Test
public void testAdd() {
Calculator calculator = new Calculator();
int result = calculator.add(2, 3);
assertEquals(5, result); // 5 = 5
}
}
테스트는 정상 작동하며 테스트에 성공한다.
1
true
Refactor
: 코드 리팩토링
아래와 같이 테스트 케이스를 늘려보면 바로 오류가 난다.
1
2
3
4
5
6
7
8
9
10
11
public class CalculatorTest {
@Test
public void testAdd() {
Calculator calculator = new Calculator();
int result = calculator.add(2, 3);
int result2 = calculator.add(1, 2);
assertEquals(5, result); // true
assertEquals(3, result2); // false
}
}
단순히 하나의 테스트 케이스만을 만족하는게 아니라 여러 테스트 케이스를 만족하도록 코드를 리팩토링한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Calculator {
public int add(int a, int b) {
return a + b; // 모든 덧셈에 대해서 가능하도록 연산
}
}
public class CalculatorTest {
@Test
public void testAdd() {
Calculator calculator = new Calculator();
int result = calculator.add(2, 3);
int result2 = calculator.add(1, 2);
assertEquals(5, result); // true
assertEquals(3, result2); // true
}
}
테스트 케이스를 추가해가면서 1~3의 과정을 반복한다.
- 번외
아래와 같이 곱하기 기능이 추가된다면 1~3의 과정을 거쳐서 아래와 같이 리팩토링도 가능할 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Calculator {
public int add(int a, int b) {
return Operation.ADD.apply(a, b);
}
public int multiply(int a, int b) {
return Operation.MULTIPLY.apply(a, b);
}
private enum Operation {
ADD((a, b) -> a + b),
MULTIPLY((a, b) -> a * b); // 곱하기 연산 추가
private final BiFunction<Integer, Integer, Integer> operation;
Operation(BiFunction<Integer, Integer, Integer> operation) {
this.operation = operation;
}
public int apply(int a, int b) {
return operation.apply(a, b);
}
}
}
public class CalculatorTest {
@Test
public void testAdd() {
Calculator calculator = new Calculator();
int result = calculator.add(2, 3);
int result2 = calculator.add(1, 2);
int result3 = calculator.multiply(2, 3); // 2 * 3 = 6
assertEquals(6, result); // true
assertEquals(5, result2); // true
assertEquals(6, result3); // true
}
}
Rest API 실전
TDD(Test-Driven Development)의 핵심은 테스트 코드를 먼저 작성하고, 이를 기반으로 실제 구현 코드를 작성하는 것이다.
가장 많이 사용하는 MVC 구조에서 TDD스러운 접근이 무엇인지 살펴보자.
1. 도메인 중심 설계
TDD는 “비즈니스 로직”을 중심으로 설계와 구현을 진행해야 합니다. 도메인 모델을 먼저 정의하는 것이 중요합니다.
왜 도메인부터 시작하나?
도메인 모델은 시스템의 핵심 비즈니스 로직을 담고 있습니다. 이것이 TDD의 첫 번째 테스트 대상이 되어야 합니다. 비즈니스 규칙과 제약 조건을 먼저 테스트로 정의합니다.
- 단계
- 도메인 객체 정의: aggregation(?)레벨의 도메인 정의. 예를 들어, Order, Product, User 같은 핵심 엔티티를 아우루는 도메인 정의.(이러한 도메인 정의 후 엔티티를 정의.)
- 도메인 로직 작성: Order를 생성하거나 계산하는 메서드의 로직.
- 도메인 로직에 대한 단위 테스트 작성.
2. Service 계층 테스트
Service 계층은 도메인 로직을 조합하거나, 외부 시스템(DB, API 등)과 상호작용을 담당합니다.
- 단계
- Service의 핵심 메서드에 대한 테스트 작성 (비즈니스 로직을 서비스에서 처리하는 경우).
- 의존성을 Mocking: Repository나 외부 시스템과의 상호작용을 Mock으로 처리.
- 필요할 경우 BehaviorSpec 등을 활용하여 비즈니스 플로우를 검증.
3. Repository 테스트 (데이터 계층)
Repository는 보통 CRUD 중심이므로, 이 단계에서 데이터 모델과 쿼리를 검증합니다.
Repository의 기본 동작 (Save, Find, Delete 등)에 대해 테스트를 작성.
데이터베이스가 포함된 통합 테스트가 필요하다면, H2와 같은 in-memory DB를 활용.
4. Controller 테스트
Controller는 사용자 입력을 처리하고, Service를 호출하여 결과를 반환하는 역할입니다. TDD에서는 Controller는 나중에 테스트를 작성하는 것이 일반적입니다.
왜 마지막인가?
Controller는 비즈니스 로직이 아니라, 단순히 Service를 호출하는 역할입니다. Service와 도메인 로직이 완성되면 Controller 테스트는 간단하게 끝낼 수 있습니다. RESTful API나 HTTP 인터페이스 검증은 MockMvc, WebTestClient 등을 사용해 작성.
5. TDD스러운 접근 순서
- 도메인부터 테스트 작성
- “가장 중요한 비즈니스 로직은 무엇인가?”를 먼저 정의하고, 이를 검증하는 테스트 작성.
- ex) “사용자는 최소 1개의 제품을 주문할 수 있어야 한다.”
- Service 계층 테스트
- 도메인 로직을 활용하는 비즈니스 흐름을 검증.
- Repository나 외부 의존성을 Mock으로 대체.
- Repository 테스트
- 데이터 계층의 CRUD 동작과 쿼리 검증.
- Controller 테스트
- API 레이어의 입력/출력 검증.
- 예외 처리 및 응답 상태 코드 확인.
번외
처음에는 REST API의 기능정의부분을 보고 TDD를 적용하였을 때는 다음과 같이 개발을 진행하였다.
- 사용자의 input, output을 볼 수 있는 controller test부터 작성
- controller 작성, reponse dto 작성, service interface 수준에서 작성
- service test 작성
- service 구현, entity를 rough하게 작성, repository 작성
- repository test 작성
- repository 작성, entity 작성
위의 방식대로 해보니 도메인 로직을 중심으로 작은 단위의 테스트를 작성하는 것이 어려웠다..
가장 핵심인 도메인 로직괴 비즈니스로직을 먼저 만드는 것이 중요하다는 관점에 완전히 어긋난 것이다.
TDD에서 가장 중요한 점은 도메인 로직을 중심으로 작은 단위의 테스트를 작성하고, 이를 기반으로 실제 구현 코드를 작성하는 것이다.
따라서 “도메인 → Service → Repository/Controller” 순서로 개발을 진행하는 것이 가장 TDD스러운 방식인것을 느끼게 되었다.
Controller부터 만드는 방식은 TDD의 핵심인 비즈니스 로직 검증을 소홀히 할 가능성이 높아 domain 먼저 개발을 진행해야한다.
결론
TDD는 코드 품질과 개발 생산성을 높이는 강력한 도구지만, 익숙해지기까지는 시간과 노력이 필요하다.
작은 단위부터 점진적으로 도입하고, 반복적인 연습을 통해 TDD의 이점을 극대화할 수 있다.
TDD에서 가장 중요한 점은 도메인 로직을 중심으로 작은 단위의 테스트를 작성하고, 이를 기반으로 실제 구현 코드를 작성하는 것입니다. 따라서 “도메인 → Service → Repository → Controller” 순서로 개발을 진행하는 것이 가장 TDD스러운 방식입니다. Controller부터 만드는 방식은 TDD의 핵심인 비즈니스 로직 검증을 소홀히 할 가능성이 높으므로 추천되지 않습니다.