[JUnit] JUnit 5를 사용한 TDD
TDD 핵심 개념
TDD란 요구사항을 테스트로 뽑아낸 다음, 테스트를 통과하는 프로그램을 개발하는 과정을 되도록 짧게 반복하는 프로그래밍 기법이다.
1. 코드를 작성하기 전에 실패하는 테스트를 작성한다.
2. 테스트를 통과할 수 있는 가장 단순한 코드를 작성한다.
통념에 따르면 프로그램 개발이란 코드를 작성한 다음, 실행이 잘되는지 하나씩 따져가며 테스트하는 것을 의미했다.
코드를 작성한다 => 테스트 한다 => (반복한다)
그러나 TDD는 이 개발 주기를 뒤집는다.
테스트 한다 => 코드를 작성한다 => (반복한다)
즉 TDD안에서 테스트는 설계를 주도하고, 테스트 대상 메서드의 첫 번째 클라이언트가 되는것을 의미한다.
TDD 장점
- 분명한 목표를 가지고 코드를 짤 수 있을뿐더러, 애플리케이션이 해야 하는 일만 정확하게 개발할 수 있다.
- 기존의 소스 코드에 버그가 생기는 것은 막아주면서, 새로운 기능을 빠르게 개발할 수 있다.
- 테스트는 애플리케이션의 설계 명세로 기능한다.
TDD의 개발주기는 사실 한가지가 더 있다.
테스트 한다 => 코드를 작성한다 => 리팩터링한다 => (반복한다)
리팩터링은 소스코드의 외부동작에는 영향을 주지않고 내부 구조만을 개선하는 방식으로 소프트웨어를 수정하는 작업이다. 이때 외부 동작에 영향을 주지 않는다는 것을 증명하기 위해 테스트를 사용할 수 있다.
TDD로 개발하지 않은 애플리케이션에 TDD 적용하기
Passenger 클래스
public class Passenger {
private String name;
private boolean vip;
public Passenger(String name, boolean vip) {
this.name = name;
this.vip = vip;
}
public String getName() {
return name;
}
public boolean isVip() {
return vip;
}
}
Flight 클래스
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Flight {
private String id;
private List<Passenger> passengers = new ArrayList<Passenger>();
private String flightType;
public Flight(String id, String flightType) {
this.id = id;
this.flightType = flightType;
}
public String getId() {
return id;
}
public List<Passenger> getPassengersList() {
return Collections.unmodifiableList(passengers);
}
public String getFlightType() {
return flightType;
}
public boolean addPassenger(Passenger passenger) {
switch (flightType) {
case "Economy":
return passengers.add(passenger);
case "Business":
if (passenger.isVip()) {
return passengers.add(passenger);
}
return false;
default:
throw new RuntimeException("Unknown type: " + flightType);
}
}
public boolean removePassenger(Passenger passenger) {
switch (flightType) {
case "Economy":
if (!passenger.isVip()) {
return passengers.remove(passenger);
}
return false;
case "Business":
return false;
default:
throw new RuntimeException("Unknown type: " + flightType);
}
}
}
AirPort 클래스에서 테스트 역할을 하는 main 메서드
public class Airport {
public static void main(String[] args) {
Flight economyFlight = new Flight("1", "Economy");
Flight businessFlight = new Flight("2", "Business");
Passenger james = new Passenger("James", true);
Passenger mike = new Passenger("Mike", false);
businessFlight.addPassenger(james);
businessFlight.removePassenger(james);
businessFlight.addPassenger(mike);
economyFlight.addPassenger(mike);
System.out.println("비즈니스 항공편 승객 리스트:");
for (Passenger passenger : businessFlight.getPassengersList()) {
System.out.println(passenger.getName());
}
System.out.println("이코노미 항공편 승객 리스트:");
for (Passenger passenger : economyFlight.getPassengersList()) {
System.out.println(passenger.getName());
}
}
}
비즈니스 로직 설명 : 이코노미 항공편에는 모든 승객을 추가 할 수 있으나, 비즈니스 항공편에는 vip 승객만 추가 할 수 있다. 그리고 항공편에서 일반 승객은 삭제할 수 있지만, vip 승객은 삭제할 수 없다.
여기서 TDD를 적용해보자.
먼저 junit5 의존성을 추가한다.
testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.11.4'
testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: '5.11.4'
그리고 승객 유형과 항공편 유형에 따라 총 4가지 경우의 수가 있는 추가 및 삭제 기능을 테스트해야한다.
중첩 테스트를 사용하여 테스트 해보자.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class AirportTest {
@DisplayName("Given 이코노미 항공편에서")
@Nested
class EconomyFlightTest {
private Flight economyFlight;
@BeforeEach
void setUp() {
economyFlight = new Flight("1", "Economy");
}
@Test
@DisplayName("이코노미 항공편과 일반 승객에 관한 테스트")
public void testEconomyFlightRegularPassenger() {
Passenger mike = new Passenger("Mike", false);
assertEquals("1", economyFlight.getId());
assertEquals(true, economyFlight.addPassenger(mike));
assertEquals(1, economyFlight.getPassengersList().size());
assertEquals("Mike", economyFlight.getPassengersList().get(0).getName());
assertEquals(true, economyFlight.removePassenger(mike));
assertEquals(0, economyFlight.getPassengersList().size());
}
@Test
@DisplayName("이코노미 항공편과 VIP 승객에 관한 테스트")
public void testEconomyFlightVipPassenger() {
Passenger james = new Passenger("James", true);
assertEquals("1", economyFlight.getId());
assertEquals(true, economyFlight.addPassenger(james));
assertEquals(1, economyFlight.getPassengersList().size());
assertEquals("James", economyFlight.getPassengersList().get(0).getName());
assertEquals(false, economyFlight.removePassenger(james));
assertEquals(1, economyFlight.getPassengersList().size());
}
}
@DisplayName("Given 비즈니스 항공편에서")
@Nested
class BusinessFlightTest {
private Flight businessFlight;
@BeforeEach
void setUp() {
businessFlight = new Flight("2", "Business");
}
@Test
@DisplayName("비즈니스 항공편과 일반 승객에 관한 테스트")
public void testBusinessFlightRegularPassenger() {
Passenger mike = new Passenger("Mike", false);
assertEquals(false, businessFlight.addPassenger(mike));
assertEquals(0, businessFlight.getPassengersList().size());
assertEquals(false, businessFlight.removePassenger(mike));
assertEquals(0, businessFlight.getPassengersList().size());
}
@Test
@DisplayName("비즈니스 항공편과 VIP 승객에 관한 테스트")
public void testBusinessFlightVipPassenger() {
Passenger james = new Passenger("James", true);
assertEquals(true, businessFlight.addPassenger(james));
assertEquals(1, businessFlight.getPassengersList().size());
assertEquals(false, businessFlight.removePassenger(james));
assertEquals(1, businessFlight.getPassengersList().size());
}
}
}
해당 테스트를 돌리면 기존 Airport 클래스는 테스트 커버리지가 0%이다.
자체 main 메서드가 있으므로 테스트되지 않았기 때문이다.
테스트가 클라이언트 역할을 했던 Airport 클래스는 필요가 없어졌고, 그외 Flight 클래스에 getFlightType 메서드는 한번도 사용되지 않아 코드 커버리지를 100% 달성하지 못했다. 이는 불필요한 소스 코드를 정리하기 위해 리팩터링을 해야한다는 점을 시사한다.
TDD를 실천하며 리팩터링하기
flightType에 따라 switch문을 나누고 있는 부분에 default 절에 해당하는 경우는 일어나지 않는다.(=불필요한 코드)
그러나 default절을 삭제하면 소스가 컴파일되지않는다. 또다른 문제로는 기존 코드에서 새로운 항공편 유형이 추가되면 분기문이 늘어날 것이다. 따라서 다형성을 활용해 switch가 사용된 분기문을 개선시키고자한다.
Flight 추상클래스
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public abstract class Flight {
private String id;
List<Passenger> passengers = new ArrayList<Passenger>();
public Flight(String id) {
this.id = id;
}
public String getId() {
return id;
}
public List<Passenger> getPassengers() {
return Collections.unmodifiableList(passengers);
}
public abstract boolean addPassenger(Passenger passenger);
public abstract boolean removePassenger(Passenger passenger);
}
- Flight 클래스를 추상 클래스로 선언하여 다형성 설계의 기초로 사용한다.
- passengers는 디폴트 패키지로 선언하여 같은 패키지에 속한 하위 클래스들이 직접 상속할 수 있게 한다.
- addPassenger, removePassenger 메서드를 추상 메서드로 선언하여 구현을 하위 클래스에 위임한다.
Flight 클래스를 상속받은 BusinessFlight 클래스
public class BusinessFlight extends Flight {
public BusinessFlight(String id) {
super(id);
}
@Override
public boolean addPassenger(Passenger passenger) {
if (passenger.isVip()) {
return passengers.add(passenger);
}
return false;
}
@Override
public boolean removePassenger(Passenger passenger) {
return false;
}
}
Flight 클래스를 상속받은 EconomyFlight 클래스
public class EconomyFlight extends Flight {
public EconomyFlight(String id) {
super(id);
}
@Override
public boolean addPassenger(Passenger passenger) {
return passengers.add(passenger);
}
@Override
public boolean removePassenger(Passenger passenger) {
if (!passenger.isVip()) {
return passengers.remove(passenger);
}
return false;
}
}
이와같이 분기문을 다형성으로 바꾸도록 리팩터링하면 분기 처리 때문에 소스가 길어지는 것을 막고 메서드를 간명하게 작성할 수 있다. 게다가 일어날 일이 없는 default 절에 굳이 예외를 던질 필요가 없어졌다.
API를 리팩터링한 내용은 테스트에도 적용되어야한다.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class AirportTest {
@DisplayName("Given 이코노미 항공편에서")
@Nested
class EconomyFlightTest {
private Flight economyFlight;
@BeforeEach
void setUp() {
economyFlight = new EconomyFlight("1");
}
@Test
@DisplayName("이코노미 항공편과 일반 승객에 관한 테스트")
public void testEconomyFlightRegularPassenger() {
Passenger mike = new Passenger("Mike", false);
assertEquals("1", economyFlight.getId());
assertEquals(true, economyFlight.addPassenger(mike));
assertEquals(1, economyFlight.getPassengers().size());
assertEquals("Mike", economyFlight.getPassengers().get(0).getName());
assertEquals(true, economyFlight.removePassenger(mike));
assertEquals(0, economyFlight.getPassengers().size());
}
@Test
@DisplayName("이코노미 항공편과 VIP 승객에 관한 테스트")
public void testEconomyFlightVipPassenger() {
Passenger james = new Passenger("James", true);
assertEquals("1", economyFlight.getId());
assertEquals(true, economyFlight.addPassenger(james));
assertEquals(1, economyFlight.getPassengers().size());
assertEquals("James", economyFlight.getPassengers().get(0).getName());
assertEquals(false, economyFlight.removePassenger(james));
assertEquals(1, economyFlight.getPassengers().size());
}
}
@DisplayName("Given 비즈니스 항공편에서")
@Nested
class BusinessFlightTest {
private Flight businessFlight;
@BeforeEach
void setUp() {
businessFlight = new BusinessFlight("2");
}
@Test
@DisplayName("비즈니스 항공편과 일반 승객에 관한 테스트")
public void testBusinessFlightRegularPassenger() {
Passenger mike = new Passenger("Mike", false);
assertEquals(false, businessFlight.addPassenger(mike));
assertEquals(0, businessFlight.getPassengers().size());
assertEquals(false, businessFlight.removePassenger(mike));
assertEquals(0, businessFlight.getPassengers().size());
}
@Test
@DisplayName("비즈니스 항공편과 VIP 승객에 관한 테스트")
public void testBusinessFlightVipPassenger() {
Passenger james = new Passenger("James", true);
assertEquals(true, businessFlight.addPassenger(james));
assertEquals(1, businessFlight.getPassengers().size());
assertEquals(false, businessFlight.removePassenger(james));
assertEquals(1, businessFlight.getPassengers().size());
}
}
}
기존에는 Flight 객체를 생성했으나 EconomyFlight 객체와 BusinessFligtht 객체를 생성하는 것으로 바꾸었다.
그리고 사용하지 않는 Airport 클래스를 삭제했다.
해당 테스트를 수행하면 코드 커버리지가 100%가 나온다. TDD를 실천하며 시스템을 리팩터링하는 것은 코드의 품질을 제고함과 동시에 코드 커버리지를 높이는데에도 효과적이다.