테스트 코드는 아무렇게나 작성하는 것이 아닌, 어떠한 기준에 의한 순서를 두어 작성해야한다.
그렇지 않으면 테스트에 필요한 시간이 많아지거나 코드가 굉장히 지저분해지기 때문에 테스트를 처음부터 다시 시작해야하는 일까지 발생할 수 있다.
그럼 어떤 순서로 테스트 코드를 작성해야하는 걸까?
구현하기 쉬운 테스트 → 구현하기 어려운 테스트
예외적인 경우 → 정상적인 경우
이러한 경우를 우선적으로 테스트 해야한다고 한다.
과연 왜 그런걸까?
만약 초반부터 다양한 조합을 검사하는 복잡한 상황을 테스트로 추가하면 해당 테스트를 통과시키기 위해 한 번에 구현해야할 코드가 많아진다.
- 대문자 포함 규칙만 충족하는 경우
- 모든 규칙을 충족하는 경우
- 숫자를 포함하지 않고 나머지 규칙은 충족하는 경우
암호 강도 측정
기능을 구현한다고 할 때, 이 순서대로 TDD
를 진행해보자.
먼저 대문자 포함 규칙만 충족하는 경우를 테스트하기 위한 코드를 다음과 같이 작성할 것이다.
@Test
void meetsOnlyUpperCreteria_Then_Weak() {
PasswordStengthMeter meter = new PasswordStrengthMeter();
PasswordStength result = meter.meter("abcDef");
assertEquals(PasswordStrength.WEAK, result);
}
이 예제를 통과시키기 위한 구현 만큼은 아주 간단하다. 단순히 WEAK
를 리턴하면 된다.
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
return PasswordStrength.WEAK;
}
}
이제 모든 규칙을 충족하는 테스트를 하기 위한 코드를 추가할 차례이다.
@Test
void meetsAllCreteria_Then_Weak() {
PasswordStengthMeter meter = new PasswordStrengthMeter();
PasswordStength result = meter.meter("abcDef12");
assertEquals(PasswordStrength.STRONG, result);
}
이 테스트를 통과시키려면 어떻게 해야 할까? 가장 빨리 통과시킬 수 있는 방법은 abcDef12
가 입력되면 STRONG
을 리턴하는 것이다.
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if(s.equals("abcDef12")) return PasswordStrength.STRONG;
return PasswordStrength.WEAK;
}
}
때문에 이렇게 코드를 작성해주었다.
이제 테스트 예를 하나 더 추가해보자
@Test
void meetsAllCreteria_Then_Weak() {
PasswordStengthMeter meter = new PasswordStrengthMeter();
PasswordStength result1 = meter.meter("abcDef12");
assertEquals(PasswordStrength.STRONG, result1);
PasswordStength result2 = meter.meter("aZcDef12");
assertEquals(PasswordStrength.STRONG, result2);
}
예제 코드를 작성했다.
어떻게 해야 테스트를 통과시킬 수 있을까? 문자열이 abcDef12
나 aZcDef12
면 STRONG
을 리턴하고 나머지는 WEAK
로 구현하면 될까?
모든 조건을 만족하는 테스트를 그렇게 구현하고 다음 테스트로 넘어가게 되면, 이후에 실제 조건에 대한 검증 로직이 추가되었을 때가 돼서야 전체 조건이 충족했을 경우를 만족시킬 수 있다.
결론적으로 이 테스트 코드를 먼저 개발하게 되면 실질적으로 모든 조건에 대한 검증 로직을 작성해야한다는 뜻이다.
이렇게 되면 한 번에 구현해야할 코드가 너무 많아진다.
한 번에 많은 코드를 작성하다 보면 나도 모르게 버그를 만들고 나중에 버그를 잡기 위해 많은 시간을 허비하게 되며, 당연히 테스트 통과 시간도 길어진다.
그뿐만 아니라 코드 작성 시간이 길어지면 집중력도 하락되어 흐름이 자주 끊기게 된다.
중간에 화장실을 다녀오고 커피도 마시게 되는데 자리에 돌아오면 이전까지의 흐름을 이어가기 위한 시간이 필요해진다.
다양한 예외 상황은 복잡한 if-else
블록을 동반할 때가 많다. 때문에 예외 상황을 전혀 고려하지 않은 코드에 예외 상황을 반영하려면 코드의 구조를 뒤집거나 코드 중간에 예외 상황을 처리하기 위한 조건문을 중복해서 추가하는 일이 벌어진다.
이는 코드를 복잡하게 만들어 버그 발생 가능성
을 증가시킨다.
초반에 예외 상황
을 먼저 테스트하면 이런 가능성이 줄어든다. 예외 상황에 따른 if-else
구조가 미리 만들어지기 때문에 많은 코드를 완성한 뒤에 예외 상황을 반영할 때보다 코드 구조가 덜 바뀐다.
TDD
를 하는 동안에도 예외 상황을 찾고 테스트에 반영하면, 예외 상황을 처리하지 않아 발생하는 버그도 줄여준다.
암호 등급 측정 예의 경우 암호 값이 없는 상황에 대한 테스트를 추가했다.
이 테스트를 추가하지 않으면 시스템 운영 중에 NPE
가 발생할 수 있다.
이에 대한 테스트를 거치지 않아서
또는 테스트를 놓쳐서
운영 상황에서 이런 일이 벌어졌다고 가정했을 때 NPE
로 인해 예외가 발생하는 것으로 끝나는 게 아니라 프로세스가 죽기 까지 한다면?
사소한 버그
가 서비스 중단
으로 이어지는 비극적인 일이 발생한 것이다.
예외 상황을 미리 찾아 테스트하면 이러한 문제 발생 가능성을 현저히 낮출 수 있다.
가장 구현하기 쉬운 테스트부터 수행하면 아주 빠르게 테스트 코드를 통과시킬 수 있다.
보통
수 분
에서수 십분
내로 테스트 코드를 통과시킬 수 있을 것 같은 것을 기준으로 한다.
다시 아까 얘기했던 암호 강도 측정
기능을 통해 알아보자.
만약 위 조건을 가진 암호 강도 측정 예제를 구현한다고 할 때,
3가지를 조건으로 두어 아래와 같은 기능을 구현하는 테스트를 시작하려 한다고 가정하자.
- 3가지 조건을 모두 만족하는 경우
STRONG
- 2가지 조건만 만족하는 경우
NORMAL
- 1가지 조건 이하로 만족하는 경우
WEAK
여기서 먼저 작성하기 쉬운 테스트를 골라, 테스트 코드 작성을 시작할 것이다.
- 모든 조건을 충족하는 경우
- 모든 조건을 충족하지 않는 경우
가장 극적인 이 두 가지가 먼저 생각이 난다.
이 중에서 뭐가 더 쉬울지 생각하면 모든 조건을 충족하는 경우이다. 모든 조건을 충족하지 않는 경우에 대해 먼저 테스트하게 된다면 각 조건에 대한 코드를 작성하게 되기 때문에 테스트 작성 시간이 길어진다.
예를 들어 모든 조건을 충족하는 테스트는 입력값이 들어오면 바로 STRONG
을 리턴하면 된다.
그리고 이후에 입력값이 없는 경우
→ 1가지가 충족되지 않는 경우
→ 2가지가 충족되지 않는 경우
→ 아무런 조건도 충족되지 않는 경우
순서로 작은 단위부터 하나씩 작성한다.
결국 이 조건들에 해당하지 않으면
STRONG
을 리턴하도록 두면 되기 때문에
처음에 작성한그저 STRONG을 리턴하는 코드
는 수정의 가능성이 현저히 낮다.
변수명 변경
, 상수를 변수로 관리
등의 가벼운 리팩토링은 테스트 코드 작성 중에 작업해도 로직을 건드리는 것이 아니기 때문에 상관 없다.
하지만 메서드 분리
와 같이 로직을 변경하는 경우에는, 내가 생각한 것과 다르게 작동하게될 수 있으므로 테스트 코드 작성이 마무리 될 무렵, 즉 틀이 다 짜인 뒤에 하는 것을 추천한다고 저자는 말했다.
나 또한 이 내용에 동의하는데, 실제로 개발을 하면서 메서드 분리
를 하게 되면 이전과 똑같은 결과가 수행될 거라고 생각했던 부분에서 이상한 값이 도출되는 경험을 한 적이 있다. 때문에 틀이 다 짜여 정상적으로 테스트가 가능한 상태까지 만든 뒤에 메서드 분리와 같은 자세한 리팩토링을 진행하자.
TDD를 시작할 때, 테스트할 목록을 미리 작성하면 좋다고 한다.
위에서 말한대로 구현이 쉬운 순서대로 테스트를 작성하려고 할 때, 목록을 보며 미리 순서를 생각해볼 수 있으며, 시간을 좀 더 들이면 테스트에서 검증할 예외 상황에 대해서도 생각해볼 수 있다.
또한 인간의 기억은 영원하지 않기 때문에 잠시 다른 일을 하고 왔을 때 어떤 테스트들을 작성하려고 했었는 지 기억하려면 꼭 기록해두는 편이 좋다고 생각한다.
Jira
나 Trello
같은 시스템을 사용하면 해당 테스트 사례를 하위 작업으로 등록해서 테스트 통과여부를 추적까지 할 수 있다고 한다.(꼭 한번 쯤은 써봐야겠다.)
한 번에 작성한 테스트가 많으면, 구현 초기에도 리팩터링을 마음껏 못하게 되며 수정할 코드가 많을 수록 리팩터링에 대한 심리적 저항
이 생기게 된다.
또한 모든 테스트를 통과시키기 전까지는 계속해서 깨지는 테스트가 존재하므로 개발 리듬을 유지하기 어렵다.
때문에 하나의 테스트 코드를 만들고 이를 통과시키고 리팩토링하고를 반복하며 하나씩 순차적으로 개발해나가는 과정을 통해 비교적 짧은 리듬을 가져가자. 이렇게 개발하며 다루는 범위가 작고 개발 주기가 짧아지므로 개발 집중력이 향상되는 좋은 방법이다.
어떤 상황의 경우 리팩토링을 통한 변경 범위가 매우 큰 경우가 존재하는데, 큰 리팩토링은 시간이 오래 걸리므로 TDD 흐름을 깨기 쉽다. 이때는 리팩토링을 진행하지 말고 테스트를 통과시키는 것에 먼저 집중한다. 대신 범위가 큰 리팩토링은 할 일 목록
에 꼭 추가해서 놓치지 않고 진행한다.
그리고 큰 리팩토링을 수행하기 전엔 꼭 코드를 커밋하자. 큰 단위의 리팩토링은 한 번에 성공하지 못 할 가능성이 있기 때문에 형상관리 시스템에 리팩토링 전의 코드 내용을 보관해두자.
테스트 코드를 작성하다 보면 시작이 잘 안 될 때가 있다.
이럴 때는 검증하는 코드부터
작성하기 시작하면 도움이 된다고 한다.
예를 들어 만료일 계산 기능
의 경우 만료일을 검증하는 코드
부터 작성해보는 것이다.
@Test
void 만원_납부하면_한달_뒤가_만료일이_됨() {
assertEquals(기대하는만료일, 실제만료일);
}
우선 어떤 값을 넣을 지, 위와 같이 한글로 작성해보면 단번에 이해하기 쉬우며 이제부터 각 매개변수에 어떤 값을 넣을 지 생각해본다.
먼저 기대하는 만료일
을 어떻게 표현할지 결정해야 한다. 만료일은 시간은 필요없고 날짜만을 표현하는 타입을 선택하면 될 거 같은데, 년월일
을 가질 수 있는 자바의 LocalDate
를 활용해서 표현해보면 될 것 같다.
@Test
void 만원_납부하면_한달_뒤가_만료일이_됨() {
assertEquals(LocalDate.of(2019,8,9), 실제만료일);
}
다음으로 실제 만료일을 바꿀 차례이다. 이 값은 실제로 계산하여 얻은 결과 값을 사용해야하기 때문에 계산 로직과 변수를 두어 관리하면 될 것 같다.
@Test
void 만원_납부하면_한달_뒤가_만료일이_됨() {
LocalDate realExpiryDate = 실제만료일계산();
assertEquals(LocalDate.of(2019,8,9), realExpiryDate);
}
이제 실제만료일계산
을 할 코드를 작성할 차례이다. 만료일을 계산하는 로직을 작성해보자
@Test
void 만원_납부하면_한달_뒤가_만료일이_됨() {
LocalDate realExpiryDate = cal.calculateExpiryDate(파라미터);
assertEquals(LocalDate.of(2019,8,9), realExpiryDate);
}
cal의 정확한 타입은 모르지만 어떤 객체의 메서드를 실행하여 계산하도록 작성했다.
이제 cal의 타입과 파라미터를 정하면 된다.
먼저 파라미터 타입을 고민해보자.
만료일을 정하는 데 필요한 것을 생각해보면 납부액
, 납부일
이 두가지이다.
납부일에 얼마를 납부했는지 알 수 있으면 실제 만료일을 구할 수 있을 것이기 때문이다.
그럼 이것을 바탕으로 납부액과 납부일을 입력받도록 변경해보자
@Test
void 만원_납부하면_한달_뒤가_만료일이_됨() {
LocalDate realExpiryDate = cal.calculateExpiryDate(LocalDate.of(2019,7,9), 10_0);
assertEquals(LocalDate.of(2019,8,9), realExpiryDate);
}
10000원
을 지불하면 한 달 후 같은 날에 만료가 되는 프로그램을 개발 중이기 때문에,
2019년 7월 9일
에 10000원
을 납부한 것으로 테스트를 작성하였다.
@Test
void 만원_납부하면_한달_뒤가_만료일이_됨() {
ExpiryDateCalculator cal = new ExpiryDateCalculator();
LocalDate realExpiryDate = cal.calculateExpiryDate(LocalDate.of(2019,7,9), 10_0);
assertEquals(LocalDate.of(2019,8,9), realExpiryDate);
}
이제 남은 것은 cal의 타입을 결정하는 일인데 ExpiryDate를 계산하는 로직을 수행하기 때문에 ExpiryDateCaculator 라는 객체를 생성하여 이를 타입으로 사용하기로 하였다. 그럼 마지막으로 이러한 코드가 완성된다.
시작이 되지 않는 다면, 위와 같은 플로우를 따라 TDD를 시작해보자!
TDD를 진행하다 보면 구현이 막히는 경우가 발생할 수 있다.
어떻게 해야할 지 전혀 생각이 나지 않거나, 무언가 잘못한 것 같은 느낌이 들 것이다.
이럴 때는 과감하게 코드를 지우고 다시 시작하자. 어떤 순서로 테스트 코드를 작성했는지 돌이켜보고 순서를 바꿔서 다시 진행한다.
그리고 다시 시작할 때는 이것을 꼭 상기한다.
개인적으로 위에서 말한
테스트 순서 목록
를 작성해두면 다시 시작할 때,
이전의 방식을 돌아보기 쉬우며 즉각적인 피드백이 가능할 것이라고 생각된다.