연동 모듈의 상태 변경을 요청하고, 상태가 정상적으로 변경되었는지 테스트하는 코드를 작성한다고 합시다. 우리가 원하는 시나리오는 연동 모듈에서 상태 변경 처리가 완료되면, 모듈의 상태를 가져와서 결과를 확인하는 것입니다. 그러나 변경 요청이 처리 완료 되었음을 테스트 코드에서 알 수 없는 구조라면 어떻게 해야할까요? 이럴 때 가장 원시적인 방법으로 몇 번 테스트를 해보고 처리에 걸리는 시간을 확인한 후 대략 넉넉하게 대기한 후 상태를 확인하는 방법을 사용할 수 있습니다.
SomeComponent component = ...;
@Test
void changeStatus() throws InterruptedException {
// 1. given
String command= "change-something";
// 2. when
component.command(command);
// 3. 얼마를 기다려야 command가 처리가 완료될까?
Thread.sleep(1000);
// 4. then
String status = component.queryStatus();
Assertions.assertEquals("something-status", status);
}
그러나 이렇게 하면 문제가 있습니다. 좋은 테스트의 조건에 대해 찾아보면 항상 등장하는 것이 테스트의 일관성과 속도에 대한 내용입니다. 테스트는 동일한 입력에 대해 일관된 결과를 내도록 작성되야 하며 또한 가능한 빠르게 실행되어 개발자로 하여금 테스트 시간에 대한 부담 없이 원하는 시점에 자주 테스트를 수행할 수 있도록 하는 것이 좋습니다.
sleep을 사용하는 위 코드는 요청된 명령이 처리되고 결과 상태를 수신하는데 대부분 1초가 소요되지 않습니다. 대게 수 밀리초 안에 처리되는데 간혹 네트워크 상황이나 기타 환경에 의해 수백 밀리초가 걸릴 때도 있습니다. 이런 상황에서 테스트가 일관되게 처리되도록 하기 위해 1초라는 넉넉한 대기 시간을 둔 것입니다.
그러나 테스트 실행 속도 관점에서 생각해보면 테스트 하나를 돌리는데 1초 이상이 항상 소요됩니다. 만약 이런 종류의 테스트가 10개 라면 10초, 100개라면 100초가 소요됩니다. 우리에게는 테스트의 결과를 일관되게 보장하면서 최적의 실행 속도를 보장하는 방법이 필요합니다.
이런 상황에서는 상태 변경 명령을 보내고 sleep 없이 즉시 원하는 결과가 나오는지 체크를 시작해서 일정 시간 동안 체크를 반복하는 방법을 사용할 수 있습니다. 원하는 결과가 나오면 즉시 실행을 반환하게 하면 불필요하게 항상 1초를 대기하지 않을 수 있습니다. 루프를 돌며 폴링하면 cpu를 많이 사용하게 되는데 단위 테스트 코드에서 cpu 시간을 현 테스트를 위해 최대한 사용하는 것임으로 문제될 것이 없습니다.
그래서 다음과 같이 테스트 결과를 일정 시간 동안 성공할 때 까지 폴링하는 TryUntilSuccess 클래스를 정의해서 여러 테스트 코드에서 공유해서 활용할 수 있습니다. TryUntilSuccess.work 메서드는 Boolean 타입 결과를 반환하는 Supplier 인터페이스를 구현한 객체(람다식 또는 메서드참조)와 타임아웃 시간을 입력받습니다. 그래서 타임아웃 시간 동안 task가 true를 반환할 때까지 실행을 반복합니다.
public class TryUntilSuccess {
public static boolean work(Supplier<Boolean> task, long timeoutMillis) {
long startMillis = System.currentTimeMillis();
while (System.currentTimeMillis() - startMillis < timeoutMillis) {
if (task.get()) {
return true;
}
}
return false;
}
}
테스트 코드는 TryUntilSuccess 클래스를 활용하여 다음과 같이 개선할 수 있습니다. 이렇게 하면 대부분 수 밀리초에 테스트가 수행되고 간헐적으로 테스트가 수백 밀리초가 소요되어도 유연하게 대응이 가능합니다.
SomeComponent component = ...;
@Test
void changeStatus() throws InterruptedException {
// 1. given
String command= "change-something";
// 2. when
component.command(command);
// 3. then
Supplier<Boolean> testOnce = () -> {
String status = component.queryStatus();
return status.equals("some status")
};
Boolean result = TryUntilSuccess.work(testOnce, 1000);
Assertions.assertTrue(result);
}
여기서 한 가지 아쉬운 점이 있습니다. JUnit 같은 테스트 프레임워크의 Assertion 기능은 결과 비교에 실패한 경우 기대 값과 실제 값이 무엇이었는지 출력해 주어서 왜 테스트가 실패했는지 개발자가 확인할 수 있도록 돕습니다. 그런데 위 처럼 코드를 수정하면 테스트의 일관성과 속도라는 두 마리 토끼를 잡았지만 테스트가 왜 실패했는지 값을 확인할 수 없는 한계를 가집니다.
그래서 TryUntilSuccess 구현을 다음과 같이 바꾸어 볼 수 있습니다. 테스트가 타임아웃 시간 이후에 최종적으로 실패하면 특정 로그를 찍어줄 Runnable 객체를 입력 받도록 합니다. 역시 아쉬운 점은 실제 테스트에 실패한 데이터를 출력해 주는 것이 아니라 한 번 더 모니터링한 데이터를 출력해 주는 한계를 가집니다. 우선 여기까지 하고 이 부분은 다음에 더 개선해 보도록 하겠습니다.
public static boolean work(Supplier<Boolean> task, long timeoutMillis, Runnable logIfFailed) {
long startMillis = System.currentTimeMillis();
while (System.currentTimeMillis() - startMillis < timeoutMillis) {
if (task.get()) {
return true;
}
}
logIfFailed.run();
return false;
}
SomeComponent component = ...;
@Test
void changeStatus() throws InterruptedException {
// 1. given
String command= "change-something";
// 2. when
component.command(command);
// 3. then
Supplier<Boolean> testOnce = () -> {
String status = component.queryStatus();
return status.equals("some status")
};
Runnable logIfFailed = () -> {
log.info("command = {}, status = {}", command, service.getStatus();
}
Boolean result = TryUntilSuccess.work(testOnce, 1000, logIfFailed);
Assertions.assertTrue(result);
}
테스트 코드 개선을 통해 일관된 결과를 내고, 더 빠르고 더 유연하게 실행되며, 테스트 실패 시에 결과를 쉽게 확인할 수 있는 테스트 코드를 만들어 보았습니다.