목차
TDD란?
Test-Driven Development
혹은 Test First Development + Refactoring
흔히들 테스트 주도 개발을 선후 관계없이 테스트만 작성하면 TDD로 알고 있는 사람과, 단위테스트는 TDD다 정도로만 알고 있는 사람이 많다. 책에서는 다음과 같이 TDD를 설명하고 있다.
TDD란 프로그래밍 의사결정과 피드백 사이의 간극을 의식하고 이를 제어하는 기술
TDD는 테스트 기술이 아닌 분석기술이자 설계 기술
이를 내 지식선에서 좀 더 쉽게 정리해보자면, 기능 개발에 앞서 기능을 가정하고, 그 가정에 대한 테스트를 작성하며 테스트가 통과하도록 만들고, 이를 개선하는 짧은 간격으로 반복하는 것을 TDD라고 생각한다.
그럼 이런 방식이 어떠한 장점이 있을까? 그냥 바로 기능부터 구현하면 생산성이 더 높아지지 않을까 생각할 수 있다. 하지만, OOP 패러다임을 기억하고 있다면, 객체간에는 메세지만을 주고받도록 설계하고, 각각의 객체간의 결합도를 낮추고 응집도를 높혀야 한다는 것을 기억할 것이다.
테스트 코드의 선행 작성은 이런 부분에서 하나의 기능에 대해 의존성을 파악하기가 쉽다.
특정 도메인의 기능을 테스트하려 할 때 이 도메인이 의존하고 있는 다른 객체가 많을 수록, 필요로 하는 매개변수 로직이 많아질수록 테스트는 힘들어질 것이다. 위에서 말했듯이 TDD의 핵심은 짦은 간격의 테스트 사이클을 반복하는 것인데, 이렇게 테스트 작성의 간격을 넓힉게 되는 코드를 만난다면 해당 코드가 어떠한 문제가 있다는 것을 알 수 있다. 그리고 이런 지점을 빨리 파악 할 수록, 완성도있는 코드로 도달하는 시간은 짧아질 수 있다.
TDD 사이클
1.
실패하는 테스트를 구현하자.
2.
테스트가 성공하도록 프로덕션 코드를 구현하자.
3.
프로덕션 코드와 테스트 코드를 리팩토링하자.
테스트 도입하기
그럼 이런 TDD를 어떻게 도입하고 테스트 코드를 작성할 것인가에 대해 고민해볼 수 있다.
1. 테스트 한다는 것은 무엇을 뜻하는가?
테스트는 승인 또는 거부에 도달하는 과정을 의미한다. 테스트는 명사적의미와 동사적 의미로 구분할 수 있다.
•
명사적 의미의 테스트: 자동으로 실행되는 테스트 과정
•
동사적 의미의 테스트: 버튼을 눌러보거나 화면에 나오는 결과를 주시하는 테스트 과정
이를 좀 더 실무에 풀어서 설명하자면, QA가 수행하는 기능 테스트는 동사로서의 테스트라 할 수 있고, 개발자가 xUnit 과 같은 프레임워크를 이용해 자동화된 테스트를 작성하는 것을 명사적인 의미의 테스트라 할 수 있다.
2. 테스트는 언제 해야 할까?
테스트 대상이 되는 코드를 작성하기 직전에 테스트를 작성하자.
양성 피드백 고리(positive feedback loop)
테스트는 되도록 빨리하는게 좋다. 시간이 지날수록 테스트를 도입하는건 쉽지 않은일이 될 것이고, 비용은 거침없이 커질 것이다. 위 그림은 제랄드 와인버그가 저술한 스타일의 영향도(influence diagram)다. 다음과 같은 성질을 가진다.
•
첫 번째 노드가 높아지면 두 번째 노드도 같이 높아진다.
•
동그라미가 그려진 화살표는 첫 번째 노드가 높아지면 반대로 두 번째 노드가 낮아진다.
위의 그림은 양성 피드백 고리(positive feedback loop)로 스트레스가 높아질수록 테스트를 하는 빈도가 더 줄게되고, 테스트가 줄어들수록 에러는 많아지고 스트레스는 더 커지게 된다.
이를 해결하기 위해선 다음과 같은 부분을 고려해볼 수 있다.
•
새로운 요소를 도입하기
•
기존 요소와 바꿔치기
•
화살표를 바꾸기
위와 같은 경우에는 테스트를 자동화된 테스트로 치환하면된다. 자동화된 테스트가 있다면 스트레스가 커질때 쯤 테스트를 실행하고, 이런 자동화된 테스트가 많을 수록 에러에 대한 두려움이 지루함으로 바뀔 수 있다. 당연하게도 에러로 인한 두려움보다는 지겨움이 낫다. 즉, 개발자의 정신건강을 위해서라도 자동화된 테스트 작성은 중요하다.
3. 테스트할 로직은 어떻게 고를까?
로직은 격리되있어야 한다.
결론부터 말하자면, 테스트를 실행하면 테스트간에는 아무런 영향을 주어서는 안된다.
그리고 테스트에 너무 오랜 시간이 걸리게 되면 테스트를 실행하는데 부담을 가지게 되고, 부담이 커질수록 테스트 실행 빈도가 낮아지게 될 것이다. 그렇기에, 테스트할 로직은 다른 테스트와 독립적이어야 하고, 격리되어서 빠른 속도로 실행이 가능해야 한다.
단언 우선
테스트 작성시 단언(assert)을 제일 먼저 작성하고 시작하자.
•
시스템을 개발할 때 완료된 시스템이 어떨거라고 알려주는 이야기(user story)부터 작성한다.
•
특정 기능을 개발할 때 기능이 완료되면 통과할 수 있는 테스트부터 작성한다.
•
테스트를 개발할 때는 완료되면 통과해야 할 단언부터 작성한다.
이처럼 단언부터 작성을 하면 작업을 단순하게 만드는 효과를 볼 수 있다.
구현에 대해 고민하지 않고 테스트만 작성해도 몇몇 문제들을 한 번에 해결할 수 있다.
4. 테스트할 데이터는 어떻게 고를 것인가?
읽기 쉽고 따라가기 좋을만한 데이터를 사용하라.
•
데이터 값을 산발하기 위해 데이터 값을 산발하지 마라.
•
데이터 간에 차이가 있다면, 의미가 있는 차이어야 한다.
•
1과 2 사이에 어떠한 개념적 차이도 없다면 1을 사용하라.
•
여러 의미를 담는 동일한 상수를 사용하지 말아라.
명백한 데이터
가독성 좋은 데이터는 명백한 데이터라 할 수 있는데, 데이터의 의도를 어떻게 표현해야 할까?
테스트 자체에 예상되는 값과 실제 값을 포함하고 이 둘 사이의관계를 드러내기 위해 노력해야 한다.
다음 코드는 은행의 환전 거래 코드이다.
Bank bank = new Bank();
bank.addRate("USD", "GBP", STANDARD_RATE);
bank.commission(STANDARD_COMMISSION);
Money result = bank.convert(new Note(100, "USD"), "GBP");
assertEquals(new Note(49.25, "GBP"), result);
Java
복사
여기서 거래 수수료는 STANDARD_COMMISSION(1.5%)가 붙고 USD → BGP 교환 환율은 2:1이라 하면 $100을 환전하면 50GBP - 1.5% = 49.25GBP가 되어야 한다는 코드인데, 수수료나 환율같은 경우 더 명백한 데이터로 매직넘버를 쓸 수도 있다.
Bank bank = new Bank();
bank.addRate("USD", "GBP", 2);
bank.commission(0.015);
Money result = bank.convert(new Note(100, "USD"), "GBP");
assertEquals(new Note(100 / 2 * (1 - 0.015), "GBP"), result);
Java
복사
이렇게 작성을 하게 되면 상수를 찾아 코드를 움직일 필요도 없고 결국 내부적으로 나눗셈과 곱셈을 수행할 프로그램을 만들 것이라는것을 알게 된다. 이런 명백한 데이터는 코드에 매직넘버를 쓰지 말라는 내용에 대한 예외적인 내용이라 할 수 있다.
물론, 이미 정의된 기호 상수가 있다면, 굳이 제거하지말고 사용하도록 하자.
피보나치 수열을 TDD로 작성해보기
위에서 작성한 TDD 사이클을 이용해서 피보나치 수열을 TDD로 작성해보자.
1. fib(0) == 0 임을 단언한다.
public void testFibonacci() {
assertEquals(0, fib(0));
}
int fib(int n) {
return 0;
}
Java
복사
빠른 초록불을 위해 구현은 상수를 반환한다.
2. fib(1) == 1 임을 단언한다.
public void testFibonacci() {
assertEquals(0, fib(0));
assertEquals(1, fib(1));
}
int fib(int n) {
if(n == 0) return 0;
return 1;
}
Java
복사
3. fib(2) == 1 임을 단언한다.
public void testFibonacci() {
assertEquals(0, fib(0));
assertEquals(1, fib(1));
assertEquals(1, fib(2));
}
int fib(int n) {
if(n == 0) return 0;
return 1;
}
Java
복사
이쯤에서 중복되는 코드를 제거하는 리팩토링을 해보자.
4. 1차 리팩토링
단언문(assertEquals)이 중복되고 있는데, 이를 변경해보자.
public void testFibonacci() {
int cases[][] = {{0, 0}, {1, 1}, {1, 2}}
for(int[] case : cases[]) {
assertEquals(case[1], fib[case[0]));
}
}
// JUnit 5 버전
@ParameterizedTest
@CsvSource(value={"0,0", "1,1", "1,2"})
public void testFibonacci(int result, int source) {
assertEquals(result, fib(source));
}
int fib(int n) {
if(n == 0) return 0;
return 1;
}
Java
복사
5. 2차 리팩토링
이제 테스트 케이스는 중복 제거도 되었다.
하지만, fib() 메서드는 아직 3이상의 값을 대응할 수 없고, 매 번 값에 대한 if문을 넣을 순 없기에 일반화를 해 줄 차례다. fib(3)는 2이라는 결과를 반환해야하기에 이전 방식대로라면 다음과 같이 작성할 수 있다.
int fib(int n) {
if(n == 0) return 0;
if(n <= 2) return 1;
return 2;
}
Java
복사
여기서 return 2 는 사실 return 1 + 1과 같다고 볼 수 있는데 이는 이렇게 볼 수 있다.
int fib(int n) {
if(n == 0) return 0;
if(n <= 2) return 1;
return fib(n-1) + 1;
}
Java
복사
그리고 두 번째 1도 fib(n-2)라고 볼 수 있다.
int fib(int n) {
if(n == 0) return 0;
if(n <= 2) return 1;
return fib(n-1) + fib(n-2);
}
Java
복사
이제 이 코드에서 if(n ≤ 2)return 1; 부분은 fib(2)에서도 fib(1) - fib(0)으로 동작하기 때문에 다음과 같이 변경해주면 된다.
int fib(int n) {
if(n == 0) return 0;
if(n == 1) return 1;
return fib(n-1) + fib(n-2);
}
Java
복사
6. 완성된 피보나치 코드와 테스트 케이스
public void testFibonacci() {
int cases[][] = {{0, 0}, {1, 1}, {1, 2}}
for(int[] case : cases[]) {
assertEquals(case[1], fib[case[0]));
}
}
// JUnit 5 버전
@ParameterizedTest
@CsvSource(value={"0,0", "1,1", "1,2"})
public void testFibonacci(int result, int source) {
assertEquals(result, fib(source));
}
int fib(int n) {
if(n == 0) return 0;
if(n == 1) return 1;
return fib(n-1) + fib(n-2);
}
Java
복사
이제 테스트로부터 유도된 완성된 피보나치 함수를 만들게 되었다.
피보나치 수열이라는 기능과 점화식이 몹시 간단하다는 점을 논외로 치더라도 실패하는 테스트와 그 테스트를 통과하기 위한 수정, 그리고 리팩토링까지 짧은 텀으로 사이클을 돌아서 금새 피보나치 함수를 완성해냈다. 또한 해당 함수는 외부에 영향을 주지도 않고 격리된 함수로써 테스트에서도 외부를 신경쓰지 않고 테스트를 진행할 수 있다.