[TDD] TDD 맛보기
TDD: Red → Green → Refactor
목표
요구사항
↓
실패하는 테스트 작성
↓
RED
↓
최소 구현
↓
GREEN
↓
코드 정리
↓
REFACTOR
↓
테스트가 계속 통과하는지 확인
RED : 내 테스트가 실제로 아직 구현되지 않은 요구사항 때문에 실패하고 있다는 뜻
1. TDD에서 왜 일부러 실패하는 테스트부터 만드나?
보통 개발을 하면 이런 순서를 떠올리기 쉽다.
Product 클래스 작성
↓
decreaseStock() 구현
↓
잘 되는지 테스트 작성
TDD에서는 순서를 뒤집는다.
"재고를 하나 차감하면 9가 되어야 해"
↓
그 요구사항을 테스트로 먼저 작성
↓
당연히 아직 구현이 없으므로 실패
↓
그 테스트를 통과할 만큼만 구현
2. RED 테스트 작성
test 폴더에 `ProductTest.java`를 생성한다.
이 파일의 역할은 `Product`가 가져야 할 행동을 구현보다 먼저 정의하는 것이다.
아직 `Product`를 만들지 않은 상태라고 생각하자. 테스트에서 먼저 이렇게 요구한다.
Given 재고 10개인 상품
When 재고 1개 차감
Then 재고 9개
`ProductTest.java`
package com.bblackbean.jpa.stock;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ProductTest {
@Test
void 재고를_한_개_차감하면_재고가_1_감소한다() {
// Given
Product product = new Product(10);
// When
product.decreaseStock();
// Then
assertThat(product.getStock()).isEqualTo(9);
}
}
`@Test` : JUnit 5에게 "이 메서드는 일반 메서드가 아닌 테스트 메서드"라고 알린다. JUnit이 이 메서드를 찾아 실행한다.
Given
Product product = new Product(10);
시작 상태로, `Product`에서 `stock = 10`인 객체를 만들겠다는 뜻이다.
When
product.decreaseStock();
재고를 1 차감시키는 메서드를 실행한다. (아직 실제 메서드는 구현하지 않았기 때문에 에러로 잡힌다.)
Then
assertThat(product.getStock()).isEqualTo(9);
AssertJ가 담당한다. `product.getStock()`의 실제 값이 9와 같은지 확인한다.
3. `Product.java`
`Product`는 상품의 상태와 상품 자신의 규칙을 가지는 객체이다.
package com.bblackbean.jpa.stock;
public class Product {
private int stock;
public Product(int stock) {
this.stock = stock;
}
public void decreaseStock() {
// RED 단계 확인을 위해 아직 아무 것도 하지 않는다.
}
public int getStock() {
return stock;
}
}
테스트를 실행하면, 시작 상태는 `stock`이 10이지만 When에 해당하는 `product.decreaseStock();` 메서드 안에서 아무 것도 하지 않기 때문에 `stock`은 그대로 10이다.
Expected :9
Actual :10
그래서 테스트는 9를 기대하지만 실제 값은 10이어서 테스트가 실패한다.
4. RED 테스트는 왜 중요할까
어차피 실패할 게 뻔한데 왜 굳이 실행할까?
만약 테스트를 처음부터 실행했는데 통과해버린다면, 오히려 이상하다.
아직 기능을 구현하지도 않았는데 테스트가 통과한다면,
1. 테스트를 잘못 작성했거나
2. 요구사항을 실제로 검증하지 않고 있거나
3. 기존 코드가 우연히 조건을 만족하고 있는 것일 수도 있다.
그래서 TDD에서는 "내가 원하는 이유로 테스트가 실패하는가?"도 확인한다.
5. GREEN : 테스트가 통과할 만큼만 구현
이제 테스트를 통과할 수 있게끔 구현한다.
10
↓
decreaseStock()
↓
9
`Product.java`를 수정한다.
package com.bblackbean.jpa.stock;
public class Product {
private int stock;
public Product(int stock) {
this.stock = stock;
}
public void decreaseStock() {
// [변경] 재고를 한 개 차감한다.
stock--;
}
public int getStock() {
return stock;
}
}
다시 테스트를 실행하면, 아래와 같이 흘러간다.
Given
stock = 10
↓
When
stock--
↓
stock = 9
↓
Then
9 == 9
↓
PASS
6. REFACTOR : 그래서 지금 뭘 고쳐야 하는가?
Refactor는 외부 행동은 그대로 유지하면서 코드의 구조를 개선하는 것인데, Refactor 단계라고 반드시 코드를 바꿔야 하는 것은 아니다.
메서드 이름은 행동을 잘 표현하는가? → decreaseStock(): 괜찮음
불필요한 중복이 있는가? → 없음
현재 요구사항에 비해 구조가 과한가? → 아님
그러면? → 건드리지 않는다.
7. Spring/JPA와는 어떻게 연결되는가?
앞으로 `Product`가 JPA Entity가 될 수도 있다.
@Entity
public class Product {
...
}
하지만 핵심 비즈니스 규칙인 `product.decreaseStock();` 자체는 여전히 Java 객체의 행동이다.
그러므로 무엇을 확인하는지에 따라 테스트 범위를 선택할 수 있다.
Product의 재고 규칙
→ 순수 단위 테스트
Repository가 DB에 잘 저장하는지
→ JPA 통합 테스트
@Transactional이 제대로 rollback하는지
→ Spring 통합 테스트
8. 프레임워크가 해주는 것과 내가 작성한 것
내가 작성한 것
Product 생성
decreaseStock() 호출
기대 결과 9 결정
stock-- 구현
JUnit 5가 해주는 것
@Test 발견
↓
테스트 메서드 실행
↓
성공 / 실패 결과 표시
AssertJ가 해주는 것
실제값 = product.getStock()
기대값 = 9
둘을 비교
Spring Boot가 해준 것은 아무 것도 없기 때문에, 이 테스트에서는 Spring Context 자체가 뜨지 않는다.
9. 연습문제
재고가 0개인 상품은 재고를 차감할 수 없다.
1. 차감 시도가 실패한다.
2. 실패 후에도 stock은 0이다.
@Test
void 재고가_없으면_차감할_수_없다() {
// Given
Product product = new Product(0);
// When & Then
assertThatThrownBy(() -> product.decreaseStock()) // product.decreaseStock()을 실행했을 때 "예외가 던져지는가?" 를 검사
.isInstanceOf(IllegalStateException.class); // 발생한 예외가 IllegalStateException 타입인지 확인한다.
// 실패한 뒤에도 재고가 0으로 유지되는지 확인
assertThat(product.getStock()).isEqualTo(0);
}
`Product.java`
package com.bblackbean.jpa.stock;
public class Product {
private int stock;
public Product(int stock) {
this.stock = stock;
}
public void decreaseStock() {
// [변경] 재고가 없으면 차감을 진행하지 않고 실패시킨다.
if ( stock <= 0 ) {
throw new IllegalStateException("재고가 부족합니다.");
}
// 검증을 통과한 경우에만 재고를 차감한다.
stock--;
}
public int getStock() {
return stock;
}
}
실행 순서
기존 요구사항
"재고 0이면 차감할 수 없다."
↓
// 테스트 작성
assertThatThrownBy(...)
↓
// 실행
예외가 발생하지 않아서 RED
↓
// 구현
if (stock <= 0) {
throw ...
}
↓
// 다시 실행
GREEN
10. 정리
- TDD에서는 “어떻게 구현할까?”보다 먼저 “무엇이 되어야 할까?”를 테스트로 표현한다.
- Red는 원하는 이유로 테스트가 실패하는지 확인하고, Green은 그 테스트를 통과할 만큼만 구현하는 단계다.
- Refactor는 새로운 기능을 추가하는 단계가 아니라 테스트가 통과하는 상태에서 기존 코드의 구조를 개선하는 단계다.
- TDD에서는 테스트가 실패했다는 사실 자체보다, 내가 의도한 이유로 실패했는지를 확인하는 게 중요하다.
- GREEN에서는 테스트가 요구한 만큼만 구현하고, REFACTOR에서는 기능을 더하지 않고 구조만 개선한다.
- 현재 코드가 충분히 단순하다면 Refactor 단계에서 아무것도 바꾸지 않는 것도 올바른 선택이다.