프로그램을 작성하기 전에 테스트를 먼저 진행하는 방식을 말한다. 코드를 검증하는 테스트 코드를 만든 다음, 실제 작성해야 하는 프로그램에 코드를 작성한다.
TDD
는 XP
에서 등장하는 여러 가지 실천 방법 중 하나로, 테스트 주도 개발과 동일한 의미를 갖는다. XP
는 2000년대 초반에 급부상한 애자일 소프트웨어 개발론
의 하나로 단순성, 상호소통, 피드백, 용기 등의 원칙에 기반해 '고객에게 최고의 가치를 최대한 빨리 전달하는 것'을 목표로 삼으며, 애자일 개발론
에는 TDD, 짝 프로그래밍, 일일빌드, 지속적인 통합, 단위 테스트 등 다양한 실천 방법을 제시하고 있다.
TDD를 이용한 개발은 크게 질문
➡️ 응답
➡️ 정제
라는 세 단계가 반복적으로 이루어진다.
흔히 단위 테스트 프레임워크를 사용한 테스트 코드 작성이 이뤄진다고 생각하면 된다.
💡 TDD에서는 테스트 자동화를 통해서 개발이 시작된 시점부터 완료될 때까지 가능한 한 빠른 시점 내에 그리고 자주 실패를 경험하도록 유도하고 있다. TDD는 실패를 통해 배움을 늘려가는 기법이다. 그래서 빨리 실패하면 실패할수록 좀 더 성공에 가까워진다.
은행 계좌 클래스를 TDD 방식으로 제작해볼 것이다.
요구사항에는 다음과 같은 것이 있을 것이다.
질문 단계에서는 메모장이나 마인드맵 등을 활용하면 도움이 된다. 테스트 케이스를 작성하는 방식은 두 가지 정도가 있다.
💡 TDD에서는 하나의 테스트 케이스가 하나의 기능을 테스트하도록 만드는 것이 기본 원칙이다.
먼저 계좌 생성에 대한 테스트 케이스 시나리오는 다음과 같다.
계좌를 생성한다 ➡️ 계좌가 정상적으로 생성됐는지 확인한다.
class AccountTest {
@Test
@DisplayName("계좌 생성")
void testAccount() {
Account account = new Account();
}
}
당연히 Account 클래스를 만든 적이 없으니 오류가 발생한다. 일단은 넘어간다. 테스트 시나리오를 코드로 기술하는 작업을 마치고, 그 다음 해결한다.
계좌가 정상적으로 생성됐는지 확인해야 한다.
class AccountTest {
@Test
@DisplayName("계좌 생성")
void testAccount() {
Account account = new Account();
if (account == null) {
throw new Exception("계좌생성 실패");
}
}
}
여전히 실패한다. 지금 만들어진 코드는 Account 클래스의 생성자 메서드 Account()에 대한 테스트 코드에 해당한다. 즉, 만들고자 하는 메서드의 예상 동작을 시나리오에 기반하여 코드를 먼저 만들어놓은 모습이다.
실패한 테스트를 성공해보자. Account 클래스를 만들고 예외를 try ~ catch 구문으로 감싸보기로 했다.
class AccountTest {
@Test
@DisplayName("계좌 생성")
public void testAccount() throws Exception {
Account account = new Account();
if (account == null) {
try {
throw new Exception("계좌생성 실패");
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}
테스트가 성공했다!
리팩토링: 정상적으로 동작하는 코드를 수정해서 좀 더 이해하기 쉽고, 변경하기 용이한 구조로 소스코드를 개선하는 작업을 말한다.
이렇게 질문
➡️ 응답
➡️ 정제
라는 단계를 반복하면 된다.
테스트 시나리오를 다시 작성해보자.
잔고 조회를 위한 테스트 코드를 작성하자.
import static org.junit.jupiter.api.Assertions.fail;
...
@Test
@DisplayName("잔고 조회")
public void testGetBalance() throws Exception {
Account account = new Account(10000);
if (account.getBalance() != 10000) {
fail();
}
}
fail()은 Junit에서 제공하는 메서드인데, 호출되면 해당 테스트 케이스는 무조건 실패한다. Account 클래스도 수정한다.
public class Account {
public Account(int i) {
}
public int getBalance() {
return 0;
}
}
수정하니 testAccount 클래스에서 에러가 발생한다. Account(int i)라는 생성자가 생겼기 때문이다. 이 경우 Java에서는 기본 생성자를 명시적으로 만들어주지 않으면 Account()를 호출할 수 없다.
여기서 업무적으로 생각할 필요도 있다. 생성자를 만들면 '초기 예치금액 없이 계좌를 만들 수 있다.'라는 의미이다. 일단 초기 입금액이 없다면 계좌를 생성할 수 없게끔 한다.
@Test
@DisplayName("계좌 생성")
public void testAccount() throws Exception {
Account account = new Account(10000);
if (account == null) {
try {
throw new Exception("계좌생성 실패");
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
시스템에 대한 질문이 완성됐다.
지금 우리가 작성한 코드에서 계좌에 10000원이 없으면 테스트는 실패한다. 그럼 10000을 돌려주도록 하드코딩으로 작성하면 어떻게 될까?
public class Account {
public Account(int i) {
}
public int getBalance() {
return 10000;
}
}
테스트를 통과했다! 하지만 이렇게 해도 되는걸까..?
- 테스트 케이스를 엉성하게 만들면 테스트 자체를 신뢰할 수 없게 된다.
- 테스트 케이스를 통한 제품 코드 구현을 하드코딩으로 시작하는 것도 괜찮은 출발점이다.
신뢰할 수 없는 테스트 케이스이지만 우선 넘어가자. 정제 단계에서 리팩토링을 통해 수정해보면 된다. 그럼 신뢰할 수 있는 테스트는 어떻게 만들 수 있을까?
테스트 케이스를 보강해보자.
@Test
@DisplayName("잔고 조회")
public void testGetBalance() throws Exception {
Account account = new Account(10000);
if (account.getBalance() != 10000) {
fail();
}
account = new Account(1000);
if (account.getBalance() != 1000) {
fail();
}
account = new Account(0);
if (account.getBalance() != 0) {
fail();
}
}
테스트가 실패했다! 로직에 문제가 있음을 알 수 있다.
❗️ TDD에서는 실패를 두려워하면 안된다. 빨리 실패하면 실패할수록 좀 더 성공에 가까워진다는걸 기억하자.
Account 클래스를 수정하자.
public class Account {
private int balance;
public Account(int i) {
}
public int getBalance() {
return this.balance;
}
}
해당 부분에서 오류가 발생한다. getBalance()가 얼마를 돌려주는지 확인해보자.
@Test
@DisplayName("잔고 조회")
public void testGetBalance() throws Exception {
Account account = new Account(10000);
if (account.getBalance() != 10000) {
fail("getBalance() => " + account.getBalance() );
}
0이 출력된다. Account 클래스에서 계좌를 생성할 때 입력받는 금액을 내부 필드에 저장하는 로직을 추가한다.
public class Account {
private int balance;
public Account(int i) {
this.balance = i;
}
public int getBalance() {
return this.balance;
}
}
테스트를 통과했다!
하지만 조금 더 리팩토링 해보자.
assertEquals(예상값, 실제값)
assertEquals("설명값", 예상값, 실제값)
Junit 에서 제공하는 메서드이다. assertEquals()를 활용하면 코드를 이렇게 수정할 수 있다.
import static org.junit.jupiter.api.Assertions.assertEquals;
...
@Test
@DisplayName("잔고 조회")
public void testGetBalance() throws Exception {
Account account = new Account(10000);
assertEquals(10000, account.getBalance());
account = new Account(1000);
assertEquals(1000, account.getBalance());
account = new Account(0);
assertEquals(0, account.getBalance());
}
테스트 시나리오를 좀 더 구체화해보자.
테스트 코드를 작성해보자.
@Test
@DisplayName("입금")
public void testDeposit() throws Exception {
Account account = new Account(10000);
account.deposit(1000);
assertEquals(11000, account.getBalance());
}
@Test
@DisplayName("출금")
public void testWithdraw() throws Exception {
Account account = new Account(10000);
account.withdraw(1000);
assertEquals(9000, account.getBalance());
}
// Account.java
public void deposit(int i) {
}
public void withdraw(int i) {
}
두 개의 테스트 케이스 모두 실패했다!
❗️ 일반적으로 TDD에서는 실패하는 테스트를 여러 개 만들어놓고 진행하는걸 권장하지 않는다.
// Account.java
public void deposit(int i) {
this.balance += i;
}
public void withdraw(int i) {
this.balance -= i;
}
account를 필드로 옮기고, setup() 을 추출해 중복을 없애자.
@SpringBootTest
class AccountTest {
private Account account;
@Test
@DisplayName("계좌 생성")
public void testAccount() throws Exception {
setup();
if (account == null) {
try {
throw new Exception("계좌생성 실패");
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
@Test
@DisplayName("잔고 조회")
public void testGetBalance() throws Exception {
setup();
assertEquals(10000, account.getBalance());
account = new Account(1000);
assertEquals(1000, account.getBalance());
account = new Account(0);
assertEquals(0, account.getBalance());
}
public void setup() {
account = new Account(10000);
}
@Test
@DisplayName("입금")
public void testDeposit() throws Exception {
setup();
account.deposit(1000);
assertEquals(11000, account.getBalance());
}
@Test
@DisplayName("출금")
public void testWithdraw() throws Exception {
setup();
account.withdraw(1000);
assertEquals(9000, account.getBalance());
}
}