비지니스 로직에 집중하는, 비동기 테스트 코드 만들기 (Awaitility)

주싱·2023년 2월 15일
2

더 나은 테스트

목록 보기
14/16

최근 몇 년 간 비동기 시스템에서 코드를 작성하고 테스트하는 일이 많아졌습니다. 어느 날 테스트 코드를 작성하고 있는데 테스트 코드에서 비지니스 로직을 표현하는 코드 보다 비동기적인 처리를 위한 기술적인 코드들이 더 많은 것이 눈에 띄었습니다.

문제 코드

다음 예제 코드는 상점에서 재고를 변경하는 비지니스 로직을 테스트하는 코드인데 테스트 결과를 비동기적으로 확인할 수 있는 구조입니다. 코드를 보면 비동기적으로 완료되는 재고 변경 작업을 체크하기 위해 다소 복잡한 루프문을 돌고 있습니다. 비동기적인 처리를 위한 기술적인 코드가 비지니스 로직을 테스트하는 코드를 읽기 어렵게 만들고 있는데 똑같은 기능을 동기적으로 처리할 때의 테스트 코드와 비교해 보면 꽤 복잡한 코드임을 알 수 있습니다.

// 1. 비동기식 테스트 코드 
@Test
void inventory_reflects_requested_value() {
    // 1. When - 상태 변경 명령 전송
    store.requestAsync(new ChangeInventory(100));

    // 2. Then - 일정 시간 동안 상태 변경 여부 확인
    long startMillis = System.currentTimeMillis();
    while (System.currentTimeMillis() - startMillis < 2000) {
        if (store.getInventory() == 100) { // condition 
            // success
            return;
        }
    }

    // 3. (실패 시) 실패 판정에 사용된 값 출력
    log.info("status : {}", store.getInventory());
    fail();
}
// 2. 동기식 테스트 코드 
@Test
void inventory_reflects_requested_value_sync() {
    // 1. When - 상태 변경 명령 전송
    store.requestSync(new ChangeInventory(100));

    // 2. Then - 상태 변경 여부 확인
    assertEquals(100, store.getInventory());
}

목표가 드러나도록

위의 동기식 테스트 코드에는 우리가 테스트하고자 하는 목표가 명확하게 드러납니다. 반면 비동기식 테스트 코드에는 우리가 무엇을 테스트하는지 보다 그것을 달성하기 위한 방법에 대한 코드가 더 많아 테스트가 목표하는 바가 명확히 드러나지 않습니다. 비동기 시스템에서의 테스트 코드 역시 테스트하고자 하는 목표가 잘 드러나도록 만들고 싶었습니다. 그래서 직접 이를 지원하는 라이브러리를 만들기 시작했습니다. 대략 다음과 같이 비동기 시스템을 테스트할 수 있으면 좋겠다고 생각했습니다. 아래 코드에는 상점 객체에 재고 변경 요청을 하면, 약 2초 내에 재고가 변경되는지 확인한다는 테스트 목표가 분명하게 드러납니다.

@Test
void inventory_reflects_requested_value_library() {
    // 1. When - 상태 변경 명령 전송
    store.requestAsync(new ChangeInventory(100));

    // 2. Then - 일정 시간 동안 상태 변경 여부 확인
    assertUntilTimeout(ofMillis(2000), // timeout
                       () -> store.getInventory() == 100, // condition
                       () -> "status : " + store.getInventory() // message on failure
    );
}

라이브러리 만들기

라이브러리 클래스는 테스트 결과를 타임아웃(Timeout) 시간이 될 때까지(Until) 단정(Assert)하며 확인한다는 의미를 담아 ‘AssertUntilTimeout’이라고 이름하였습니다. 이 클래스는 타임아웃 시간이 만료될 때까지 테스트 결과를 체크하는 람다식(Boolean 반환)을 반복 호출합니다. 만약 중간에 조건을 만족하는 경우 즉시 실행을 종료하고, 타임아웃된 경우에는 테스트 실패에 대한 근거를 로깅하고 예외를 던지도록 했습니다.

public class AssertUntilTimeout {

    /**
     * 제한시간(timeout) 동안 특정 조건을 만족(assertOnce)하는지 반복 확인합니다. 조건 만족 시 즉시 작업을 종료하고, 타임아웃 시 테스트
     * 실패 컨텍스트를 출력하고 실패를 알리는 예외를 던집니다.
     *
     * @param timeout 제한시간
     * @param assertOnce 조건 확인 메서드
     * @param messageOnFailure 실패 메시지 메서드
     */
    public static void assertUntilTimeout(Duration timeout, AssertionSupplier assertOnce, Supplier<String> messageOnFailure) {
        if (!tryUntilTimeout(timeout, assertOnce)) {
            buildAndThrowAssertionFailure(timeout, messageOnFailure);
        }
    }

    private static boolean tryUntilTimeout(Duration timeout, AssertionSupplier workOnce) {
        long timeoutMillis = timeout.toMillis();
        long startMillis = System.currentTimeMillis();
        while (System.currentTimeMillis() - startMillis < timeoutMillis) {
            try {
                if (workOnce.get()) {
                    return true;
                }
            } catch (Throwable ex) {
                throwAsUncheckedException(ex);
            }
        }
        return false;
    }

    private static void buildAndThrowAssertionFailure(Duration timeout, Supplier<String> messageOnFailure) {
        AssertionFailureBuilder.assertionFailure()
                               .message(messageOnFailure)
                               .reason("execution exceeded timeout of " + timeout.toMillis() + " ms")
                               .buildAndThrow();
    }

    private AssertUntilTimeout() {
        /* no-op */
    }
}

바퀴를 재발명한 건가? (JUnit)

그렇게 얼마 동안 직접 개발한 라이브러리로 테스트 코드를 쉽게 작성할 수 있었습니다. 그런데 어느날 우연히 Mockito 레퍼런스 페이지를 읽는데 timeout을 활용한 검증 기능이 눈에 띄었습니다.

조금 당황스러웠습니다. Mockito에 이런 기능이 있다면 JUnit에도 당연히 기능이 존재할 거라 예상할 수 있었습니다. 아니나 다를까 JUnit에서도 Assertions 클래스에 assertTimeout() 이라는 타임아웃 기반 검증 API를 제공하고 있었습니다. 그래서 바퀴를 재발명한 어리석은 일을 했다며 머리를 한대 쥐어박고 JUnit API를 사용하도록 테스트 코드를 바꾸어 보았습니다. 그런데 다행히도(?) 제가 만든 것과 JUnit이 제공하는 것에는 작은 차이점이 있었습니다. 제가 만든 라이브러리는 Assertion을 위한 조건을 체크하는 람다식을 입력 받고 실행을 반복하는 책임은 라이브러리가 가지고 있습니다. 반면에 JUnit의 API는 사용자가 직접 반복하는 책임을 가지고 코드를 작성해야 했습니다. 사소한 차이지만 사용자가 반복의 책임을 질 필요가 없겠다고 생각했습니다. 문득 JUnit이라는 유익한 오프소스 프로젝트에 기능을 제안해 볼 좋은 기회라 생각되어 이때다 싶어 JUnit GitHub에 가서 이슈를 남겼습니다.

@Test
void inventory_reflects_requested_value() {
    // 1. When - 상태 변경 명령 전송
    store.requestAsync(new ChangeInventory(100));

    // 2. Then - 일정 시간 동안 상태 변경 여부 확인
    assertTimeout(ofMillis(2000), // timeout
                  () -> {
                      while (store.getInventory() != 100) { // condition
                          Thread.sleep(100);
                      }
                  }, () -> "status : " + store.getInventory() // message on failure
    );
}

도메인 특화 언어 (Awaitility)

유용한 오픈소스 프로젝트에 업무를 통해 만든 코드를 기여할 수 있다면 얼마나 좋은 일일까요! 기쁜 마음으로 정성껏 이슈를 작성해서 남겼는데 JUnit 메인테이너 중 한 사람이 곧 답변을 달아주었습니다. 한 마디로 Awaitility 라는 기술을 사용해 봤냐는 겁니다.

저는 처음 듣는 기술이었는데, 뭔가 해서 가봤습니다. 그리고 Readme를 읽기 시작하는데 심상치 않은 도구임을 직감했습니다.

결론적으로 Awaitility Usage Guide를 통해 쉽게 라이브러리 사용법을 학습할 수 있었고 제가 원하는 모든 기능이 매우 유연하게 제공되고 있었습니다. Awaitility는 스스로를 라이브러리라 소개하지 않고 DSL(Domain-specific language)이라고 소개하는데 작성된 예시 코드를 보면 정말 하나의 테스트 도메인에 특화된 언어 같은 느낌이 듭니다. 아래는 기존에 작성된 예제를 Awaitility를 사용하도록 바꿔본 코드입니다. 훌륭하지 않나요? 정말 놀랍고 완벽했습니다. 여러분도 비동기 시스템을 개발하고 테스트하신다면 Awaitility를 활용해 보시길 권해 드리고 싶습니다.

@Test
void inventory_reflects_requested_value_awaitility() {
    // 1. When - 상태 변경 명령 전송
    store.requestAsync(new ChangeInventory(100));

    // 2. Then - 일정 시간 동안 상태 변경 여부 확인
    with().conditionEvaluationListener(condition -> log.info("status : {}", store.getInventory())) // 테스트 조건 확인 시, 상태 값을 출력합니다
          .await() // 기다립니다
          .atMost(500, MILLISECONDS) // 500 msec 동안
          .pollInterval(ofMillis(100)) // 100 msec 간격으로
          .until(() -> store.getInventory() == 100); // 다음 조건을 통과할 때까지
}
profile
소프트웨어 엔지니어, 일상

0개의 댓글