Test

소프트웨어 테스트 원칙

hongyb 2024. 11. 10. 22:53

소프트웨어 테스트 원칙

단위 테스트

단위 테스트 : 코드의 개별 구성 요소(주로 메서드나 함수)가 올바르게 동작하는지 검증하는 테스트

 

단위 테스트 특집

  1. 독립적: 단위 테스트는 가능한 한 외부 종속성이나 환경의 영향을 받지 않고 개별 단위의 동작만을 검증
  2. 작은 범위: 단위 테스트는 함수, 메서드 또는 클래스와 같은 작은 단위를 테스트
  3. 자동화 가능: JUnit같은 테스트 프레임워크를 사용하여 자동화
public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}

public class CalculatorTest {
    @Test
    void testAdd() {
        Calculator calculator = new Calculator();
        int result = calculator.add(2, 3);
        assertEquals(5, resul);
    }
}

 

단위 테스트 장점

  1. 높은 테스트 커버리지 : 단위 테스트는 기능 테스트로 수행하기 어렵거나 불가능한 오류 조건에 대해 쉽게 테스트할 수 있다.
  2. 팀 생산성 향상 : 대규모 어플리케이션 작업에서 단위 테스트를 사용하면 다른 컴포넌트가 준비되지 않아도 테스트 가능하다. → mock
  3. 디버깅 작업 감소 : 단위 테스트를 통해 코드 중 어디에 문제가 있는지 구체적인 부분을 알 수 있다. 애플리케이션을 일일이 디버깅할 필요가 줄어든다.
  4. 리팩터링 용이 : 리팩토링하는 과정에서 가장 중요한 것은 “결과의 변경 없이” 코드의 구조를 변경하는 것이다. 소스를 리팩터링하거나 고칠 때 오류를 만들 위험이 있다. 단위 테스트를 통해 리팩터링에 확신을 갖게 된다
  5. 기능 구현에 도움 : 단위 테스트는 코드가 독립적으로 테스트될 수 있도록 구조적 설계를 강요한다. 단위 테스트를 고려하지 않으면 테스트하기 어렵고 유지 보수하기 힘든 코드가 만들어진다.
  6. 코드 문서화 : 단위 테스트 그 자체로 API 사용 예제가 만들어진다. 테스트 코드를 작성하기만 해도 유스 케이스를 명확히 알 수 있다.
  7. 다양한 지표 제공 : 커버리지 지표 및 테스트 통과, 실패 진행사항을 추적할 수 있다.

 

통합 테스트

통합 테스트 : 대상 환경에서 실행 가능한 컴포넌트 간의 상호작용을 테스트

 

시스템의 상호작용을 통해, 단위 테스트에서는 발견하기 어려운 문제를 찾아낼 수 있다.

  • 여러 컴포넌트가 함께 동작하는지를 확인
  • 데이터베이스, 파일 시스템 등 외부 리소스와의 상호작용을 포함
  • 주로 모의 객체를 사용하지 않고 실제 의존성을 테스트

 

상호작용 테스트 설명
객체 객체를 인스턴스화 하고 다른 객체의 메서드를 호출한다. 다른 클래스에 속한 객체 간에 어떻게 협력해서 문제를 해결할 수 있는지 확인 가능하다.
서비스 애플리케이션을 데이터베이스, 다른 외부 리소스와 함께 테스트 한다.
서브 시스템 인터페이스인 프런트엔드와 비즈니스 로직인 백엔드가 구분된 아키텍쳐를 가진 애플리케이션에서 통합 테스트 한다.
public class EmailService {
    public boolean sendEmail(String email, String message) {
        // 이메일 전송 로직 (예를 들어 실제 이메일 서버와 통신)
        return true;
    }
}

public class UserService {
    private EmailService emailService;

    public UserService(EmailService emailService) {
        this.emailService = emailService;
    }

    public boolean registerUser(String email) {
        // 사용자 등록 로직 후 이메일 전송
        return emailService.sendEmail(email, "Welcome!");
    }
}

public class UserServiceIntegrationTest {

    @Test
    void testRegisterUser() {
        // 실제 EmailService 사용, Mock 객체 사용하지 않는다
        EmailService emailService = new EmailService();
        UserService userService = new UserService(emailService);
        
        boolean result = userService.registerUser("test@example.com");
        assertTrue(result);
    }
}

 

시스템 테스트

시스템 테스트 : 시스템이 구체화된 요구 사항을 만족하는지 평가하기 위해 완전한 통합 환경에서 수행하는 테스트 소프트웨어가 실제 환경에서 제대로 작동하는지 확인하는 마지막 단계 중 하나이다.

 

시스템 테스트 목적

  • 시스템의 종합적인 검증.
  • 모든 모듈과 기능이 의도한 대로 작동하는지 확인.
  • 성능, 보안, 신뢰성 등의 비기능적 요구사항도 만족하는지 확인.
  • 의존성: 실제 데이터베이스, 네트워크, API, 파일 시스템 등 외부 리소스를 포함한 전체 환경을 설정하여 테스트합니다.

시스템 테스트 환경에서 모든 외부 시스템이 항상 사용 가능한 것은 아니기 때문에 테스트 더블이나 모의 객체를 사용한다.

 

테스트 더블 :

테스트 대상 객체를 대신하는 모든 대체 객체. 실제로 동작할 필요는 없고, 테스트 시에 필요한 상황을 시뮬레이션하거나 제어하기 위해 사용된다. 테스트 더블은 실제 의존성 대신 사용되므로, 테스트 환경에서 복잡한 설정이나 외부 의존성을 최소화할 수 있고, 특정 조건을 쉽게 테스

트할 수 있다.

 

스텁

  • 테스트에서 사용할 수 있는 고정된 반환값이나 미리 정의된 행동을 제공
  • 특정 입력에 대해 예상되는 출력을 반환하는 용도로 사용
  • 외부 컴포넌트 동작을 단순화하여 미리 정해진 응답 반환

모킹

  • 메서드 호출 여부호출 횟수, 호출된 인자 등을 검증
  • 단순히 값을 반환하는 것에 그치지 않고 행동을 관찰하고, 검증

 

인수 테스트

수 테스트는 애플리케이션이 사용자의 요구사항을 충족하는지, 즉 비즈니스 요구사항과 기능적 요구사항을 만족하는지 검증하는 테스트 과정. 인수 테스트는 소프트웨어가 실제로 출시되기 전에 최종 사용자가 기대한 대로 동작하는지 확인하는 마지막 단계로 볼 수 있다. → 사용자 관점

 

인수 테스트는 Given, When, Then 키워드를 사용하여 표현하기도 한다. Given, When, Then은 BDD(Behavior Driven Development)에서 사용하는 시나리오 기반 테스트 작성 방식의 핵심 구성 요소이다.

Given (주어진 상황) : 테스트의 초기 상태 또는 전제 조건을 설명
When (행동) : 사용자가 시스템에서 수행하는 특정 행동이나 이벤트

Then (결과) : 기대되는 결과를 설명한다. 특정 행동이 발생한 후 시스템이 어떻게 반응해야 하는지를 설명한다. 비즈니스 목표를 달성했는지 확인 가능.

 

[예시]

기능 : 사용자 주식 트레이드

시나리오 : 트레이드가 마감되기 전에 사용자가 판매를 요청

 

"Given" 나는 MSFT 주식을 100 가지고 있다. 그리고 나는 APPL 주식을 150 가지고 있다.

"When" 나는 MSFT 주식 20을 팔도록 요청했다.

"Then" 나는 MSFT 주식 80 가지고 있어야 한다. 그리고 MSFT 주식 20이 판매 요청이 실행되었어야 한다.

 

블랙박스 테스트 VS 화이트박스 테스트

블랙박스 테스트

블랙 박스 테스트는 시스템의 내부 상태나 동작에 대한 지식이 없을 때 수행하는 테스트다. 주로 입력출력에만 집중하여 시스템의 작동 여부를 확인한다. 블랙박스 테스트는 외부적인 시스템 인터페이스를 사용한다.

 

화이트박스 테스트

화이트박스 테스트는 소프트웨어의 내부 구조, 코드, 알고리즘을 기반으로 시스템을 테스트하는 방법. 테스트 작성자는 소프트웨어의 코드를 분석하고, 코드의 흐름이나 내부 동작을 기반으로 테스트 케이스를 설계한다. 주로 개발자가 코드의 로직과 흐름을 검증하는 데 사용한다.

 

장단점 비교

장점

블랙박스 테스트 화이트박스 테스트
비개발자여도 테스트 할 수 있다. GUI가 필요하지 않다.
개발과 독립적으로 수행할 수 있다. 개발자가 제어하므로 다양한 실행 경로를 커버할 수 있다.
사용자 중심적이며, 설계 명세와 다른 부분이 무엇인지 알 수 있다. 작은 버그나 예외적인 상황에서 발생할 수 있는 결함을 찾는 데 유리하다. 특히 경계값 분석에 효과적

단점

블랙박스 테스트 화이트박스 테스트
입력할 수 있는 경우의 수 제한적 프로그래밍 지식 필요
커버되지 않은 테스트 있을 수 있다. 구현 변경시 테스트 다시 작성해야 한다.
테스트 중복 가능. 테스트와 구현 결합

빠른 사용자 피드백이 필요하거나 가능 → 블랙박스 테스트

수동으로 실행할 수 있는 테스트 스크립트나 GUI를 제공한다면 고객이 애플리케이션에 대해 직관적으로 알 수 있다.

 

테스트 커버리지가 필요한 경우 → 화이트박스 테스트

여러 유형의 테스트를 활용하여 코드 커버리지를 높일 수 있고, 애플리케이션을 리팩터링 하거나 개선하는 데에 유리하다.

 

테스트 커버리지

테스트 커버리지(Test Coverage)는 소프트웨어 테스트 과정에서 코드의 얼마나 많은 부분이 테스트되었는지를 측정하는 지표이다. 가장 기본적인 지표는 다음과 같다.

 

구문 커버리지 : 코드의 각 문장이 실행되었는지 측정한다. 코드 한 줄이 한 번 이상 실행된다면 충족.

public class Calculator {
    public String divide(int a) {
		    System.out.println("calculate")//1
        if (a > 0) { //2
            return "Cannot divide by zero"; //3
        }
        return String.valueOf(a); //4
    }
}

a가 음수일 시 3개의 구문만 실행 → 커버리지는 75%(3/4)

 

브랜치 커버리지 : 조건문(예: if-else)의 각 분기가 테스트되었는지 확인합. 코드 내 모든 분기점에서 가능한 모든 경로가 테스트되었는지 측정

public void checkNumber(int num) {
    if (num < 0) { //분기 1
        ...
    } else if(num == 0) { //분기 2
        ...
    } else { //분기 3
			  ...
    }
}

 

조건 커버리지 : 각 개별 조건이 true, false 값을 가질 수 있도록 테스트하는 것을 목표로 한다.

public boolean isValid(int age, boolean isMember) {
    return age > 18 && isMember;
}
  • age = 20, isMember = true: 두 조건 모두 true
  • age = 15, isMember = true: 첫 번째 조건 false, 두 번째 조건 true
  • age = 20, isMember = false: 첫 번째 조건 true, 두 번째 조건 false
  • age = 15, isMember = false: 두 조건 모두 false

 

얼마만큼의 코드를 자동화한 단위 테스트로 계산해야 할까? 대답할 필요조차 없다. 모조리 다 해야 한다. 모. 조. 리! 100% 테스트 커버리지를 권장하냐고? 권장이 아니라 강력히 요구한다. 작성한 코드는 한 줄도 빠짐없이 전부 테스트해야 한다. 군말은 필요 없다. ― 클린 코더 (로버트 마틴 저)
소프트웨어의 본질은 해당 소프트웨어의 사용자를 위해 도메인에 관련된 문제를 해결하는 능력에 있다. 그 밖의 매우 중요하다 할 수 있는 기능도 모두 이러한 기본적인 목적을 뒷받침하는 데 불과하다. - 에릭 에반스

 

높은 테스트 커버리지가 필요한 경우

  1. 핵심 기능: 핵심적인 비즈니스 로직이나 보안 관련 코드
  2. 오픈 소스나 공공 API: 많은 사용자나 다른 개발자가 사용하는 라이브러리나 API는 높은 테스트 커버리지 필요.
  3. 복잡한 코드: 복잡한 로직이 많을수록 테스트 커버리지를 높이는 것이 유리 → 복잡한 부분일수록 예상치 못한 문제들이 발생할 가능성이 높다.

 

테스트는 중요한 곳에 집중해야 하고 개발자에게 큰 문제가 없다는 확신을 주어야 한다. 복잡성이 높은 곳에 인지 부하로 인해 버그가 생길 가능성이 높고, 비즈니스 로직이 존재하는 도메인 모델 혹은 알고리즘에 문제가 생기면 단순히 시스템 오류로 끝나지 않는다.

 

테스트하기 쉬운 코드

public API 테스트

퍼블릭 API는 외부 코드나 시스템에서 의존하고 있을 가능성이 크다. API가 변경되거나 잘못 작성되면 해당 API를 사용하고 있는 모든 외부 코드가 제대로 작동하지 않을 수 있다. 변하지 않고 오류 없는 public 메서드를 만든 후 철저한 테스트를 통해 위험을 방지해야 한다.

의존성 줄이기

클래스 내에서 새로운 객체를 직접 또는 간접적으로 인스턴스화하면, 그 클래스는 해당 객체에 강하게 결합되어(타이트 커플링) 의존성이 커진다. 이는 클래스 내부에서 직접 객체를 생성하므로, 테스트 시 해당 객체를 모킹(Mock)하거나 대체하기 어려워진다.

class Vehicle {
    Driver d;
    boolean hadDriver = true;

    Vehicle(Driver d) {
        this.d = d;
    }

    private void setHadDriver(boolean hasDriver) {
        this.hadDriver = hasDriver;
    }
}

 

간단한 생성자 만들기

테스트 단계

  1. 테스트할 클래스 인스턴스화
  2. 클래스를 특정 상태로 설정
  3. 작업을 수행
  4. 클래스 상태 검증

생성자가 없다면 1번, 2번을 같이 수행하게 된다. 테스트 케이스에서 생성자에 클래스를 미리 정의하면 다양한 상태를 만들기 힘들다.

 

이를 해결하기 위해 클래스를 특정 상태로 설정하는 것은 별도의 작업으로 분리해야 한다. 테스트 케이스에서 동일한 객체를 여러 시나리오로 재사용할 때, setter를 이용해 객체의 일부 상태만 변경하면서 다양한 경우를 테스트할 수 있다. 이 방식은 객체를 매번 새로 생성하지 않으면서 테스트 코드의 중복을 줄이는 데 도움을 준다.

class Car {
    private int maxSpeed;
    
    public void setMaxSpeed(int maxSpeed) {
        this.maxSpeed = maxSpeed;
    }
}

 

데메테르 법칙 따르기

데메테르 법칙(Law of Demeter)은 객체 지향 설계에서 "잘 알지 못하는 객체와 상호작용하지 말라"는 원칙을 의미한다. 객체는 자신이 직접적으로 알고 있는 객체(예: 자신의 필드, 메서드 파라미터, 메서드에서 생성된 객체)와만 상호작용해야 하며, 메서드 체이닝이나 연속적인 접근을 통해 다른 객체의 내부 객체를 직접 접근하는 것을 피해야 한다.

데메테르 법칙의 구체적인 규칙

  • 객체는 자기 자신(this)과만 상호작용해야 한다.
  • 객체는 자신의 필드(인스턴스 변수)와 상호작용할 수 있다.
  • 객체는 자신의 메서드에서 생성된 객체와 상호작용할 수 있다.
  • 객체는 메서드의 매개변수로 전달된 객체와 상호작용할 수 있다.
  • 객체는 이러한 객체들의 직접적인 메서드만 호출할 수 있다. 즉, 객체는 "친구의 친구"에게 메시지를 전달하지 않는다.
public class Order {
    private Customer customer;

    public Customer getCustomer() {
        return customer;
    }
}

public class Customer {
    private Address address;

    public Address getAddress() {
        return address;
    }
}

public class Address {
    private String city;

    public String getCity() {
        return city;
    }
}

// 데메테르 법칙 위반 코드
public class OrderService {
    public void printCustomerCity(Order order) {
        // Order -> Customer -> Address -> city에 접근
        System.out.println(order.getCustomer().getAddress().getCity());
    }
}

 

데메테르 법칙 위반 예시 테스트. Address, Customer, Order를 모두 모킹해야 하므로 좋지 않은 테스트이다.

public class OrderServiceTest {

    @Test
    public void testPrintCustomerCity() {
        // 여러 단계의 객체 모킹 필요
        Address mockAddress = mock(Address.class);
        when(mockAddress.getCity()).thenReturn("New York");

        Customer mockCustomer = mock(Customer.class);
        when(mockCustomer.getAddress()).thenReturn(mockAddress);

        Order mockOrder = mock(Order.class);
        when(mockOrder.getCustomer()).thenReturn(mockCustomer);

        OrderService orderService = new OrderService();
        orderService.printCustomerCity(mockOrder);

        verify(mockAddress).getCity();
    }
}

 

숨은 의존성과 전역 상태 피하기

숨은 의존성 : 코드에서 명시적으로 드러나지 않지만 내부적으로 다른 객체나 모듈에 의존하는 경우를 뜻한다. 숨은 의존성은 객체가 어떤 다른 객체나 리소스에 의존하고 있는지를 알기 어렵게 만든다. → 모킹 어려워진다.

 

OrderRepository를 모킹하기 어려워진다.

public class OrderService {
    // 숨은 의존성: OrderRepository를 직접 생성
    private OrderRepository orderRepository = new OrderRepository();

    public Order findOrderById(int id) {
        return orderRepository.findById(id);
    }
}

 

전역 상태 : 애플리케이션에서 여러 곳에서 공유되고 접근 가능한 상태(예: 전역 변수, 싱글톤 객체, 스태틱 필드 등)를 의미

전역 객체에만 접근을 공유하는 게 아니라 전역 객체가 참조하는 모든 객체를 공유하는 문제점 발생한다.

class Reservation {
    public void makeReservation() {
        Manager manager = Manager.getManager();
        ..
    }
}

제너릭 메서드 사용하기

정적 코드(static)을 사용 시 다형성을 활용하지 못하면 애플리케이션과 테스트에 코드를 재사용하지 않게 된다. 이런 상황은 애플리케이션과 테스트에서 코드 중복이 생길 수 있어 피하는 게 좋다.

 

제네릭 메서드를 사용하면 하나의 테스트 메서드로 여러 타입을 동시에 테스트할 수 있어, 테스트 코드의 양도 줄고 중복 테스트를 피할 수 있습니다.

//제너릭 사용 X -> 코드 중복
public class Utils {
    public static String getFirstString(List<String> list) {
        return list.isEmpty() ? null : list.get(0);
    }

    public static Integer getFirstInteger(List<Integer> list) {
        return list.isEmpty() ? null : list.get(0);
    }
}

//제너릭 사용
public class Utils {
    public static <T> T getFirstElement(List<T> list) {
        return list.isEmpty() ? null : list.get(0);
    }
}

@Test
public void testGetFirstElement() {
    List<String> stringList = Arrays.asList("apple", "banana", "cherry");
    List<Integer> integerList = Arrays.asList(1, 2, 3);

    // 같은 메서드를 다양한 타입으로 테스트 가능
    assertEquals("apple", Utils.getFirstElement(stringList));
    assertEquals(Integer.valueOf(1), Utils.getFirstElement(integerList));
}

상속보다는 합성

상속은 클래스 간 강한 결합을 유발한다. 반면 합성(composition)은 객체의 기능을 독립된 구성 요소로 분리하여 주입할 수 있기 때문에, 필요한 부분만을 쉽게 교체하거나 모의(mock)할 수 있어 테스트가 더 유연해진다.

상속

// Engine 클래스
public class Engine {
    public void start() {
        System.out.println("엔진이 시동을 겁니다.");
    }
}

// Car 클래스 (Engine을 상속)
public class Car extends Engine {
    public void drive() {
        System.out.println("자동차가 주행합니다.");
    }
}

 

합성

// Engine 클래스
public class Engine {
    public void start() {
        System.out.println("엔진이 시동을 겁니다.");
    }
}

// Car 클래스 (Engine을 합성)
public class Car {
    private Engine engine;

    public Car() {
        this.engine = new Engine();
    }

    public void startCar() {
        engine.start();
        System.out.println("자동차가 시동을 겁니다.");
    }

    public void drive() {
        System.out.println("자동차가 주행합니다.");
    }
}

// 테스트 코드에서 Mock 엔진 주입. 독립적인 테스트 가능하다.
class CarTest {
    @Test
    void testStart() {
        Engine mockEngine = Mockito.mock(Engine.class); // Mock 객체 생성
        Car car = new Car(mockEngine);
        car.start();
        verify(mockEngine).start(); // 엔진의 start 메서드가 호출되었는지 검증
    }
}

분기문보다 다형성 활용

분기문이 많으면 클래스가 복잡해지고 인스턴스화가 어려울 수 있다. 다형성을 사용하면 객체를 인터페이스나 부모 클래스를 통해 주입받기 때문에, 테스트할 때 쉽게 모의 객체로 교체할 수 있다.

 

코드가 다음과 같을 때 테스트 코드를 알아보자.

public class DocumentPrinter {
    public void printDocument(Document document) {
        document.pringDocument();
    }
}

public abstract class Document {
    public abstract void printDocument();
}

public class WordDocument extends Document {
    @Override
    public void printDocument() {
        printWORDDocument();
    }
}

public class PDFDocument extends Document {
    @Override
    public void printDocument() {
        printPDFDocument();
    }
}

 

테스트 코드는 다음과 같다.

public class DocumentPrinterMockTest {

    @Test
    void testPrintWordDocumentWithMock() {
        Document wordDocument = Mockito.mock(WordDocument.class); 
        DocumentPrinter printer = new DocumentPrinter();
        printer.printDocument(wordDocument);
      
        verify(wordDocument, times(1)).printDocument();
    }

    @Test
    void testPrintPDFDocumentWithMock() {
        Document pdfDocument = Mockito.mock(PDFDocument.class);
        DocumentPrinter printer = new DocumentPrinter();
        printer.printDocument(pdfDocument);
    
        verify(pdfDocument, times(1)).printDocument();
    }
}

 

결론 : 좋은 코드는 테스트 하기 쉽다.

좋은 코드는 “변경하기 쉬운”이라는 형용사를 가지고 있는데요.

이 의미는 약한 결합도를 가지고 있는 코드를 뜻하며 반대로 강결합이 되어있는 코드는 유지비용이 증감되어 저품질코드로 분류됩니다.

그렇다면 강결합으로 이루어진 코드를 테스트하기 쉬울까요?

당연히 매우 어렵습니다.

외부의 영향을 받거나 내부적으로 의존성을 가지고 있는 코드는 변경에 유연하게 대응하지 못하고 재사용하기 어려운 코드들입니다.

그렇기 때문에 테스트를 작성하기 어려운 코드가 만들어지는 것입니다.

그렇다고 테스트하기 쉬운 코드가 모두 좋은 코드가 되는 것은 아닙니다. 하지만 저희는 테스트를 작성하면서 하나의 좋은 코드의 지표를 아래와 같이 세울 수 있게 됩니다.

“만약 내가 작성한 코드가 테스트하기 어려운 코드라면 냄새나는 코드일 가능성이 높아.”

 

자료출처 : https://tech.inflab.com/20230404-test-code/#네번째-좋은-코드는-테스트하기-쉽다

  1. 단일 책임 원칙 : 테스트 작성 단순
  2. 의존성 주입 : mock 객체 쉽게 사용 가능
  3. 의존성 최소화 : 의존성이 적어질수록 테스트 쉬워진다.

TDD

TDD는 소프트웨어 개발 방법론 중 하나로, 테스트를 먼저 작성한 후에 그 테스트를 통과하는 코드를 작성하는 방식이다. 코드를 작성하기 전에 먼저 해당 코드가 어떻게 동작해야 할지를 테스트로 정의하는 개발 프로세스이다.

TDD 기본 싸이클

  1. Red (실패하는 테스트 작성)
    • 구현하려는 기능에 대한 테스트 코드를 작성
    • 테스트가 실패하는 것을 확인함으로써, 아직 해당 기능이 없다는 것을 명확하게 인지
    @Test
    void testAdd() {
        Calculator calc = new Calculator();
        assertEquals(5, calc.add(2, 3));  // 아직 add 메서드가 구현되지 않음.
    }
  1. Green (테스트 통과를 위한 최소한의 코드 작성)
    • 테스트를 통과시키기 위해 필요한 최소한의 코드를 작성
    • 코드를 반복적으로 실행 후, 테스트가 통과되도록 작성
    public class Calculator {
        public int add(int a, int b) {
            return a + b;  // 테스트 통과를 위한 간단한 코드 작성
        }
    }
  1. Refactor (리팩토링)
    • 중복 제거하거나, 가독성이나 성능을 개선하는 등 더 나은 코드 구조로 리팩토링.
    • 리팩토링 후에도 테스트가 여전히 통과하는지 확인하여, 기능이 깨지지 않았음을 보장

TDD의 장점

  1. 높은 코드 품질:개발자는 기능에 대한 명한 요구 사항을 이해하고 이를 테스트할 수 있는 구조로 코드를 작성하게 된다.
  2. 안정적인 리팩토링 : 리팩토링을 통해 코드의 구조를 개선할 때, 테스트 코드가 기능의 정상 동작을 보장해 주기 때문에 기능이 손상되지 않은 상태에서 안전하게 코드를 개선할 수 있다
  3. 더 나은 설계: TDD를 사용하면 테스트 가능한 코드를 작성해야 하기 때문에 자연스럽게 의존성을 줄이고 모듈화된 설계로 이어진다

TDD 단점

  • 초기 개발 속도 저하: 코드를 작성하기 전에 테스트부터 작성해야 하기 때문에, 초기 개발 속도는 느려질 수 있다
  • 테스트 유지보수 비용 증가: 기능이 변화할 때마다 테스트 코드를 유지보수해야 한다. 추가적인 비용 발생 가능
  • 잘못된 테스트 설계 위험: 테스트 설계를 잘못하거나, 지나치게 구현에 의존적인 테스트를 작성하면 리팩토링 시 유지 보수 비용 증가.

BDD

BDD는 비즈니스 요구 사항을 기반으로 사용자의 동작을 정의하고, 그 동작에 대한 테스트를 작성하는 방식이다.

주로 Given-When-Then 패턴을 사용

  • Given: 시스템의 초기 상태(상황, 전제 조건)
  • When: 사용자가 시스템에 취하는 행동(액션)
  • Then: 그 행동에 대한 기대 결과(결과)

BDD와 TDD

테스트 케이스를 작성하는 데는 시간과 비용이 많이 들어가지만, 이미 작성된 요구사항이나 기획서를 기반으로 테스트 케이스를 작성하면 이 비용을 줄일 수 있다. BDD와 TDD는 상호 배타적인 관계가 아니라 상호 보완적인 관계이다. 프로젝트에서 BDD를 통해 시나리오를 검증하고, TDD를 통해 그 시나리오에 사용되는 각 모듈을 검증하는 방식이 효과적이다.

 

개발 주기 내에서 테스트하기

  1. 개발
    • 개발자의 작업 장소
    • SCM에 여러번 커밋한다.
    • 개발 단계에서 비즈니스 로직에 대해 단위 테스트를 실행한다.
  1. 통합 단계
    • 모든 컴포넌트 포함하여 애플리케이션 빌드
    • 자동화된 빌드를 수행하여 애플리케이션을 패키징하는 단계이다.
    • 단위 테스트와 기능 테스트 수행. 기능 테스트는 블랙박스 테스트로 실행
    • 시스템 몇몇 요소가 빠져있어 일부 테스트만 실행
  1. 인수 단계, 부하 테스트 단계
    • 부하 테스트 : 애플리케이션에 부하 주어 적절하게 확장하는지 확인
    • 인수 단계 : 고객이 시스템을 인수하는 단계
    • 통합 단계와 동일한 테스트 수행
  1. 예비 운영
    • 실제 운영 배포 직전 수행하는 마지막 검증 단계
    • 인수 단계에서 실행한 테스트 실행하는 것 권장

 

자료출처

https://tv.kakao.com/channel/3693125/cliplink/414004682

https://velog.io/@kimyj1234/TDD-BDD#bdd-기본-패턴

https://blog.wakmusic.xyz/tdd-vs-bdd-c738b507930f

https://yozm.wishket.com/magazine/detail/2471/