DEV/JUnit

[JUnit] JUnit 5를 사용한 TDD

Imvory 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를 실천하며 시스템을 리팩터링하는 것은 코드의 품질을 제고함과 동시에 코드 커버리지를 높이는데에도 효과적이다.