ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [JUnit] JUnit 5를 사용한 TDD
    DEV/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를 실천하며 시스템을 리팩터링하는 것은 코드의 품질을 제고함과 동시에 코드 커버리지를 높이는데에도 효과적이다.

    댓글

Designed by Tistory.