Devin.KR

업무 규칙 테스트

110분 안팎

학습 목표

JUnit 5로 정상·경계·실패 입력을 검증합니다.

개념

왜 눈으로 확인한 실행을 테스트로 남기나요

도서가 한 번 대여되는 모습을 콘솔에서 확인해도 0일 신청이나 중복 대여를 막았다는 증거는 없습니다. 테스트는 입력과 기대 결과를 코드로 남겨 다음 수정에서도 같은 약속을 지키는지 확인합니다. 이번에는 Book과 LoanPolicy를 연결하는 LoanService를 구현하고, 잘못된 기간의 요청이 실패할 때 도서 상태까지 그대로인지 검증합니다. 테스트를 실행하는 일이 목적이 아니라 업무 규칙의 성공과 실패를 관찰하는 것이 목적입니다. 서버나 데이터베이스가 필요하지 않은 규칙은 작은 객체만 만들어 빠르게 확인합니다.

JUnit 5의 테스트 형태

package lab;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class LoanServiceTest {
    @Test void minimumBorrow() {
        Book book = new Book("동아리 노트");
        LoanService.borrow(book, 1);
        assertTrue(book.isBorrowed());
    }
}

@Test는 JUnit이 실행할 테스트 메서드라는 표시입니다. org.junit.jupiter.api.Test를 사용합니다. assertTrue는 조건이 참인지, assertFalse는 거짓인지, assertEquals는 기대값과 실제값이 같은지 확인합니다. assertEquals에서는 기대값을 앞에 두어 실패 메시지의 expected와 actual을 해석하기 쉽게 합니다. 이 실습의 pom에는 Spring Boot 3.1.5 테스트 의존성이 있지만 @SpringBootTest는 사용하지 않습니다. new Book으로 만든 객체와 정적 메서드만 검증하므로 애플리케이션 컨텍스트, 외부 설정, 운영 DB를 읽을 이유가 없습니다.

준비, 실행, 확인을 분리합니다

테스트의 첫 줄은 준비입니다. 제목이 있는 새 책을 만듭니다. 다음 줄은 검증할 업무 실행입니다. LoanService.borrow를 한 번 호출합니다. 마지막 줄은 확인이며 도서가 실제 대여 상태로 바뀌었는지 봅니다. 한 테스트에 여러 업무를 계속 이어 붙이면 실패가 어디서 시작했는지 읽기 어려워집니다. 테스트 이름 minimumBorrow는 입력의 의도를 보여 줍니다. 최소 기간과 최대 기간을 다른 테스트로 만들면 1일에서 실패했는지 14일에서 실패했는지 보고서에서 바로 구분할 수 있습니다.

기간 테스트의 기대값은 업무 담당자와 합의한 범위에서 정합니다. LoanPolicy가 반환한 값으로 다시 기대값을 계산하면 구현과 테스트가 함께 틀릴 수 있습니다. 1일과 14일은 허용, 0일과 15일은 거부라는 표를 직접 사용합니다. 대여 중 여부만 true로 바뀌는지 보는 정상 경로에 더해, 실패 입력에서는 상태가 false로 유지되는지도 확인합니다. 예외 하나가 발생했다는 사실과 실패 전에 부수 효과를 남기지 않았다는 사실은 별도 검증 대상입니다.

예외를 확인하는 방법

@Test void zeroKeepsState() {
    Book book = new Book("A");
    assertThrows(IllegalArgumentException.class,
        () -> LoanService.borrow(book, 0));
    assertFalse(book.isBorrowed());
}

assertThrows는 지정한 코드가 기대한 종류의 예외를 발생시키는지 검증합니다. () -> 뒤의 표현은 지금 즉시 실행하지 않고 JUnit이 실행하도록 전달하는 작은 함수입니다. LoanService.borrow(book, 0)를 assertThrows 바깥에서 먼저 호출하면 예외가 테스트 자체를 중단시키며 확인할 기회가 없어집니다. 예외가 필요한 호출만 감싸고 도서 준비는 밖에 둡니다. 그래야 준비 단계에서 우연히 발생한 예외를 요청 거부의 증거로 착각하지 않습니다. 후속 assertFalse는 실패 처리 후 상태 보존을 확인합니다.

검증을 먼저 하고 상태를 바꿉니다

public static void borrow(Book book, int days) {
    if (!LoanPolicy.isAllowed(days)) {
        throw new IllegalArgumentException("대여 기간은 1~14일입니다");
    }
    book.borrow();
}

!는 boolean 값을 반대로 바꿉니다. 허용되지 않은 기간이면 예외를 발생시키고, 통과한 요청만 book.borrow를 호출합니다. 반대로 먼저 대여하고 나서 기간을 검사하면 예외는 발생하더라도 이미 true로 바뀐 상태가 남을 수 있습니다. 새 레슨에서는 이 호출 순서가 업무 결과에 어떤 차이를 만드는지 확인합니다. 중복 대여는 Book이 이미 알고 있는 규칙이므로 LoanService가 따로 borrowed 값을 직접 고치지 않습니다. 기존 도서의 공개 메서드로 위임해 규칙을 유지합니다.

LoanService의 이번 입력 계약은 null이 아닌 Book과 정수 days입니다. null Book 처리를 어느 계층에서 할지 아직 정하지 않았으므로 임의의 성공 처리를 추가하지 않습니다. 기간 오류는 IllegalArgumentException, 이미 대여 중인 상태는 IllegalStateException으로 구분합니다. 반환값이 없는 void 메서드는 결과가 없어서 검증할 수 없는 것이 아닙니다. 변경된 객체 상태나 발생한 예외를 관찰하면 됩니다. 나중에 외부 저장소가 붙으면 관찰 지점이 늘어나지만 지금은 객체만으로 충분합니다.

테스트의 독립성과 실행 결과

각 테스트 안에서 새 Book을 만듭니다. 정적 필드에 Book을 보관하거나 이전 테스트가 만든 상태를 기대하지 않습니다. 전체 테스트의 실행 순서를 고정해 오류를 숨기지 않습니다. minimumBorrow와 maximumBorrow는 각각 자신의 객체를 가지고, doubleBorrow는 하나의 테스트 안에서 대여와 재대여를 연속 실행해 필요한 시나리오를 완결합니다. 순서가 중요한 업무와 서로 독립적인 테스트 실행 순서는 다른 문제입니다. 이 기준이 있어야 단일 테스트 실행과 전체 테스트 실행에서 같은 결과를 얻습니다.

starter는 0일과 15일을 허용하는 경계 오류가 있습니다. 테스트를 읽어 실패한 입력을 찾고 LoanService의 조건을 LoanPolicy.isAllowed 호출로 바꿉니다. 전체 테스트를 실행하면 이전 레슨의 출력, 기간 집계, 도서 검증도 함께 실행됩니다. Maven 종료 코드가 0이어도 테스트를 아예 찾지 못한 경우는 완료로 보지 않습니다. target/surefire-reports에서 Tests run이 27인지, Failures와 Errors와 Skipped가 모두 0인지 확인합니다. 테스트 이름은 Maven 기본 탐색에 맞게 Test로 끝나도록 제공합니다.

빨간 결과를 읽고 다시 확인합니다

assertion failure는 기대 결과와 실제 결과가 다르다는 뜻입니다. Expected ... to be thrown, but nothing was thrown이면 잘못된 요청을 정상 처리한 경로를 봅니다. Unexpected exception type이면 예외가 발생했지만 계약과 종류가 다른 경우입니다. 보고서의 클래스, 메서드 이름, 스택의 본인 코드 줄을 연결해서 읽습니다. 반면 컴파일 실패나 의존성 다운로드 실패는 테스트가 업무 규칙을 평가하기 전의 환경 문제입니다. errors나 테스트 보고서 유무를 확인해 구분하며 기대값을 바꾸어 해결하지 않습니다.

수정 후에는 먼저 해당 테스트를 ./mvnw -Dtest=LoanServiceTest test로 실행하고 전체 ./mvnw test로 누적 계약을 확인합니다. 테스트를 삭제하거나 @Disabled로 가리면 통과 증거가 사라집니다. 완료 메모에는 어떤 입력이 어떤 규칙을 지키는지 적고 정상·경계·실패 경로를 한 줄씩 설명합니다. 미션에서는 이 27개 테스트를 유지한 채 제목 변경 테스트 여섯 개를 추가합니다. 테스트 종류와 외부 의존성 분리의 더 넓은 설명은 연결한 서재에서 읽고, 지금은 본인 코드가 의도대로 바뀌었다는 근거를 실행 결과로 제출합니다.

따라하기

대여 전후 상태의 계약 확인

LoanServiceTest에서 0일·15일 거부 테스트를 읽습니다. 각 테스트가 새 Book을 준비하는지, assertThrows 뒤에서 대여 상태가 false인지 확인하는지 살펴봅니다. 정상 대여와 거부 후 상태를 별도 관찰합니다.

시작 코드의 TODO 수정

src/main/java/lab/LoanService.java에서 기간 검증을 기존 LoanPolicy 호출로 바꿉니다. 아래 업무 실행 순서를 유지합니다. 코드 조각을 해당 선언과 메서드 안에 적용한 뒤 ./mvnw test를 실행해 컴파일하고 규칙을 확인합니다.

if (!LoanPolicy.isAllowed(days)) {
    throw new IllegalArgumentException("대여 기간은 1~14일입니다");
}
book.borrow();

정상과 경계를 직접 실행

앞 단계의 빌드가 끝나 target/classes가 만들어진 뒤 아래 명령을 실행합니다. Demo.java는 실습 루트에 만드는 관찰용 파일입니다. 제공 테스트와는 별도이며 다른 레슨으로 이동해 실행하지 않습니다.

cat > Demo.java <<'JAVA'
public class Demo {
    public static void main(String[] args) {
        lab.Book book = new lab.Book("A");
        try { lab.LoanService.borrow(book, 0); }
        catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
        System.out.println("거부 후 대여 중: " + book.isBorrowed());
        lab.LoanService.borrow(book, 14);
        System.out.println("14일 신청 후: " + book.isBorrowed());
    }
}
JAVA
javac --release 17 -encoding UTF-8 -cp target/classes -d target/classes Demo.java
java -cp target/classes Demo

실행 결과

대여 기간은 1~14일입니다
거부 후 대여 중: false
14일 신청 후: true

테스트와 종료 상태 확인

아래 명령으로 전체 테스트를 다시 실행하고 바로 이어 종료 상태를 읽습니다. -q는 정상 로그를 줄입니다. 아래 0은 echo가 보여 준 테스트 명령의 종료 상태입니다. target/surefire-reports의 XML 또는 txt에서 제공 테스트 27개가 실행되었고 실패·오류·건너뜀이 0인지 함께 확인합니다. 실습 안내의 7일 정상 대여 테스트를 직접 더했다면 28개입니다.

./mvnw -q test
echo $?

실행 결과

0

확인 문제

실습

LoanService.borrow는 LoanPolicy로 기간을 먼저 검증한 뒤 Book.borrow를 호출합니다. 0일·15일 거부 후 도서 상태가 false인지, 1일·14일 정상 대여와 중복 대여 거부를 확인합니다. 기존 테스트를 삭제하거나 비활성화하지 않습니다. src/test/java/lab/LoanServiceTest.java를 읽고 새로 7일 정상 대여 테스트를 작성한 뒤 실행해 봅니다. 제공 테스트 27개에 새 테스트를 더하면 28개가 됩니다.

시작 코드·테스트 내려받기

실행 명령

./mvnw test

기대 결과

제공 테스트 27개 실행, Failures 0, Errors 0, Skipped 0, 종료 코드 0입니다.

모범 답안모범 답안 내려받기

더 읽기

면접 질문

  • 정상·경계·실패 경로를 각각 테스트하는 이유는 무엇인가요?
  • assertThrows로 예외와 실패 후 상태를 어떻게 검증하나요?