Dev/TDD

Junit In Action - TDD를 위한 테스트 원칙, 도구 및 활용 Review - BDD와 테스트 피라미드 전략

린네의 2025. 2. 25. 20:38

BDD 살펴보기

행위 주도 개발의 개념은 댄 노스가 2000년대 중반에 창안했다. BDD란 비즈니스 요구 사항과 목표를 소프트웨어의 동작과 기능으로 변환하는 개발 방법론을 말한다. BDD를 실천한다면 애플리케이션이 어떻게 동작하는지 구체적인 사례를 가지고 여러 팀간에 원활하게 소통할 수 있으며, 이해관계자 간의 협력을 장려함으로써 의미 있는 소프트웨어를 만들 수 있게 된다. 

 

BDD의 이점과 과제

✔️ 사용자 요구 충족 : 사용자는 구현에 신경을 덜 쓰고 애플리케이션의 기능에 더 집중할 수 있다.

✔️ 명확성 제공 : 시나리오는 소프트웨어가 수행해야 하는 작업을 명확히 한다. 시나리오는 기술에 친숙한 사람과 기술에 친숙하지 못한 사람 모두 이해할 수 있는 간단한 언어로 기술한다. 시나리오는 분할하거나 다른 시나리오를 추가하는 방식으로 모호성을 없앨 수 있다.

✔️ 변경 대응 : 시나리오는 소프트웨어 문서의 일부다. 애플리케이션과 함께 발전하는 살아 있는 문서인 것이다. 또한 새 변경 사항을 확인하는데에도 도움이 된다. 자동화된 인수 테스트는 변경이 생겼을 때 시스템이 회귀하는 것을 막는다.

✔️ 자동화 지원 : 시나리오의 단계가 이미 정의되어 있으므로 시나리오를 자동화된 테스트로 변환할 수 있다.

✔️ 비즈니스 가치에 집중 : BDD는 프로젝트에 쓸데없는 기능이 추가되는 것을 막는다. 기능의 우선순위를 정하는 데에도 사용할 수 있다.

✔️ 비용 절감 : 기능의 중요도에 따라 우선순위를 정하고 불필요한 기능을 만들지 않는다면 리소스가 낭비되는 일도 없을뿐더러 꼭 필요한 작업에만 리소스를 집중할 수 있다.

 

피라미드로 표현한 소프트웨어 테스트 수준

✔️ 단위 테스트 : 단위 테스트는 테스트 피라미드의 기초를 이룬다. 개별 단위를 이루는 메서드나 클래스 각각을 격리해 테스트하고, 테스트 대상이 예상한 대로 작동하는지 확인한다.

✔️ 통합 테스트 : 개별적으로 검증한 소프트웨어 구성 요소를 더 큰 덩어리로 합쳐서 테스트한다.

✔️ 시스템 테스트 : 시스템이 명세를 잘 따르고 있는지 평가하기 위해 전체 시스템에서 테스트를 수행한다. 시스템 테스트는 설계나 코드에 대한 지식이 필요하지 않으며 전체 시스템의 기능에 중점을 둔다. 주로 모의 객체를 사용해서 테스트를 수행한다. 

✔️ 인수 테스트 : 인수 테스트는 시나리오와 테스트 케이스를 사용하여 애플리케이션이 최종 사용자의 기대를 충족하는지를 검증한다. 

 

 

📌  단위테스트에서 인수테스트로 갈수록 단위가 복잡해지며 종류가 적어진다.

 

소프트웨어를 테스트할 때 주로 검증해야 할 대상은 아래와 같다.

 

✔️ 비즈니스 로직 : 프로그램이 실세계의 비즈니스 규칙과 절차를 이해하고 해석한 결과를 말한다.

✔️ 잘못된 입력 : 예를 들어 항공편 관리 시스템에서는 항공편을 예약할 때 음수를 입력할 수 없다.

✔️ 경곗값 조건 : 최댓값 또는 최솟값과 같은 도메인의 극단 값을 말한다. 경곗값 조건으로 승객이 0명 또는 만석인 항공편을 테스트할 수 있다.

✔️ 예상치 못한 조건 : 프로그램에서 정상적인 비즈니스 로직을 따르지 않는 조건을 말한다. 예를 들어 항공편은 일단 이륙한 순간부터는 출발지를 변경할 수 없다.

✔️ 불변성 : 프로그램 실행 중에 값이 변경되지 않아야 한다. 예를 들어 승객의 식별자는 프로그램 실행 중에 변경이 되지 않는다.

✔️ 회귀 : 시스템 업그레이드나 패치 후에 기존에는 없었던 버그가 생기면 안 된다. 

 

 

단위 테스트 예제

📝 요구사항

 

✔️  승객이 미국인이라면 식별자는 SSN로 한다. SSN의 처음 세 자리에는 000, 666, 900~999는 쓸 수 없다.

✔️ 승객이 미국인이 아닐 땐 SSN과 비슷한 규칙으로 식별자를 생성한다. 

✔️ 항공편이 정규식을 따라야 한다. 항공편명은 영문 대문자 2자리로 이루어진 항공사 코드와 3~4자리 숫자로 구성된다. 

✔️ 항공편에서 좌석보다 승객이 많을 수 없고, 한번 이륙한 후에는 출발지를 변경할 수 없으며, 착륙한 후에는 목적지를 변경할 수 없다.

✔️ 비행기에 좌석이 충분하지 않다면 승객을 추가할 수 없다.

✔️ 비행기가 이륙하면 메세지를 출력하고 비행기의 상태를 변경한다. 비행기가 착륙했을 때도 메세지를 출력하고 비행기의 상태를 변경한다.

 

 

📝  예제 코드

  • Passenger
public class Passenger {

    private String identifier;
    private String name;
    private String countryCode;
    // 미국인 식별자 
    // ㄴ SSN : 미국인일 때 사용. 000, 666, 900 ~ 999는 쓸 수 없음
    private String ssnRegex = "^(?!000|666)[0-8][0-9]{2}-(?!00)[0-9]{2}-(?!0000)[0-9]{4}$";
    // 미국인이 아닐 경우 식별자 
    private String nonUsIdentifierRegex = "^(?!000|666)[9][0-9]{2}-(?!00)[0-9]{2}-(?!0000)[0-9]{4}$";
    private Pattern pattern;


    public Passenger(String identifier, String name, String countryCode) {
    
        // 정규식 생성 
		pattern = countryCode.equals("US") ? Pattern.compile(ssnRegex) : Pattern.compile(nonUsIdentifierRegex);
        Matcher matcher = pattern.matcher(identifier);
        if (!matcher.matches()) {
            throw new RuntimeException("Invalid identifier");
        }

        if (!Arrays.asList(Locale.getISOCountries()).contains(countryCode)) {
            throw new RuntimeException("Invalid country code");
        }

        this.identifier = identifier;
        this.name = name;
        this.countryCode = countryCode;
    }

    public String getIdentifier() {
        return identifier;
    }
    
    // 승객식별자 생성시 정해진 정규식 규칙에 따라 생성 
    public void setIdentifier(String identifier) {
        Matcher matcher = pattern.matcher(identifier);
        if (!matcher.matches()) {
            throw new RuntimeException("Invalid identifier");
        }

        this.identifier = identifier;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getCountryCode() {
        return countryCode;
    }

    public void setCountryCode(String countryCode) {
        if (!Arrays.asList(Locale.getISOCountries()).contains(countryCode)) {
            throw new RuntimeException("Invalid country code");
        }

        this.countryCode = countryCode;
    }


    @Override
    public String toString() {
        return "Passenger " + getName() + " with identifier: " + getIdentifier() + " from " + getCountryCode();
    }
}

 

  • Flight 
// 항공편 
public class Flight {

    private String flightNumber;
    private int seats;
    private int passengers;
    private String origin;
    private String destination;
    private boolean flying;
    private boolean takenOff;
    private boolean landed;

	// 항공사 코드 정규식 :: 항공편면은 영문 대문자 2자리로 이루어진 항공사 코드와 3~4자리 숫자로 구성 
    private String flightNumberRegex = "^[A-Z]{2}\\d{3,4}$"; 
    private Pattern pattern = Pattern.compile(flightNumberRegex);


    public Flight(String flightNumber, int seats) {
        Matcher matcher = pattern.matcher(flightNumber);
        if (!matcher.matches()) {
            throw new RuntimeException("Invalid flight number");
        }
        this.flightNumber = flightNumber;
        this.seats = seats;
        this.passengers = 0;
        this.flying = false; // 운행 중 
        this.takenOff = false; // 이륙 후 
        this.landed = false; // 착륙 후 
    }

    public String getFlightNumber() {
        return flightNumber;
    }

    public int getSeats() {
        return seats;
    }

	// 좌석수보다 승객수가 클 수 없음 
    public void setSeats(int seats) { 
        if (passengers > seats) {
            throw new RuntimeException("Cannot reduce the number of seats under the number of existing passengers!");
        }

        this.seats = seats;
    }

    public int getPassengers() {
        return passengers;
    }

    public String getOrigin() {
        return origin;
    }

    // 이륙후에는 출발지를 바꿀 수 없음 
    public void setOrigin(String origin) {
        if (takenOff) {
            throw new RuntimeException("Flight cannot change its origin any longer!");
        }

        this.origin = origin;
    }

    public String getDestination() {
        return destination;
    }

	// 착륙후에는 목적지를 바꿀 수 없음 
    public void setDestination(String destination) {
        if (landed) {
            throw new RuntimeException("Flight cannot change its destination any longer!");
        }
        this.destination = destination;
    }

    public boolean isFlying() {
        return flying;
    }

    public boolean isTakenOff() {
        return takenOff;
    }

    public boolean isLanded() {
        return landed;
    }

    @Override
    public String toString() {
        return "Flight " + getFlightNumber() + " from " + getOrigin() + " to " + getDestination();
    }

    // 좌석이 좌석보다 승객이 많을 수 없음 
    public void addPassenger() {
        if (passengers >= seats) {
            throw new RuntimeException("Not enough seats!");
        }
        passengers++;
    }

    public void takeOff() {
        System.out.println(this + " is taking off");
        flying = true;
        takenOff = true;
    }

    public void land() {
        System.out.println(this + " is landing");
        flying = false;
        landed = true;
    }

}

 

  • Passenger의 기능을 검증
public class PassengerTest {

    @Test
    //  미국인인 승객 객체가 올바른 식별자를 사용하여 생성되었는지 검증 
    public void testPassengerCreation() {
        Passenger passenger = new Passenger("123-45-6789", "John Smith", "US");
        assertNotNull(passenger);
    }

    @Test
    // 미국인이 아닌 객체가 올바른 식별자를 사용하여 생성되었는지 검증 
    public void testNonUsPassengerCreation() {
        Passenger passenger = new Passenger("900-45-6789", "John Smith", "GB");
        assertNotNull(passenger);
    }

    @Test
     // 미국인 승객 객체가 잘못된 식별자로 객체를 생성했을 때 에러를 던지는지 검증 
    public void testCreatePassengerWithInvalidSsn() {
        assertThrows(RuntimeException.class,
                () -> {
                    Passenger passenger = new Passenger("123-456-789", "John Smith", "US");
                });
        assertThrows(RuntimeException.class,
                () -> {
                    Passenger passenger = new Passenger("900-45-6789", "John Smith", "US");
                });
    }

    @Test
    // 미국인이 아닌 승객 객체가 잘못된 식별자로 객체를 생성했을 때 에러를 던지는지 검증 
    public void testCreatePassengerWithInvalidNonUsIdentifier() {
        assertThrows(RuntimeException.class,
                () -> {
                    Passenger passenger = new Passenger("900-456-789", "John Smith", "GB");
                });
        assertThrows(RuntimeException.class,
                () -> {
                    Passenger passenger = new Passenger("123-45-6789", "John Smith", "GB");
                });
    }

    @Test
    // 국가 코드가 유효하지 않다면 객체 생성시 예외를 던지는지 검증 
    public void testCreatePassengerWithInvalidCountryCode() {
        assertThrows(RuntimeException.class,
                () -> {
                    Passenger passenger = new Passenger("900-45-6789", "John Smith", "GJ");
                });
    }

    @Test
    // SSN이 유효하지 않다면 객체 생성시 예외를 던지는지 검증 
    public void testSetInvalidSsn() {
        assertThrows(RuntimeException.class,
                () -> {
                    Passenger passenger = new Passenger("123-45-6789", "John Smith", "US");
                    passenger.setIdentifier("123-456-789");
                });

    }

    @Test
    // 미국인 승객 객체에 대해 유효한 SSN을 설정할 수 있는지 검증 
    public void testSetValidSsn() {
        Passenger passenger = new Passenger("123-45-6789", "John Smith", "US");
        passenger.setIdentifier("123-98-7654");
        assertEquals("123-98-7654", passenger.getIdentifier());
    }

    @Test
    // 미국인이 아닌 승객 객체에 대해 유효한 식별자를 설정할 수 있는지 검증 
    public void testSetValidNonUsIdentifier() {
        Passenger passenger = new Passenger("900-45-6789", "John Smith", "GB");
        passenger.setIdentifier("900-98-7654");
        assertEquals("900-98-7654", passenger.getIdentifier());
    }

    @Test
      // 잘못된 국가 코드를 설정했을 떄 예외를 던지는지 검증 
    public void testSetInvalidCountryCode() {
        assertThrows(RuntimeException.class,
                () -> {
                    Passenger passenger = new Passenger("123-45-6789", "John Smith", "US");
                    passenger.setCountryCode("GJ");
                });

    }

    @Test
    // 유효한 국가코드를 설정할 수 있는지 검증 
    public void testSetValidCountryCode() {
        Passenger passenger = new Passenger("123-45-6789", "John Smith", "US");
        passenger.setCountryCode("GB");
        assertEquals("GB", passenger.getCountryCode());
    }

    @Test
    // toString 메서드의 동작 검증 
    public void testPassengerToString() {
        Passenger passenger = new Passenger("123-45-6789", "John Smith", "US");
        passenger.setName("John Brown");
        assertEquals("Passenger John Brown with identifier: 123-45-6789 from US", passenger.toString());
    }


}

 

  • Flight의 기능을 검증 
public class FlightTest {

    @Test
    // 항공편이 정상적으로 생성되는지 검증 
    public void testFlightCreation() {
        Flight flight = new Flight("AA123", 100);
        assertNotNull(flight);
    }

    @Test
    // 항공편명의 숫자를 2자리, 5자리 등으로 유효하지 않게 설정했을 때 예외를 던지는지 검증 
    public void testInvalidFlightNumber() {
        assertThrows(RuntimeException.class,
                () -> {
                    Flight flight = new Flight("AA12", 100);
                });
        assertThrows(RuntimeException.class,
                () -> {
                    Flight flight = new Flight("AA12345", 100);
                });
    }

    @Test
    // 유효한 항공편명으로 항공편 객체가 정상적으로 생성되는지 검증 
    public void testValidFlightNumber() {
        Flight flight = new Flight("AA345", 100);
        assertNotNull(flight);
        flight = new Flight("AA3456", 100);
        assertNotNull(flight);
    }

    @Test
    // 좌석 수 내에서만 승객을 추가할 수 있는지 검증 
    public void testAddPassengers() {
        Flight flight = new Flight("AA1234", 50);
        flight.setOrigin("London");
        flight.setDestination("Bucharest");
        for (int i = 0; i < flight.getSeats(); i++) {
            flight.addPassenger();
        }
        assertEquals(50, flight.getPassengers());
        assertThrows(RuntimeException.class,
                () -> {
                    flight.addPassenger(); 
                });

    }

    @Test
    public void testSetInvalidSeats() {
        Flight flight = new Flight("AA1234", 50);
        flight.setOrigin("London");
        flight.setDestination("Bucharest");
        for (int i = 0; i < flight.getSeats(); i++) {
            flight.addPassenger();
        }
        assertEquals(50, flight.getPassengers());
        assertThrows(RuntimeException.class,
                () -> {
                    flight.setSeats(49);     // 좌석수를 현재 승객수보다 적게 설정할 수 없는지 검증 
                });
    }

    @Test
    public void testSetValidSeats() {
        Flight flight = new Flight("AA1234", 50);
        flight.setOrigin("London");
        flight.setDestination("Bucharest");
        for (int i = 0; i < flight.getSeats(); i++) {
            flight.addPassenger();
        }
        assertEquals(50, flight.getPassengers());
        flight.setSeats(52);   // 좌석수를 현재 승객수보다 크게 설정 가능한지 검증 
        assertEquals(52, flight.getSeats());
    }

    @Test
    // 비행기가 이륙한 다음에는 출발지를 변경할 수 없는지 검증
    public void testChangeOrigin() {
        Flight flight = new Flight("AA1234", 50);
        flight.setOrigin("London");
        flight.setDestination("Bucharest");
        flight.takeOff; // 이륙 
        assertEquals(true, flight.isFlying());
        assertEquals(true, flight.isTakenOff());
        assertEquals(false, flight.isLanded());
        assertThrows(RuntimeException.class,
                () -> {
                    flight.setOrigin("Manchester");
                });
    }

    @Test
    // 비행기 상태 변경 검증 
    public void testLand() {
        Flight flight = new Flight("AA1234", 50);
        flight.setOrigin("London");
        flight.setDestination("Bucharest");
        flight.takeOff(); // 이륙 
        assertEquals(true, flight.isTakenOff());
        assertEquals(false, flight.isLanded());
        flight.land(); // 착륙
        assertEquals(true, flight.isTakenOff());
        assertEquals(true, flight.isLanded());
        assertEquals(false, flight.isFlying());
    }

    @Test
    // 비행기가 착륙한 다음에는 도착지를 변경할 수 없는지 검증 
    public void testChangeDestination() {
        Flight flight = new Flight("AA1234", 50);
        flight.setOrigin("London");
        flight.setDestination("Bucharest");
        flight.takeOff(); // 이륙
        flight.land(); // 착륙 
        assertThrows(RuntimeException.class,
                () -> {
                    flight.setDestination("Sibiu");
                });
    }
}