[JAVA] 코드를 올바르게 드러내는 방법
자바 코딩의 기술
6. 올바르게 드러내기
코드는 꼭 테스트하라. 아니면 사용자가 하게 된다.
— 데이브 토마스, 앤드류 헌트
인간은 모두 실수를 한다. 얼마나 뛰어나든, 교육을 얼마나 잘 받았든, 경험이 얼마나 풍부 하든 가끔 버그가 있는 코드를 작성한다.
Java에는 내장된 테스트 지원이 없지만 그 역할은 JUnit 프레임워크가 대신한다. JUnit 프레임워크는 테스트를 자동으로 실행하기 위한 사실상의 Java 표준이다.
Java에서 단위 테스트 작성의 사실상 표준인 JUnit의 가장 최신 버전은 JUnit5이다. 테스트 정의를 위해서는 메소드위에 @Test 애노테이션을 추가하면 실행이 가능하다.
6.1 Given-When-Then으로 테스트 구조화
Person.java
public class Person {
private String name;
private Gender gender;
public Person() {
}
public Person(String name, Gender gender) {
this.name = name;
this.gender = gender;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Gender getGender() {
return gender;
}
public void setGender(Gender gender) {
this.gender = gender;
}
}
Gender.java
public enum Gender {
MALE,
FEMALE
}
PersonTest.java
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PersonTest {
@Test
void setGenderMAIE() {
Person person = new Person();
person.setGender(Gender.MALE);
Assertions.assertTrue(Gender.MALE == person.getGender());
}
}
테스트는 적절하게 역할을 수행한다. 하지만 위 테스트 코드는 단순히 코드를 나열하고 있기 때문에 의도를 파악하기가 쉽지 않다.
given when then
일반적으로 테스트는 given, when, then이라는 세 개의 햄식 부분으로 구성된다.
- 숫자 5가 주어졌을 때 (given)
- 숫자 2를 더할 경우 (when)
- 숫자 7이 나타나야 한다. (then)
given
실제 테스트를 준비하는 단계이다. 이 단계에서는 테스트를 하기위한 기능을 실행하기 위한 전제 조건이 모두 포함되어야 한다.
when
실제로 테스트 하려는 연산을 수행한다.
then
when에서 수행한 결과가 실제로 기대한 결과인지 명확히 드러낸다.
이제 이러한 개념을 위에서 작성한 테스트 코드에 적용시켜보았다.
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PersonTest {
@Test
void setGenderMAIE() {
// given
Person person = new Person();
// when
person.setGender(Gender.MALE);
// then
Assertions.assertTrue(Gender.MALE == person.getGender());
}
}
단순히 줄바꿈을 추가만 해주어도 테스트 코드의 가독성을 크게 높일 수 있다.
6.2 의미 있는 assertion 사용하기
컴퓨터 프로그래밍에서 assertion은 프로그램 안에 추가하는 참-거짓을 미리 가정하는 문이다.
JUnit에 들어 있는 가장 기본적인 assertion은 assertTrue() 이다. 해당 조건을 만족하는지 만족하지 않는지를 보여주는 boolean값이다.
Java는 이러한 boolean 표현식을 이해할 수 있지만 협업을 진행하는 동료 개발자와 더 편하게 일을 하기 위해서는 더 이해하기 쉬운 방식으로 작성해야 한다. 그렇기 때문에 assertTrue() 는 그 의미를 파악하기에 적합하지 않다.
위 테스트 코드는 기능상 올바르게 동작한다. 하지만 테스트에 실패하게 되면 단순히 실패한 지점을 표시할 뿐, 어떠한 테스트를 실패했는지 알 수 없다.
의도적으로 테스트를 실패하였다.
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PersonTest {
@Test
void setGenderMAIE() {
// given
Person person = new Person();
// when
person.setGender(Gender.MALE);
// then
Assertions.assertTrue(Gender.FEMALE == person.getGender());
}
}
해당 조건이 false이기 때문에 실패한 것은 알지만 무엇이 잘못되었는지 파악할 수 없다.
class PersonTest {
@Test
void setGenderMAIE() {
// given
Person person = new Person();
// when
person.setGender(Gender.MALE);
// then
Assertions.assertEquals(Gender.FEMALE, person.getGender());
}
}
assertTrue() -> assertEquals() 로 교체하였다. 해당 메소드는 좀 더 나은 오류 메시지를 제공한다.
앞선 테스트는 실패했다는 정보만 얻을 수 있었지만 이제는 어떠한 이유로 실패했는지 까지 확인할 수 있다.
JUnit에는 이러한 assertEquals() 말고도 다양한 메소드를 제공한다. 더 나은 오류 메시지를 얻기위해 검증하려는 테스트에 가장 적합한 assertion을 선택해야 한다.
6.3 실제 값보다 기대 값을 먼저 보이기
class PersonTest {
@Test
void setGenderMAIE() {
// given
Person person = new Person();
// when
person.setGender(Gender.MALE);
// then
Assertions.assertEquals(person.getGender(), Gender.FEMALE);
}
}
위 코드는 올바른 테스트 처럼 보인다. 하지만 테스트가 실행되어 실패하면 문제를 확인할 수 있다.
바로 오류 메시지가 거꾸로 표시되는 것이다. 실패한 테스트의 메시지를 읽을 때 최소한 그 메시지 자체는 무조건 옳다고 가정하기 때문에 가정 자체가 틀리면 안 된다.
이 문제의 해결 방법은 간단하다. assertEquals() 메소드의 두 인수를 바꾸어주기만 하면 된다.
오류 메시지는 이제 올바른 정보를 제공한다. 먼저 무엇을 원하는지 생각해야 한다.
6.4 합당한 허용값 사용하기
public class Person {
private String name;
private Gender gender;
private double height;
private double weight;
public Person() {
}
public Person(String name, Gender gender, double height, double weight) {
this.name = name;
this.gender = gender;
this.height = height;
this.weight = weight;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Gender getGender() {
return gender;
}
public void setGender(Gender gender) {
this.gender = gender;
}
public double getHeight() {
return height;
}
public void setHeight(double height) {
this.height = height;
}
public double getWeight() {
return weight;
}
public void setWeight(double weight) {
this.weight = weight;
}
public void addHeight(double height) {
this.height += height;
}
public void addWeight(double weight) {
this.weight += weight;
}
public double getBmi() {
return this.weight / ((this.height / 100) * (this.height / 100));
}
}
부동 소수점 테스트를 위해서 person에 키와 몸무게 필드를 추가 하였고, 해당 필드를 기반으로 bmi 지수를 얻기위한 getBmi 메소드를 추가하였다.
테스트는 기존 몸무게가 70kg 였지만 5kg이 더 쩠다고 가정하고 진행하였다,
참고로 실제 BMI 계산을 통해 측정해보면 23.76이라는 값을 얻을 수 있었다.
@Test
void testGetBmi() {
Person person = new Person("hyeonic", Gender.MALE, 178.0, 70.0);
person.addWeight(5.0);
Assertions.assertEquals(23.67, person.getBmi());
}
위 데이터를 기반으로 테스트를 진행하였다.
정상적으로 BMI 값을 가져왔지만 예상했던 소수점 이하의 값이 달랐기 때문에 실패하게 되었다.
해당 문제는 부동소수점 산술 연산에서 소수를 표현하는 방식에서 비롯되었다. 간단히 정리하면 부동소수점 수를 모두 유한한 비트 수로 표현하기는 어렵다. 그렇기 때문에 대부분의 프로그래밍 언어에서 부동소수점 수를 근사화 한다. 그렇기 때문에 끝수처리 오차가 발생할 수 있다.
위 테스트에서 178.0, 70.0 또한 double 타입으로 근사화된 수일 뿐이다. 그렇기 때문에 산술 연산이 일어나면 기대한 값과 매우 가까운 근사값으로 표현된다.
그렇기 때문에 부동소수점 연산을 테스트하기 위해서는 소수점 자릿수를 명시해야 한다.
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PersonTest {
static final double TOLERANCE = 0.01;
@Test
void testGetBmi() {
Person person = new Person("hyeonic", Gender.MALE, 178.0, 70.0);
person.addWeight(5.0);
Assertions.assertEquals(23.67, person.getBmi(), TOLERANCE);
}
}
위 테스트를 실행하면 정상적으로 통과되는 것을 확인할 수 있었다.
부동소수점 연산에 완전한 일치는 없다. 기대값과 실제값 사이의 약간의 차이를 허용해야 한다.
assertEquals() 메소드의 경우 delta라는 허용값을 지원한다. 위 메소드로 float나 double을 사용할 때는 항상 자릿수를 알아야 하고 받아들일 수 있는 허용 수준을 명시해야 한다.
6.5 예외 처리는 JUnit에 맡기기
import java.io.IOException;
public class Person {
private String name;
private Gender gender;
private double height;
private double weight;
...
public void throwException() throws IOException {
throw new IOException();
}
}
Person 클래스에 예외를 던지는 메소드가 있다고 가정한다.
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.*;
class PersonTest {
@Test
void newPerson() {
Person person = new Person();
try {
person.throwException();
} catch (IOException e) {
Assertions.fail(e.getMessage());
}
}
@Test
void newPersonFail() {
Person person = new Person();
try {
person.throwException();
Assertions.fail("fail");
} catch (IOException e) {
}
}
}
위 코드를 살펴보면 서로 다른 방식으로 예외를 사용하고 있다.
첫 번째 테스트는 IOException이 발생하면 실패하고 두 번째 테스트는 반대로 성공한다. 이때 fail() assertion을 실행해 테스트를 의도적으로 실패시켰다.
하지만 첫 번째 테스트는 IOException을 잡아 실패시키되 실패한 테스트를 더 잘 추적할 수 있도록 예외 메시지를 남긴다. 하지만 두 번째 테스트는 예외가 발생하면 성공한다. 제어의 흐름을 catch 블록에 넘기고 fail()을 호출하지 않기 때문이다.
JUnit에는 예외를 처리하는 내장 메커니즘이 있다.
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.*;
class PersonTest {
@Test
void newPerson() throws IOException {
Person person = new Person();
person.throwException();
Assertions.assertEquals(person.getHeight(), 0.0);
}
@Test
void newPersonFail() {
Person person = new Person();
Executable when = () -> person.throwException();
assertThrows(IOException.class, when);
}
}
첫 번째 테스트는 가장 기초적인 테스트이다. 모든 JUnit 테스트는 어떤 예외도 발생하지 않는다는 암묵적인 assertion을 포함한다. 그렇기 때문에 명시적으로 추가하지 않고 JUnit이 알아서 처리하도록 놔둔다. 테스트 코드는 매우 간소해지고 예외가 발생하면 JUnit은 실패에 대한 전체 스택 추척을 표시한다.
두 번째 테스트는 assertThrows() 를 활용한 방법이다. 예외가 생기길 바라는 JUnit5의 Executable 타입 형태로 메소드만 assertion에 전달한다.
6.6 테스트 설명하기
위에서 작성한 테스트들은 Given-When-Then으로 테스트를 구조화하였고 어떠한 예외를 던지길 원하는지 명시한다. 하지만 각각의 테스트에는 좋은 이름과 설명서가 존재하지 않는다. 결국 그것을 파악하기 위해서는 테스트 코드를 하나하나 읽어가며 의도를 파악해야 한다.
테스트에 실패하게 되면 클래스명과 메소드명만 확인할 수 있다. 그렇기 때문에 실제로 무엇을 테스트하는지 불분명하다. 주석을 추가하는 방법도 있지만 JUnit5에서는 @DisplayName이라는 애노테이션이 제공된다.
@Test
@DisplayName("person에 성별을 MALE로 설정하는 테스트")
void setGenderMAIE() {
// given
Person person = new Person();
// when
person.setGender(Gender.MALE);
// then
Assertions.assertEquals(person.getGender(), Gender.MALE);
}
@Test
@Disabled("[why it's disabled] TODO: [What's the plan to enable again]")
void testGetBmi() {
Person person = new Person("hyeonic", Gender.MALE, 178.0, 70.0);
person.addWeight(5.0);
Assertions.assertEquals(23.67, person.getBmi(), TOLERANCE);
}
@DisplayName를 사용하면 공백과 특수 기호를 활용하여 간결한 테스트 설명을 작성할 수 있다. 기존에 읽기 힘들고 공백을 작성할 수 없던 메소드명에서 좀 더 확실하게 테스트에 대한 설명을 작성할 수 있다.
테스트를 삭제하지 않고 비활성화 하기 위한 @Disabled 애노테이션도 있다. 위 애노테이션에 비슷한 형식을 사용하여 왜 비활성화 했는지, 나중에 테스트가 어떻게 변할지, 향후 개발자가 다시 활성화할 때 필요한 정보를 제공 할 수 있다.
6.7 독립형 테스트 사용하기
class PersonTest {
static final double TOLERANCE = 0.01;
Person person;
@BeforeEach
void setUp() {
person = new Person("hyeonic", Gender.MALE, 178.0, 70.0);
}
@Test
@DisplayName("person에 성별을 MALE로 설정하는 테스트")
void setGenderMAIE() {
// given
// when
person.setGender(Gender.MALE);
// then
Assertions.assertEquals(person.getGender(), Gender.MALE);
}
@Test
void testGetBmi() {
// given
// when
person.addWeight(5.0);
// then
Assertions.assertEquals(23.67, person.getBmi(), TOLERANCE);
}
@BeforeEach나 @BeforeAll은 given 부분에 필요한 공통 설정 코드를 추출하고 한 번만 작성할 수 있게 도와준다. 코드 중복이 사라지기 때문에 이점이 많지만 테스트를 이해하는데 어려움이 동반한다.
@BeforeEach의 경우 여러 테스트 메소드에서 사용되지만 클래스 최상단에 위치하기 때문에 맨 처음 읽게 된다. 이러한 배치는 단일 테스트 메소드 마다 설정 메소드의 역할을 다시 떠올려야 한다. 이것의 의미는 테스트가 더 이상 독립적이지 않다는 것이다.
위 코드는 간단히 두 가지의 테스트만 진행하여 매우 간단하지만 테스트의 개수가 늘어나거나 복잡해지면 힘들 것이다.
이것의 해결책으로 테스트와 초기 설정 코드를 좀 더 명확히 연결 짓는 것이다.
class PersonTest {
static final double TOLERANCE = 0.01;
static Person createPerson() {
return new Person("hyeonic", Gender.MALE, 178.0, 70.0);
}
@Test
@DisplayName("person에 성별을 MALE로 설정하는 테스트")
void setGenderMAIE() {
// given
Person person = createPerson();
// when
person.setGender(Gender.MALE);
// then
Assertions.assertEquals(person.getGender(), Gender.MALE);
}
@Test
void testGetBmi() {
// given
Person person = createPerson();
// when
person.addWeight(5.0);
// then
Assertions.assertEquals(23.67, person.getBmi(), TOLERANCE);
}
각각의 단일 테스트는 given, when, then 부분을 하나의 테스트 메소드 안에서 바로 연결하여 독립적인 테스트가 되었다. 초기 설정 부분을 의미있는 static 메소드로 분리하였을 뿐이다.
@BeforeEach나 @BeforeAll은 흔히 사용하지만 그만큼 문제점을 동반하고 있다. 테스트가 독립적이고 테스트 메소드만 보아도 코드 스크롤 없이 전체 테스트를 이해할 수 있는 것이 더욱 훌륭한 테스트이다.
테스트 설정에 변수가 세 개 이상 들어가면 @BeforeEach 대신 테스트 전체를 설정하는 클래스를 생성하는 것이 더욱 바람직하다.
6.8 테스트 매개변수화
메소드 하나 또는 메소드 사슬을 같은 방법으로 테스트하되 여러 다양한 매개변수로 테스트해야 할 때가 존재한다. 메소드가 얼마나 넓은 범위의 값에 대해 동작하는지 알기 위해서 이다. 테스트 메소드의 매개변수로 열거하면 쉽게 할 수 있지만 테스트가 복잡해진다.
하지만 단순히 메소드를 열거하는 것은 문제가 있다. 만약 열거된 테스트를 실행하는 중간에 테스트가 실패하게 되면 후에 열거된 테스트는 무시된다.
해결할 수 있는 유일한 방법은 매개변수별로 각 테스트를 실행하고 테스트마다 assertion을 하나씩 넣는 것뿐이다. 다행히 JUnit에는 이러한 상황에 맞는 특수한 assetion이 존재한다.
@ParameterizedTest
@ValueSource(ints = {60, 70, 80})
void isBmiLessThen25(double weight) {
Person person = new Person();
person.setHeight(180);
person.setWeight(weight);
Assertions.assertTrue(25.0 > person.getBmi());
}
6.9 경계 케이스 다루기
32비트의 정수를 하나하나 테스트하기 위해서는 2의 32제곱, 약 40억개가 넘는 테스트가 필요하다. 경우의 수를 모두 테스트 하는 것은 무리가 있기 때문에 경계 케이스를 다루어야 한다.
String
— null
— ““(빈 문자열)
— “ ”(여백 문자만 포함하는 문자열)
— 영문자가 아닌 특수문자를 포함하는 문자열
Int
-- 0
— 1
— -1
— Integer.MAX_VALUE
— Integer.MIN_VALUE
double
— 0
— 1.0
— -1.0
— Double.MAX_VALUE
— Double.MIN_VALUE
Object[]
— null
— {}
— {null}
— {new Object[], null}
List
— null
— Collections.emptyList()
— Collections.singletonList(null)
— Arrays.asList(new Object(), null)
이것이 전부는 아니다. 경계 케이스는 코드 일부와 깊은 연관이 있지만 일반적으로 매개변수의 데이터 타입 경계 정도는 최소한 테스트해야 한다.
6.10 6장에서 배운 내용
코드 테스트는 더욱 정교한 소프트웨어를 만든다. 실제 기능 구현 코드보다 더 많은 테스트 코드를 작성해야 한다.
테스트는 간결하고 핵심을 전달해야 한다. 어떤 테스트 하나가 실패할 때마다 어디서부터 오류를 찾아야 하는지 코드에서 명확히 알려줘야 한다.
생각정리
무의미한 테스트를 짜는 것은 아닌지, 놓치고 있는 부분은 없는지 등 테스트 코드에 대한 고민이 많았다. 협업을 진행하는 개발자들에게 테스트 코드는 자신이 작성한 기능을 증명할 수 있는 좋은 수단이라고 생각한다. 그렇기 때문에 테스트 코드 또한 읽기 좋게 구조화가 되어 있어야 한다는 필요성을 느끼게 되었다.
이 책에서는 자세히는 아니지만 간단히 테스트 코드를 구조화하고 코드량을 줄일 수 있는 여러가지 방법에 대해 소개하고 있다. 다른 파트보다 먼저 테스트를 다룬 부분부터 살펴보았다.
필자는 항상 프로그래밍에 대해 공부할 때 책이나 관련 강의를 먼저 찾아보고 지식을 습득하는 편이다. 하지만 항상 의문이 들었던 것은 어떤 강의든 테스트에 중요성에 대해 언급하지만 테스트 코드를 작성하기 위한 방법보다는 기능에 대한 부분을 더욱 중점으로 다루었다. 그렇기 때문에 테스트 코드는 더더욱 미지의 세계 처럼 다가왔다.
테스트와 관련된 책들은 생각보다 많았다. 익숙하지 않다 보니 자연스럽게 관심도가 떨어졌을 뿐이다. 이 책을 시작으로 좀 더 심도있게 다루는 다양한 책들을 살펴보아야 겠다.
References.
사이먼 하러, 요르그 레너드, 리누스 디에츠, 『자바 코딩의 기술』, 심지현 옮김, 길벗(2020), p155-182.