Java의 JUnit과 AssertJ에 대해서는 다음 게시글을 참고해주세요.
JUnit5 과 AssertJ 로 단위 테스트 작성하기
Test Driven Development, 테스트 주도 개발은 2002년에 소개되면서 주목받기 시작한 설계 기법이다.
TDD의 기본 흐름은 다음과 같다.
일반적인 개발 방법론과 가장 큰 차이점은 테스트 코드를 작성한 뒤에 실제 코드를 작성한다는 점이다. 프로그램 설계 단계에서 테스트 코드를 작성해야 한다.
떄문에 프로그램 완성까지 걸리는 시간은 증가한다. 하지만 이를 통해 "작동하는 가장 깔끔한 코드" 즉, 응집도가 높고 결합도가 낮은 클래스로 구성된 시스템을 얻을 수 있다.
객체지향에 대해서는 [객체지향] 객체지향의 본질 게시글을 참고해주세요.
테스트 주도 개발은, 객체가 이미 존재한다고 가정하고 객체에게 어떤 메세지를 전송할 것인지에 관해 먼저 생각하라고 한다.
-> 객체지향의 본질인 역할, 책임, 협력의 관점에서 잘 생각해야 한다.
객체들이 어떤 메세지를 주고받으며 어떤 협력할 것인지의 기대를 코드로 작성하는 것이다. 그만큼 사전 설계를 하고 테스트-주도 개발을 진행해야 한다.
테스트는 주로, 객체의 메소드를 호출하고 반환값을 검증하는 식으로 한다.
이는 객체가 수행해야 하는 책임에 관한 것이다.
테스트에 필요한 간접 입력값을 제공하기 위해, 스텁(stub)을 추가하거나,
테스트에 필요한 간접 출력값을 검증하기 위해, 목 객체(mock object)를 사용하는 것은, 객체와 협력해야 하는 협력자에 관해 고민한 결과를 코드로 표현한 것이다.
단위 테스트란, 말 그대로 한 단위(모듈)만을 테스트하는 것이다.
TDD 라는 개념이 나오기 전의 단위 테스트는, 단지 프로그램이 '돌아간다' 라는 사실만 확인하는 일회성 코드에 불과했다. 코드를 구현한 후, 임시 코드를 급조해 테스트를 수행했었다.
사용자가 키를 누를때 마다 5초 뒤에 어떤 명령이 실행되어야 한다면,
사용자가 키를 누를때 마다 5초 뒤에 어떤 명령이 실행되는 테스트가 통과하는것을 확인하고 코드를 버리를 것은 TDD가 아니다.
TDD는, 표준 타이밍 함수를 호출하는 것이 아닌 타이밍 함수를 직접 구현해서 시간을 완전히 통제한 후, bool 플래그를 설정하는 명령을 함수로 넘겨 시간을 올바른 값으로 바꾸는 즉시 bool 값이 false에서 true로 바뀌는지 확인하고, 테스트 케이스가 모두 통과한 후에는 내 코드를 사용할 사람들에게 공개하고, 테스트 코드와 작동 코드를 같은 소스 패키지로 묶어서 체크인하는 것이다.
TDD의 기본 법칙은 실제 코드를 짜기 전에 단위 테스트부터 짜는것이다. 하지만 이것은 빙산의 일각에 불과하다. 더 알아보자.
위 세가지 규칙을 따라 일하면 매일 수십개의 테스트 케이스가 나온다. 실제 코드를 사실상 전부 테스트하는 테스트케이스가 나온다.
물론 실제 코드와 맞먹을 정도로 방대한 테스트 코드는 관리문제를 유발하기도 하니 적당히가 중요하다.
테스트 코드는 실제 코드 못지않게 중요하므로, 깨끗하게 짜야 한다.
테스트 코드를 잘 설계해서 실제 코드를 테스트 하기만 하면 그만이라고 생각해서 품질 기준을 낮추는 경우가 있다.
예를 들면 변수명을 대충 짓거나 코드를 잘 설계해서 분리하지 않는 경우가 있다.
하지만, 실제 코드가 진화할수록 테스트 코드도 변화해야 하는데, 이때 테스트 코드가 지저분할 수록 변경하기 어려워진다. 점점 테스트 코드가 개발자의 가장 큰 불만으로 이어진다. 결국 테스트 슈트를 폐기하는 상황에 처할수도 있다.
깨끗한 테스트 코드란, 한가지의 요구사항만 만족하면 된다.
"가독성"
(실제 코드만큼 효율적인 필요는 없다)
테스트 코드는 최소의 표현으로 많은 것을 나타내야 한다.
잡다하고 세세한 코드를 최대한 없애고 명확히 세 부분으로 나눠 보이게 만들어야 한다. (BUILD-OPERATE-CHECK 패턴)
다음은 세부분으로 명확히 나누어진 예시 코드이다. (Junit5, AssertJ)
@Test
void split_메소드_테스트() {
// BUILD
String input = "1,2,3";
// OPERATE
String[] result = input.split(",");
// CHECK
assertThat(result).contains("2", "1", "3");
assertThat(result).containsExactly("1", "2", "3");
}
또는, 관례적으로 given-when-then 이라는 패턴을 사용한다.
GWT의 예시 코드는 아래 나올 예시들에 적용했다.
GWT는 시스템 전반의 기능이나 시나리오 테스트에 주로 사용되는 반면, BOC는 유닛 테스트나 구체적인 기능 테스트에 더 적합하다고 한다.
그런데 대부분 Given-When-Then 밖에 안쓰긴 한다.
JUnit으로 테스트 코드를 짤 때에는 함수마다 assert 문을 단 하나만 사용해야 한다는 의견이 있다. 가혹한 규칙이지만 확실히 장점이 있다.
하지만 테스트를 분리하면 중복되는 코드가 많아진다.
이때는 TEMPLATE METHOD 패턴 을 사용하여 중복을 제거할 수 있다.
given-when 부분을 부모 클래스에 두고 then 부분을 자식 클래스에 두면 된다.
또는 독자적인 테스트 클래스라면 @Before
함수에 given/when 부분을 두고 @Test
함수에 then 부분을 넣으면 된다.
다음은 해당 내용을 적용한 예시 Java 코드이다.
class Calculator {
public int add(int a, int b) {
return a + b;
}
}
//...
public class CalculatorTest {
private Calculator calculator;
private int result;
@BeforeEach
public void setup() {
// Given
calculator = new Calculator();
int a = 5;
int b = 3;
// When
result = calculator.add(a, b);
}
@Test
public void 계산기_테스트() {
// Then
assertThat(result).isEqualTo(8);
}
@Test
public void 계산기_틀린경우_테스트() {
// Then
assertThat(result).isNotEqualTo(9);
}
}
테스트 가독성이 올라가긴 한다. 좋은 의견인것 같다.
하지만, assert 문을 딱 하나만 사용하기에는 배보다 배꼽이 커지는 경우가 많다. assert문을 적게 사용하는것은 좋지만, 딱 하나에 집착하지는 말자.
테스트 당 'assert' 하나보다는 '개념' 하나만 테스트 하라는 규칙이 더 좋아 보인다. 이것저것 잡다한 개념을 연속으로 테스트 하는 함수는 피하자.
여러 개념을 한 함수로 몰아넣으면 그 코드를 읽는 사람은 각 절이 존재하는 이유와 각 절이 테스트 하는 개념을 모두 한번에 이해해야 한다.
아래는 여러 개념을 동시에 테스트 하는 안좋은 예시이다.
class Calculator {
public int add(int a, int b) {
return a + b;
}
public int sub(int a, int b) {
return a - b;
}
}
//...
public class CalculatorTest {
@Test
public void 계산기_테스트() {
Calculator calculator = new Calculator();
int a = 5;
int b = 3;
int c = 7;
int additionResult = calculator.add(a, b);
int subtractionResult = calculator.sub(a, b);
int complexResult = calculator.add(calculator.sub(a, b), c);
assertThat(additionResult).isEqualTo(8);
assertThat(subtractionResult).isEqualTo(2);
assertThat(complexResult).isEqualTo(9); // 복잡하다.
}
}
위 코드는 여러 개념을 한번에 테스트하고 있어서 읽을때 복잡해 보인다.
아래는 개선한 테스트 클래스이다.
public class CalculatorTest {
private Calculator calculator = new Calculator();
@Test
public void 더하기_테스트() {
int a = 5;
int b = 3;
int additionResult = calculator.add(a, b);
assertThat(additionResult).isEqualTo(8);
}
@Test
public void 빼기_테스트() {
int a = 5;
int b = 3;
int subtractionResult = calculator.sub(a, b);
assertThat(subtractionResult).isEqualTo(2);
}
@Test
public void 복합_테스트() {
int a = 5;
int b = 3;
int c = 7;
int complexResult = calculator.add(calculator.sub(a, b), c);
assertThat(complexResult).isEqualTo(9);
}
}
코드를 읽는 입장에서도 잘 읽히고, 테스트 실행시에 훨씬 깔끔한 결과를 볼 수 있을것이다.
테스트 코드에 여러 개념을 동시에 사용하고 있다면, 코드 속에 감춰진 일반적인 규칙을 파악한 후, 테스트 메소드를 분리해야 한다.
여러 assert문이 한 테스트 메소드에 있다는것이 문제가 아니라, 여러 개념이 동시에 테스트 되고 있다는 사실이 문제이다.
따라서 '테스트 당 assert 하나' 보다는 '테스트 당 개념 하나' 가 더 좋은 규칙인 것 같다.
깨끗한 테스트는 다음과 같은 규칙을 따른다.
테스트는 빠르게 돌아야 한다. 테스트가 너무 느리면 자주 돌릴 엄두가 안난다. 자주 돌려야 초반에 문제를 찾아내 고칠 수 있다.
각 테스트는 서로 의존하면 안된다. 한 테스트가 다음 테스트가 실행될 환경을 준비하면 안된다. 한 테스트의 실패가 원인으로 다른 테스트가 잇다라 실패하면 원인을 진단하기 어려워진다.
테스트는 어떤 환경에서도 반복 가능해야 한다. 실제 환경, QA 환경, 네트워크가 연결되지 않는 환경 등 모든 환경에서 실행 가능해야 환경에 테스트가 의존하지 않게된다.
테스트는 객관적인 'bool' 값으로 결과를 내야 한다. 성공 아니면 실패이다.
통과 여부를 알기 위해 로그 파일을 읽어야 하면 안된다.
테스트는 적시에 작성해야 한다. 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다.
구현을 한 다음 테스트 코드를 작성하면, 실제 코드가 테스트하기 어렵거나 불가능하다는 사실을 뒤늦게 발견하게 된다.
여러 책에 조금씩 작성되어있는 TDD 개념을 알아봤다.
하지만 깨끗한 테스트 코드라는 주제는 책 한권으로도 부족한 주제이다.
보다 깊게 공부할땐 단위 테스트라는 책을 읽어보자.
해당 게시글은 객체지향의 사실과 오해, Clean Code 책을 읽고 정리한 게시글입니다.