코드는 꼭 테스트하라, 아니면 사용자가 하게 된다. - 데이트 토마스, 앤드류 헌트
@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());
}
}
when
단계에서 수행한 결과가 실제 기대했던 결과인지 확인하는 단계Assertions
의 assertXxx
절로 결과를 확인한다.
given
,when
,then
에 추가로 새 줄로 그루핑헤서 구조를 더 명확히 할 수 있다.
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);
}
}
assertEquals(double expected, double actual, double delta)
메서드를 제공한다.expected
에는 기댓값, actual
에는 실제 값을 넣는다.delta
에는 허용값을 넣는다.delta = 0.0001
이다.정리하면
assertEquals()
에float
이나double
을 사용할 때는, 자릿수를 파악한 상태로 허용 수준을 명시해야 한다.
화폐와 같이 오차를 허용하지 않는 연산이 필요할 때에는 다음의 방법이 있다.
- 숫자를
double
이나float
에 저장하는 대신long
변수에 저장BigDecimal
사용
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);
}
}
@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
를 사용하면 테스트를 매개변수화할 수 있다.@ParameterizedTest
의 name
속성에는 테스트에 대한 설명을 작성할 수 있다.{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장 내용을 정리한 것입니다.