[태그:] 테스트 하네스

  • 자동차 없는 엔진을 어떻게 테스트할까? 테스트 하네스의 비밀 (드라이버, 스텁, 목)

    자동차 없는 엔진을 어떻게 테스트할까? 테스트 하네스의 비밀 (드라이버, 스텁, 목)

    소프트웨어 개발에서 ‘단위 테스트’나 ‘통합 테스트’를 수행할 때, 우리는 종종 난감한 상황에 부딪힙니다. 이제 막 개발이 완료된 작은 모듈 하나를 테스트하고 싶은데, 이 모듈을 호출하는 상위 모듈이나 이 모듈이 사용하는 하위 모듈이 아직 만들어지지 않은 경우입니다. 이는 마치 자동차의 핵심 부품인 엔진은 완성되었지만, 아직 핸들이나 바퀴, 차체가 없는 상황과 같습니다. 이 상태로 엔진이 잘 작동하는지 어떻게 테스트할 수 있을까요?

    바로 이 문제를 해결하기 위해 등장한 개념이 ‘테스트 하네스(Test Harness)’입니다. 테스트 하네스는 테스트 대상 컴포넌트가 정상적으로 실행될 수 있도록 주변 환경을 흉내 내어주는 가상의 ‘테스트 지원 환경’ 전체를 의미합니다. 여기에는 테스트를 실행하고, 테스트 데이터를 입력하며, 결과를 검증하는 코드와 소프트웨어가 모두 포함됩니다. 마치 자동차 엔진을 테스트하기 위해 임시로 연결하는 연료 공급 장치, 시동 장치, 계측 장비 세트와 같습니다.

    테스트 하네스는 여러 구성 요소로 이루어져 있으며, 그중 가장 핵심적인 것이 바로 ‘드라이버(Driver)’와 ‘스텁(Stub)’입니다. 또한, 자동화된 테스트를 위해서는 테스트 케이스의 묶음인 ‘테스트 슈트’, 실제 테스트 동작을 정의한 ‘테스트 스크립트’, 그리고 스텁보다 더 지능적인 가짜 객체인 ‘목 오브젝트’ 등이 필요합니다. 본 글에서는 이 테스트 하네스의 구성 요소들이 각각 무엇이며, 어떻게 상호작용하여 격리된 환경에서의 정밀한 테스트를 가능하게 하는지 그 원리를 깊이 있게 탐구해 보겠습니다.


    테스트 드라이버 (Test Driver)

    핵심 개념: 상위 모듈을 대신하는 임시 운전사

    테스트 드라이버는 아직 개발되지 않은 상위 모듈을 대신하여, 테스트 대상 모듈을 ‘호출’하고 제어하는 역할을 하는 임시 코드 또는 도구입니다. 이름 그대로 자동차의 ‘운전사(Driver)’처럼, 테스트 대상 모듈에게 어떤 일을 해야 할지 지시하고 실행을 시작시키는 역할을 합니다. 드라이버는 주로 하위 모듈부터 개발하고 이를 점차 결합해 나가는 ‘상향식 통합 테스트(Bottom-up Integration Testing)’에서 필수적으로 사용됩니다.

    상황을 가정해 봅시다. 주문 데이터베이스에서 주문 내역을 가져오는 getOrderDetails() 라는 하위 모듈의 개발이 막 끝났습니다. 하지만 이 모듈을 실제로 호출하여 사용하는 상위 모듈인 ‘주문 내역 조회 UI’ 화면은 아직 개발 중입니다. 이 경우, 우리는 getOrderDetails() 모듈이 과연 올바르게 동작하는지 테스트할 방법이 막막합니다.

    이때 등장하는 것이 바로 테스트 드라이버입니다. 개발자는 getOrderDetails() 모듈을 테스트하기 위한 간단한 프로그램을 작성합니다. 이 프로그램(드라이버)은 다음과 같은 일을 합니다.

    1. 테스트에 필요한 사전 환경을 설정합니다. (예: 데이터베이스 연결)
    2. 테스트 대상 모듈인 getOrderDetails()를 특정 파라미터(예: 주문 번호 ‘12345’)와 함께 호출합니다.
    3. getOrderDetails() 모듈로부터 반환된 결과 값을 받아옵니다.
    4. 받아온 결과 값이 우리가 예상했던 값(예: ‘상품명: 노트북, 수량: 1’)과 일치하는지 비교하고 검증합니다.
    5. 테스트 결과를 화면에 출력하거나 로그 파일에 기록합니다.

    이처럼 드라이버는 테스트 대상 모듈의 ‘클라이언트’ 또는 ‘사용자’ 역할을 임시로 수행하여, 해당 모듈이 독립적으로 테스트될 수 있는 환경을 만들어 줍니다.

    적용 사례: JUnit을 이용한 서비스 모듈 테스트

    최근에는 JUnit, TestNG와 같은 단위 테스트 프레임워크가 테스트 드라이버의 역할을 상당 부분 대신하고 있습니다. 개발자는 테스트 프레임워크가 제공하는 규칙에 맞춰 테스트 코드를 작성하기만 하면, 프레임워크가 알아서 테스트를 실행하고 결과를 보고해 줍니다.

    다음은 Spring Boot 환경에서 주문 서비스 모듈(OrderService)을 테스트하는 JUnit 기반의 테스트 코드 예시입니다. 여기서 @Test 어노테이션이 붙은 getOrderTest() 메소드가 바로 테스트 드라이버의 역할을 수행합니다.

    Java

    // 테스트 대상 클래스
    public class OrderService {
    // ... (내부 로직)
    public Order getOrderDetails(String orderId) {
    // 데이터베이스에서 주문 정보를 조회하여 반환하는 로직
    // ...
    return order;
    }
    }

    // 테스트 드라이버 역할을 하는 테스트 클래스
    public class OrderServiceTest {

    private OrderService orderService = new OrderService();

    @Test // 이 메소드가 테스트를 실행하는 드라이버임을 명시
    public void getOrderTest() {
    // 1. 테스트 데이터 준비 (Given)
    String testOrderId = "ORDER_100";

    // 2. 테스트 대상 메소드 호출 (When)
    Order resultOrder = orderService.getOrderDetails(testOrderId);

    // 3. 결과 검증 (Then)
    assertNotNull(resultOrder); // 결과가 Null이 아니어야 함
    assertEquals(testOrderId, resultOrder.getId()); // 주문 ID가 일치해야 함
    assertEquals("노트북", resultOrder.getProductName()); // 상품명이 일치해야 함
    }
    }

    이 테스트 코드를 실행하면, JUnit 프레임워크가 getOrderTest() 메소드를 자동으로 실행하여 orderService.getOrderDetails()를 호출하고, assertEquals 와 같은 단언문(Assertion)을 통해 결과가 올바른지 검증한 후 성공/실패를 알려줍니다. 이처럼 현대적인 테스트 프레임워크는 개발자가 복잡한 드라이버 코드를 직접 만들 필요 없이, 간단한 어노테이션과 메소드 작성만으로 테스트를 수행할 수 있게 해줍니다.


    테스트 스텁 (Test Stub)

    핵심 개념: 하위 모듈을 흉내 내는 임시 배우

    테스트 스텁은 테스트 드라이버와 정반대의 역할을 합니다. 아직 개발되지 않았거나, 테스트 환경에서 직접 호출하기 곤란한(예: 외부 결제 시스템, 실시간 주식 시세 API) 하위 모듈을 대신하여, 마치 실제 모듈인 것처럼 ‘흉내’ 내는 가짜 모듈입니다. 스텁은 상위 모듈로부터 호출을 받았을 때, 미리 정해진 고정된 값을 반환해 주는 아주 단순한 형태로 만들어집니다. 스텁은 주로 상위 모듈부터 개발하고 아래로 내려가는 ‘하향식 통합 테스트(Top-down Integration Testing)’에서 필수적으로 사용됩니다.

    상황을 다시 가정해 봅시다. 이번에는 ‘주문 내역 조회 UI’ 화면이라는 상위 모듈의 개발이 먼저 끝났습니다. 이 UI 모듈은 내부에 getOrderDetails() 라는 하위 모듈을 호출하여 실제 주문 데이터를 받아와 화면에 표시해야 합니다. 하지만 getOrderDetails() 모듈은 아직 개발 중입니다. 이 상태에서는 UI 모듈이 데이터를 정상적으로 받아와 화면에 올바르게 그려주는지 테스트할 수 없습니다.

    이때 ‘테스트 스텁’을 만듭니다. 우리는 getOrderDetails() 라는 이름과 파라미터를 가진 가짜 메소드를 하나 만듭니다. 이 가짜 메소드는 내부에 복잡한 데이터베이스 조회 로직 없이, 단순히 미리 준비된 테스트용 주문 데이터 객체를 즉시 반환(return)하도록 코딩되어 있습니다.

    가짜 getOrderDetails() 스텁의 예:

    Java

    public Order getOrderDetails(String orderId) {
    // 실제 로직 대신, 미리 만들어둔 가짜 데이터 반환
    Order fakeOrder = new Order("ORDER_100", "테스트용 노트북", 1);
    return fakeOrder;
    }

    이제 상위 모듈인 UI 모듈이 이 가짜 스텁 메소드를 호출하면, 스텁은 항상 동일한 ‘테스트용 노트북’ 정보를 반환해 줄 것입니다. 이를 통해 개발자는 하위 모듈의 완성 여부와 관계없이, 상위 모듈이 데이터를 받아 화면에 정상적으로 표시하는 로직을 독립적으로 테스트할 수 있게 됩니다. 스텁은 실제 모듈의 복잡한 로직은 흉내 내지 않고, 단지 정해진 ‘응답’만을 제공하는 ‘대역 배우’와 같습니다.


    목 오브젝트 (Mock Object)

    핵심 개념: 상태 검증을 넘어 행위 검증까지 하는 똑똑한 스텁

    목 오브젝트(Mock Object, 모의 객체)는 스텁과 마찬가지로 테스트 대상 모듈이 의존하는 다른 객체를 흉내 내는 가짜 객체라는 점에서 유사합니다. 하지만 스텁이 단순히 미리 정해진 값을 반환하여 테스트 대상 모듈의 ‘상태 검증’을 돕는 데 그친다면, 목 오브젝트는 한 걸음 더 나아가 테스트 대상 모듈과의 ‘상호작용’ 자체를 검증하는, 즉 ‘행위 검증(Behavior Verification)’까지 수행하는 훨씬 더 똑똑하고 능동적인 가짜 객체입니다.

    스텁을 사용한 테스트는 다음과 같이 검증합니다: “A 모듈에 X를 입력했더니, Y라는 결과가 나왔는가?” (상태 검증)

    목 오브젝트를 사용한 테스트는 여기에 더해 다음을 검증합니다: “A 모듈이 올바른 결과 Y를 만들기 위해, 의존 객체 B의 save() 메소드를 ‘정확히 1번’ 호출했고, 파라미터로는 ‘객체 Z’를 넘겼는가?” (행위 검증)

    예를 들어, 주문이 완료되면 이메일로 알림을 보내는 OrderService 모듈을 테스트한다고 생각해 봅시다. 이 모듈은 내부적으로 EmailSender 라는 객체의 send() 메소드를 호출합니다. 단위 테스트 환경에서 실제로 이메일을 발송할 수는 없으므로, 우리는 가짜 EmailSender 를 만들어야 합니다.

    • 스텁(Stub)을 사용한다면: 가짜 EmailSender는 아무 일도 하지 않거나, send() 메소드가 호출되면 항상 true를 반환하도록 만들 것입니다. 그리고 우리는 OrderService의 주문 완료 로직이 오류 없이 끝나는지만 확인할 수 있습니다.
    • 목(Mock)을 사용한다면: 우리는 Mockito와 같은 목 프레임워크를 사용하여 가짜 EmailSender 목 객체를 만듭니다. 그리고 테스트 코드에서 다음과 같이 ‘기대 행위’를 설정합니다. “테스트가 끝나면, 이 emailSenderMock 객체의 send() 메소드가 ‘정확히 한 번(exactly once)’ 호출되었어야 하며, 그때 첫 번째 파라미터는 ‘test@example.com’ 이어야 한다” 라고 명시합니다. 테스트 실행 후, verify() 구문을 통해 이 기대 행위가 실제로 일어났는지 검증합니다. 만약 개발자의 실수로 send() 메소드가 두 번 호출되거나, 잘못된 이메일 주소로 호출되었다면 테스트는 실패하게 됩니다.

    이처럼 목 오브젝트는 의존 객체와의 ‘올바른 소통 방식’까지 검증할 수 있게 해주어, 훨씬 더 정교하고 신뢰도 높은 단위 테스트를 가능하게 합니다.


    테스트 슈트와 테스트 스크립트 (Test Suite & Test Script)

    핵심 개념: 시나리오(스크립트)를 모아놓은 한 권의 희곡(슈트)

    ‘테스트 스크립트’와 ‘테스트 슈트’는 자동화된 테스트의 구조를 설명하는 용어입니다.

    • 테스트 스크립트 (Test Script): 개별 테스트 케이스를 자동화된 형태로 구현한 코드 또는 명령어의 집합입니다. 앞서 보았던 Selenium 테스트 코드나 JUnit 테스트 메소드 하나하나가 바로 테스트 스크립트에 해당합니다. 하나의 스크립트는 “로그인 기능을 테스트한다” 또는 “상품 상세 페이지의 가격 표시를 검증한다”와 같이 명확하고 구체적인 하나의 목적을 가집니다.
    • 테스트 슈트 (Test Suite): 특정 기능 그룹이나 테스트 목적에 따라 관련된 테스트 스크립트(또는 테스트 케이스)들을 모아 놓은 ‘집합’ 또는 ‘컬렉션’입니다. 예를 들어, ‘회원 관리 기능 테스트 슈트’ 안에는 ‘정상 회원가입 스크립트’, ‘중복 아이디 가입 시도 스크립트’, ‘로그인 스크립트’, ‘비밀번호 찾기 스크립트’ 등이 포함될 수 있습니다. 또한, 시스템의 핵심 기능들만 모아 빠른 시간 안에 검증하는 ‘스모크 테스트 슈트(Smoke Test Suite)’나, 매일 밤 모든 주요 기능을 검증하는 ‘야간 회귀 테스트 슈트(Nightly Regression Test Suite)’와 같이 목적에 따라 슈트를 구성할 수도 있습니다.

    테스트 슈트는 테스트의 관리와 실행을 효율적으로 만들어 줍니다. 우리는 “회원가입 기능만 빠르게 테스트해보고 싶다” 할 때는 ‘회원 관리 기능 테스트 슈트’만 선택하여 실행하고, 전체 시스템의 안정성을 확인하고 싶을 때는 모든 슈트를 한 번에 실행할 수 있습니다. 대부분의 테스트 프레임워크와 CI/CD 도구는 이러한 테스트 슈트 단위의 실행 및 관리 기능을 기본적으로 제공합니다.


    마무리: 고립된 테스트 환경 구축의 핵심 요소들

    지금까지 우리는 테스트 대상 모듈이 독립적으로 실행될 수 있도록 도와주는 가상의 환경, 즉 ‘테스트 하네스’를 구성하는 핵심 요소들에 대해 알아보았습니다.

    구성 요소역할주요 사용 시점비유
    테스트 드라이버상위 모듈을 대신하여 테스트 대상을 호출상향식 통합 테스트자동차 없는 엔진의 임시 운전사
    테스트 스텁하위 모듈을 대신하여 호출 당하고 응답하향식 통합 테스트실제 배우를 대신하는 대역 배우
    목 오브젝트스텁처럼 응답 + 상호작용(행위) 검증단위 테스트 (TDD)대사뿐 아니라 동선까지 확인하는 연기 지도
    테스트 스크립트자동화된 개별 테스트 케이스테스트 자동화연극의 한 장면 (Scene)
    테스트 슈트관련된 테스트 스크립트의 집합테스트 관리 및 실행연극의 전체 대본 (Play Script)

    이러한 테스트 하네스의 구성 요소들은 현대적인 소프트웨어 개발, 특히 자동화된 단위 테스트와 CI/CD 환경에서 없어서는 안 될 필수적인 도구들입니다. 이들을 적재적소에 활용함으로써 우리는 외부 환경의 변화나 다른 모듈의 개발 지연에 영향을 받지 않고, 우리가 만든 코드의 품질을 오롯이 검증하고 책임질 수 있게 됩니다. 결국, 튼튼한 테스트 하네스를 구축하는 것은 변화에 흔들리지 않는 견고하고 신뢰성 있는 소프트웨어를 만드는 가장 확실한 지름길입니다.