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");
});
}
}