Chapter 06. 올바르게 드러내기

Yeseong31·2023년 9월 2일
0

자바 코딩의 기술

목록 보기
6/8

코드는 꼭 테스트하라, 아니면 사용자가 하게 된다. - 데이트 토마스, 앤드류 헌트


Given-When-Then으로 테스트 구조화


JUnit5란?

  • JUnit5는 자바 클래스 라이브러리에는 속하지 않지만 단위 테스트를 작성하는 사실상의 표준이다.
  • 자바 8부터 사용 가능하며, 테스트 주도 개발(TDD)을 위해 사용한다.
  • 테스트를 정의할 때 @Test 표기만 해주면 돼서 사용도 간편하다.

테스트 구조화

  • 일반적으로 테스트는 given, when, then이라는 세 개의 핵심 부분으로 구성된다.
class CruiseControlTest {

    @Test
    void setPlanetarySpeedIs7667() {

		// given
        CruiseControl cruiseControl = new CruiseControl();

		// when
        cruiseControl.setPreset(SpeedPreset.PLANETARY_SPEED);

		// then
        Assertions.assertTrue(7667 == cruiseControl.getTargetSpeedKmh());
    }
}
  • given
    • 실제 테스트를 준비하는 단계
    • 테스트하려는 기능을 실행하기 위한 전제 조건을 포함한다.
  • when
    • 실제로 테스트하려는 연산을 수행하는 단계
  • then
    • when 단계에서 수행한 결과가 실제 기대했던 결과인지 확인하는 단계
    • 주로 AssertionsassertXxx 절로 결과를 확인한다.

given, when, then에 추가로 새 줄로 그루핑헤서 구조를 더 명확히 할 수 있다.




의미 있는 assertions 사용하기

  • 다음의 예제를 살펴보자.
class CruiseControlTest {

    @Test
    void setPlanetarySpeedIs7667() {

        CruiseControl cruiseControl = new CruiseControl();

        cruiseControl.setPreset(SpeedPreset.PLANETARY_SPEED);

        Assertions.assertTrue(7667 == cruiseControl.getTargetSpeedKmh());
    }
}
  • 위 예제는 기능상 올바르게 동작하긴 하지만, 테스트가 실패할 경우에는 문제가 발생한다.
  • 문제가 발생하면 java.lang.AssertionError라는 스택 추적을 받는데, 여기에는 어떤 assertion이 실패했는지만 알려주고 왜 실패했는지는 알려주지 않는다.
  • 지금과 같이 assertTrue()를 사용하면 값의 동등 비교가 아니라 true/false 여부에만 집중하므로 왜 두 값이 같지 않은지는 자세히 설명하지 않는다.

class CruiseControlTest {

    @Test
    void setPlanetarySpeedIs7667() {

        CruiseControl cruiseControl = new CruiseControl();

        cruiseControl.setPreset(SpeedPreset.PLANETARY_SPEED);

        Assertions.assertEquals(7667, cruiseControl.getTargetSpeedKmh());
    }
}
  • 따라서 두 값이 같은지 확인할 때에는 assertEquals()를 사용해야 한다.
  • 이 assertion을 사용하면 테스트 실패 시 다음과 같은 오류 메시지를 제공한다.

    expected: <7667> but was <1337>

예외는 어떤 면에서 테스트와 유사하다.
메시지 형식(템플릿)이 정해져 있으면 왜 코드가 정상적으로 동작하지 않는지 개발자가 알아내는 데 매우 유용하다.

JUnit의 assertion의 종류는 매우 많다.
자세한 내용은 JUnit5 Assertions 문서를 참고하자.




실제 값보다 기대 값을 먼저 보이기

  • 테스트를 실행할 때 assertion에 실패하면 문제가 확연히 드러난다.

    expected: <7667> but was <1337>
  • 아쉽지만 자바나 JUnit은 타입 검증을 지원하지 않는다.

  • 테스트 검증을 통해 기대한 타입과 실제 값을 비교할 때 순서는 전적으로 의미상의 문제이고 틀리기도 쉽다.

  • 실패한 테스트의 메시지를 읽을 때 최소한 그 메시지 자체는 무조건 옳다고 가정하므로 가정이 틀리면 안 된다.


class CruiseControlTest {

    @Test
    void setPlanetarySpeedIs7667() {
        CruiseControl cruiseControl = new CruiseControl();

        cruiseControl.setPreset(SpeedPreset.PLANETARY_SPEED);

        Assertions.assertEquals(7667, cruiseControl.getTargetSpeedKmh());
    }
}

테스트 검증 시에는 먼저 무엇을 원하는지부터 생각하자.




합당한 허용값 사용하기

  • 다음의 예제를 살펴보자.
class OxygenTankTest {

    @Test
    void testNewTankIsEmpty() {
        OxygenTank tank = OxygenTank.withCapacity(100);
        Assertions.assertEquals(0, tank.getStatus());
    }

    @Test
    void testFilling() {
        OxygenTank tank = OxygenTank.withCapacity(100);

        tank.fill(5.8);
        tank.fill(5.6);

        Assertions.assertEquals(0.114, tank.getStatus());
    }
}
  • 위 코드는 정수 값이 아닌 부동소수점 수를 비교하는 테스트이다.
  • 언뜻 보면 문제가 없어보이지만 테스트는 실패한다.
  • 문제는 부동소수점 산술 연산 때문이다.

두 번째 테스트를 실행하면 다음의 메시지와 함께 테스트가 실패한다.

expected: <0.114> but was <0.139999...>

자바를 포함한 모든 프로그래밍 언어에서는 부동소수점 수를 근사화한다.
따라서 근사화 작업으로 인해 숫자에 오차가 생길 수밖에 없다.


class OxygenTankTest {

    static final double TOLERANCE = 0.00001;

    @Test
    void testNewTankIsEmpty() {
        OxygenTank tank = OxygenTank.withCapacity(100);
        Assertions.assertEquals(0, tank.getStatus(), TOLERANCE);
    }

    @Test
    void testFilling() {
        OxygenTank tank = OxygenTank.withCapacity(100);

        tank.fill(5.8);
        tank.fill(5.6);

        Assertions.assertEquals(0.114, tank.getStatus(), TOLERANCE);
    }
}
  • 부동소수점의 오차는 막을 수 없다. 따라서 약간의 오차는 허용해야 한다.
  • JUnit에는 assertEquals(double expected, double actual, double delta) 메서드를 제공한다.
    • expected에는 기댓값, actual에는 실제 값을 넣는다.
    • delta에는 허용값을 넣는다.
    • 예를 들어 소수점 넷째자리까지 일치해야 한다면 delta = 0.0001이다.

정리하면 assertEquals()float이나 double을 사용할 때는, 자릿수를 파악한 상태로 허용 수준을 명시해야 한다.

화폐와 같이 오차를 허용하지 않는 연산이 필요할 때에는 다음의 방법이 있다.

  1. 숫자를 double이나 float에 저장하는 대신 long 변수에 저장
  2. BigDecimal 사용



예외 처리는 JUnit에 맡기기

  • 다음의 예제에서 첫 번째 테스트와 두 번째 테스트를 하나씩 살펴보자.
class LogbookTest {

    @Test
    void readLogbook() {

        Logbook logbook = new Logbook();

        try {
            List<String> entries = logbook.readAllEntries();
            Assertions.assertEquals(13, entries.size());
        } catch (IOException e) {
            Assertions.fail(e.getMessage());
        }
    }

    @Test
    void readLogbookFail() {
        Logbook logbook = new Logbook();

        try {
            logbook.readAllEntries();
            Assertions.fail("read should fail");
        } catch (IOException ignored) {}
    }
}
  • 첫 번째 테스트
    • 테스트에서 IOException이 발생하면 실패하도록 작성되었다.
    • 이때 실패한 테스트를 추적하기 용이하도록 예외 메시지를 남기고 있다.
    • 하지만 메시지만 제공하고, 전체 예외를 스택 추적으로 제공하지는 않는다.
  • 두 번째 테스트
    • 예외가 발생하면 성공하도록 작성되었다.
    • 제어 흐름을 catch 블록에 넘기고 fail()을 호출하지 않는다.
    • 하지만 예외의 이유를 알 수 없다는 문제가 있다.
  • 이제 코드를 개선해 보자.
class LogbookTest {

    @Test
    void readLogbook() throws IOException {

        Logbook logbook = new Logbook();

        List<String> entries = logbook.readAllEntries();

        Assertions.assertEquals(13, entries.size());
    }

    @Test
    void readLogbookFail() {

        Logbook logbook = new Logbook();

        Executable when = () -> logbook.readAllEntries();

        Assertions.assertThrows(IOException.class, when)
    }
}

모든 JUnit 테스트는 어떠한 예외도 발생하지 않는다는 암묵적인 assertion을 포함한다.
따라서 명시적인 예외를 넣지 말고, 그냥 JUnit이 알아서 처리하도록 놔두면 된다.

  • 첫 번째 테스트
    • try ~ catch 블록과 fail() 호출을 모두 빼서 코드를 간소화했다.
    • 이렇게 하면 원인 사슬이 깨지지 않고 가독성도 향상되었다.
  • 두 번째 테스트
    • assertThrows()를 사용하여 예외가 생기길 바라는 메서드를 assertion에 추가했다.
    • 이때 assertThrows()의 두 번째 인자에는 JUnit5의 Executable 타입이 들어간다.



테스트 설명하기

테스트가 무엇을 검증하는지 설명하는 것은 중요하다.

class OxygenTankTest {

    static final double PERMILLE = 0.001;

    @Test
    @DisplayName("Expect 44% after filling 22l in an empty 50l tank")
    @Disabled("We don't have small tanks anymore! TODO: Adapt for big tanks")
    void fillTank() {
        OxygenTank smallTank = OxygenTank.withCapacity(50);

        smallTank.fill(22);

        Assertions.assertEquals(0.44, smallTank.getStatus(), PERMILLE);
    }

    @Test
    @DisplayName("Fail if fill level > tank capacity")
    void failOverfillTank() {
        OxygenTank bigTank = OxygenTank.withCapacity(10_000);
        bigTank.fill(5344.0);

        Executable when = () -> bigTank.fill(6000);

        Assertions.assertThrows(IllegalArgumentException.class, when);
    }
}
  • JUnit5를 사용하면 @DisplayName을 사용해서 메서드명을 바꾸지 않고 테스트 설명을 명시적으로 나타낼 수 있다.
  • 또한 테스트를 더 이상 사용하지 않을 때에는 @Disabled를 사용하여 비활성화할 수 있다.



독립형 테스트 사용하기

@BeforeEach@Before은 테스트에 필요한 공통 설정 코드를 추출하고 한 번만 작성할 수 있도록 해준다.
하지만 두 표기가 만들어내는 암묵적 종속성은 코드를 읽기 힘들게 만든다.

  • 다음의 예제를 살펴보자.
class OxygenTankTest {

    OxygenTank tank;

    @BeforeEach
    void setUp() {
        tank = OxygenTank.withCapacity(10_000);
        tank.fill(5_000);
    }

    @Test
    void depressurizingEmptiesTank() {
        tank.depressurize();

        Assertions.assertTrue(tank.isEmpty());
    }

    @Test
    void completelyFillTankMustBeFull() {
        tank.fillUp();

        Assertions.assertTrue(tank.isFull());
    }
}
  • 위 예제에는 @BeforeEach가 붙은 setUp() 메서드로 tank의 값을 설정하고 있다.
  • @Test가 붙은 두 개의 테스트는 setUp()이 초기화한 tank의 값을 참조하고 있다.
  • 하나의 tank를 참조하고 있기 때문에 모든 테스트는 독립적이지 않은 상태이다.
  • 테스트가 독립적이지 않으면 다음의 문제가 발생할 수 있다.
    • 테스트 실행 순서가 바뀌면 테스트 결과도 달라진다.
    • 테스트가 매우 많으면 tank 등의 상태가 가지는 특성을 쉽게 잊어버릴 수 있다.
    • 중간에 테스트가 실패하면 어디서부터 테스트가 실패했는지 파악하는 것이 힘들다.

문제를 해결하는 방법은 설정 코드를 더 분명하게 연결 짓는 것이다.

class OxygenTankTest {

    static OxygenTank createHalfFilledTank() {

        OxygenTank tank = OxygenTank.withCapacity(10_000);
        tank.fill(5_000);
        return tank;
    }

    @Test
    void depressurizingEmptiesTank() {

        OxygenTank tank = createHalfFilledTank();

        tank.depressurize();

        Assertions.assertTrue(tank.isEmpty());
    }

    @Test
    void completelyFillTankMustBeFull() {

        OxygenTank tank = createHalfFilledTank();

        tank.fillUp();

        Assertions.assertTrue(tank.isFull());
    }
}
  • 위 예제는 이전의 문제를 보완하여 테스트를 독립적으로 만든 케이스이다.
  • 테스트의 given 부분을 @BeforeEach 설정 메서드로 분리하는 대신 각 테스트에 다시 넣도록 변경했다.
  • 그 과정에서 static으로 createHalfFilledTank()라는 의미 있는 이름의 메서드를 만들어 기능을 분리했다.

@BeforeEach@BeforeAll 표기는 가능하면 사용하지 말자.
대신 테스트 전체를 설정하는 클래스를 만들어 사용하자.




테스트 매개변수화

  • 다음의 예제를 살펴보자.
class DistanceConversionTest {

    @Test
    void testConversionRoundTrip() {

        assertRoundTrip(1);
        assertRoundTrip(1_000);
        assertRoundTrip(9_999_999);
    }

    private void assertRoundTrip(int kilometers) {

        Distance expectedDistance = new Distance(
                 DistanceUnit.KILOMETERS,
                 kilometers);

        Distance actualDistance = expectedDistance
                 .convertTo(DistanceUnit.MILES)
                 .convertTo(DistanceUnit.KILOMETERS);

        Assertions.assertEquals(expectedDistance, actualDistance);
    }
}
  • 메서드 하나 또는 메서드 사슬을 같은 방법으로 테스트하되 여러 다양한 입력 매개변수로 테스트해야 할 때가 있다.
  • 하지만 위 예제처럼 테스트 메서드의 매개변수를 열거하기만 하면 테스트가 복잡해진다.
  • 또한 지금 상황에서는 testConversionRoundTrip()에서 assertRoundTrip()을 하나씩 실행하고 있는데, 중간에 실패하는 테스트가 있다면, 이후의 assertion은 실행되지 못하고 숨겨지는 문제도 있다.

문제를 해결하는 유일한 방법은 메개변수별로 각 테스트를 실행하고, 테스트당 assertion을 하나씩 넣는 것 뿐이다.
다행히 JUnit에는 이러한 상황에 사용할 수 있는 특수한 assertion이 있다.

class DistanceConversionTest {

	@ParameterizedTest(name = "#{index}: {0}km == {0}km->mi->km")
	@ValueSource(ints = {1, 1_000, 9_999_999})
	void testConversionRoundTrip(int kilometers) {

		Distance expectedDistance = new Distance(
				DistanceUnit.KILOMETERS,
				kilometers);

		Distance actualDistance = expectedDistance
				.convertTo(DistanceUnit.MILES)
				.convertTo(DistanceUnit.KILOMETERS);

		Assertions.assertEquals(expectedDistance, actualDistance);
	}
}
  • @ParameterizedTest@ValueSource를 사용하면 테스트를 매개변수화할 수 있다.
  • 이렇게 하면 매개변수와 실제 테스트 코드를 분리할 수 있다.
  • @ParameterizedTestname 속성에는 테스트에 대한 설명을 작성할 수 있다.
    • {index}는 테스트의 인덱스를 참조하고, 중괄호 {}는 테스트의 메서드 인수를 참조하고 있다.

이를 활용하여 CSV 파일과 같은 외부 소스나 메서드의 반환값도 매개변수로 넣을 수 있다.

테스트 매개변수화는 시작일 뿐이다.
입력과 기대 출력을 자동으로 생성하는 junit-quickcheck와 같은 속성 기반 테스트 라이브러리를 사용하면 코드에서 큰 매개변수 리스트를 관리하지 않아도 넓은 범위의 매개변수를 쉽게 처리할 수 있다.




경계 케이스 다루기

  • 다음의 예제를 살펴보자.
class TransmissionParserTest {

	@Test
	void testValidTransmission() {
		
		TransmissionParser parser = new TransmissionParser();
		
		Transmission transmission = parser.parse("032Houston, UFO sighted!");

		Assertions.assertEquals(32, transmission.getId());
		Assertions.assertEquals("Houston, UFO sighted!", transmission.getContent());
	}
}
  • 위 예제에서는 유효한 입력이 전형적인 출력을 생성하는 일반적인 실행 경로를 테스트하는 메서드 하나만 있다.
  • 하지만 다음과 같이 일반적이지 않은 입력 문자열이 들어오면 어떻게 해야 할까?
    • null 입력
    • ‘' 빈 문자열 입력
    • ‘ ' 여백 문자만을 포함하는 문자열 입력
    • ç©˙Ω∂å 영문자가 아닌 특수문자를 포함하는 문자열 입력
  • 경계 케이스는 코드의 일부와 깊은 연관이 있지만, 매개변수의 데이터 타입 경계 정도는 최소한 테스트해야 한다.

class TransmissionParserTest {

    @Test
    void testValidTransmission() {

        TransmissionParser parser = new TransmissionParser();

        Transmission transmission = parser.parse("032Houston, UFO sighted!");

        Assertions.assertEquals(32, transmission.getId());
        Assertions.assertEquals("Houston, UFO sighted!",
                 transmission.getContent());
    }

    @Test
    void nullShouldThrowIllegalArgumentException() {

        Executable when = () -> new TransmissionParser().parse(null);
        Assertions.assertThrows(IllegalArgumentException.class, when);
    }

    @Test
    void malformedTransmissionShouldThrowIllegalArgumentException() {

        Executable when = () -> new TransmissionParser().parse("∑åß∂©");
        Assertions.assertThrows(IllegalArgumentException.class, when);
    }
}
  • 이전의 예제와 비교했을 때 두 개의 테스트가 더 추가됐다.
  • 하나는 입력 값이 null인 경우, 또 하나는 입력 값이 영문자가 아닌 특수문자로만 이루어진 경우를 확인한다.
  • 이 밖에도 입력 값의 경우의 수는 무한하지만 테스트는 경제적이어야 한다.
  • 테스트에서 다루지 않았던 버그를 발견한다면 그때 버그 해결을 위한 테스트를 더 작성하면 된다.


이 장의 내용은 [자바 코딩의 기술: 똑똑하게 코딩하는 법]의 6장 내용을 정리한 것입니다.

profile
역시 개발자는 알아야 할 게 많다.

0개의 댓글

관련 채용 정보