TDD란 Test Driven Development의 약자로 테스트 주도 개발이라고 합니다. 반복 테스트를 이용한 소프트웨어 방법론으로 작은 단위의 테스트 케이스를 작성하고 이를 통과하는 코드를 추가하는 단계를 반복하여 구현합니다.
TDD에 대해 알아보기 전에 전통적인 개발 방식은 코드를 어떤 순서로 작성하는지 TDD와 비교해 무엇이 다른지 알아봅시다.
전통적인 개발 방식(Traditional Development)
전통적인 개발 방식은 위와 같이 1. 애플리케이션 구상 → 2. 코드 작성 → 3. 테스트 코드 작성 순서로 애플리케이션을 개발합니다. 이미 코드를 먼저 작성하였기 때문에 여러 가지 많은 이유로 테스트 코드 작성을 하지 않거나 아예 작성하지 않는 경우도 많이 볼 수 있습니다.
이러한 흐름을 기억해 두고 TDD는 무엇이 다른지 비교해 보겠습니다.
테스트 주도 개발(TDD, Test Driven Development)
TDD는 위와 같이 크게 3단계로 구분합니다. 각 단계에 대해 좀 더 자세히 설명하자면
1.테스트 코드 작성 - 실패하는 테스트를 작성하는 것부터 시작합니다.
2.실제 코드 작성 - 1번에서 실패한 테스트를 성공하는 코드를 작성합니다.
3.리팩터링 - 작성한 테스트코드 및 실제 코드를 리팩터링 합니다.
그저 위의 3가지 순서를 계속 반복하는 것이 TDD입니다.
그렇다면 TDD의 장점은 뭐고, 왜 하는 걸까요?
실무에서 직접 TDD를 해 본 경험으로 가장 좋았던 점은 테스트 코드가 있으면 변경한 코드가 결과에 어떤 영향을 끼치는지 쉽게 확인할 수 있기 때문에 운영 중인 서비스 코드의 리팩터링이 자유로운 점이었습니다. 그 외에도 명확하게 내가 작성해야 할 코드의 우선순위를 정하기 쉽다는 점 다양한 테스트 케이스를 통해 API 개발 시 코드의 허점을 빨리 파악할 수 있다는 점 등 많은 장점이 있습니다.
TDD의 흐름을 기억하기 위해 간단한 FizzBuzz를 Java & Junit5를 활용해 구현해 보겠습니다.
FizzBuzz 문제를 모르시는 분들을 위해 간단하게 설명 드리면 1부터 100까지 반복하면서 숫자를 출력할 때, 3의 배수이면 "Fizz", 5의 배수이면 "Buzz"를 출력하고 3과 5의 공배수면 "FizzBuzz"를 출력하는 프로그램을 작성하는 문제입니다.
먼저 FizzBuzzTest 클래스를 src/main 경로가 아닌 src/test 경로에 만들어 주고
Fizz를 출력하는 테스트코드를 작성해 봅시다.
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class FizzBuzzTest {
// 3의 배수면 Fizz
// 5의 배수면 Buzz
// 3와 5의 배수면 FizzBuzz
// 3와 5의 배수가 아니면 그대로 출력
@DisplayName("Fizz가 출력되어야 한다.")
@Test
@Order(1)
void testFizz() {
String result = "Fizz";
assertEquals(result, FizzBuzz.fizzBuzz(3), "Fizz 가 출력되어야 한다.");
}
}
위의 코드를 보면 FizzBuzz 클래스의 fizzBuzz() 메서드를 호출하여 예상한 결과와 같은지 확인하는 테스트 코드를 작성했습니다. @Order, @TestMethodOrder 에 대해 궁금하신 분은 여기를 참고해 주세요.
FizzBuzz 클래스는 아직 만들지 않았기 때문에 컴파일 에러가 발생합니다. FizzBuzz 클래스는 메인 코드기 때문에 아래와 같이 test 디렉터리가 아닌 src/main/java 경로에 만들어 줘야 합니다.
FizzBuzz 클래스를 만들었다면 이번엔 fizzBuzz() 메서드도 컴파일 에러가 나기 때문에 만들어 줍니다.
public class FizzBuzz {
public static String fizzBuzz(int i) {
return null;
}
}
위와같이 만들었다면 이제 테스트코드의 모든 컴파일 에러를 해결했으니 바로 테스트코드를 실행해보면, 당연하게도 아래와 같은 결과가 출력됩니다.
다음으로 테스트를 성공 시키기 위한 코드를 작성합니다.
public class FizzBuzz {
public static String fizzBuzz(int i) {
if (i % 3 == 0) {
return "Fizz";
}
return null;
}
}
다시 테스트코드를 실행해 결과를 확인해봅니다.
점점 감이 오시나요?? 이러한 과정을 계속해서 반복하며 개발하는 것이 테스트 주도개발입니다. 계속해서 다음 테스트를 작성해 보겠습니다.
Fizz를 만들었으니 이번에는 Buzz를 출력하는 테스트 코드를 작성해 봅시다.
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class FizzBuzzTest {
// 3의 배수면 Fizz
// 5의 배수면 Buzz
// 3와 5의 배수면 FizzBuzz
// 3와 5의 배수가 아니면 그대로 출력
@DisplayName("Fizz가 출력되어야 한다.")
@Test
@Order(1)
void testFizz() {
String result = "Fizz";
assertEquals(result, FizzBuzz.fizzBuzz(3), "Fizz 가 출력되어야 한다.");
}
@DisplayName("Buzz가 출력되어야 한다.")
@Test
@Order(2)
void testBuzz() {
String result = "Buzz";
assertEquals(result, FizzBuzz.fizzBuzz(5), "Buzz 가 출력되어야 한다.");
}
}
testBuzz() 를 만들었지만 이번엔 컴파일 에러가 없으니 바로 테스트코드를 실행해 보고 아래의 결과를 확인합니다.
당연하겠지만 실패했습니다. 이제 이걸 성공하도록 코드를 수정합니다.
public class FizzBuzz {
public static String fizzBuzz(int i) {
if (i % 3 == 0) {
return "Fizz";
}
else if(i % 5 == 0) {
return "Buzz";
}
return null;
}
}
다시 테스트 코드를 실행하면 아래와 같이 성공한 것을 확인할 수 있습니다.
바로 다음 단계로 넘어가 FizzBuzz를 출력하는 테스트와 3과 5의 배수가 아닌 경우를 만들어 봅시다. 이건 위의 두 예제와 같은 작업이기 때문에 별다른 설명 없이 코드와 전체 결과를 바로 확인해봅시다.
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class FizzBuzzTest {
// 3의 배수면 Fizz
// 5의 배수면 Buzz
// 3와 5의 배수면 FizzBuzz
// 3와 5의 배수가 아니면 그대로 출력
...생략
@DisplayName("FizzBuzz가 출력되어야 한다.")
@Test
@Order(3)
void testFizzBuzz() {
String result = "FizzBuzz";
assertEquals(result, FizzBuzz.fizzBuzz(15), "FizzBuzz 가 출력되어야 한다.");
}
@DisplayName("FizzBuzz가 아니라 숫자가 출력되어야 한다.")
@Test
@Order(4)
void testNotFizzBuzz() {
String result = "1";
assertEquals(result, FizzBuzz.fizzBuzz(1), "FizzBuzz가 아니라 숫자가 출력되어야 한다.");
}
}
public class FizzBuzz {
public static String fizzBuzz(int i) {
if (i % 3 == 0 && i % 5 == 0) {
return "FizzBuzz";
}
else if (i % 3 == 0) {
return "Fizz";
}
else if(i % 5 == 0) {
return "Buzz";
}
else {
return Integer.toString(i);
}
}
}
여기까지 오셨다면 이제 테스트주도 개발의 흐름에 대해 감을 잡으셨을 것 같습니다.
그런데 시간이 지나 코드를 보니 개선해야 할 점들이 눈에 들어옵니다. 그런데 코드가 만약 실제 서비스 배포된 코드라면 이미 문제없이 잘 돌아가니까 개선하지 않고 싶어질 수 있습니다. 하지만 위와같이 잘 작성된 테스트코드가 있다면 리팩터링을 마음 편하게 할 수 있습니다!
아래와같이 리팩터링을 진행하고 전체 테스트코드를 다시 실행해 보겠습니다.
public class FizzBuzz {
public static String fizzBuzz(int i) {
StringBuilder result = new StringBuilder();
if (i % 3 == 0) {
result.append("Fizz");
}
if (i % 5 == 0) {
result.append("Buzz");
}
if (result.isEmpty()){
result.append(i);
}
return result.toString();
}
}
변함없이 잘 작동하는 것을 확인할 수 있습니다.