-
[JUnit] JUnit 5를 사용한 TDDDEV/JUnit 2025. 1. 23. 16:32
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를 실천하며 시스템을 리팩터링하는 것은 코드의 품질을 제고함과 동시에 코드 커버리지를 높이는데에도 효과적이다.
'DEV > JUnit' 카테고리의 다른 글
[JUnit] 데이터베이스 애플리케이션 테스트 (1) 2025.01.22 [JUnit] REST API 테스트 하기 (2) 2025.01.07 [JUnit] Spring Boot 애플리케이션 테스트 (1) 2024.12.10 [JUnit] JUnit5 extension (확장 모델) (1) 2024.10.22 [JUnit] 모의 객체 프레임워크 EasyMock / JMock / Mockito (0) 2024.09.11