[소프트웨어/프로그래밍] 테스트

프로그램이 의도된 대로 동작하는지 검증 및 확인하는 것을 테스트(test)라고 한다. 테스트는 프로그램이 요구사항에 맞게 동작하는지, 동작 도중 에러가 발생하지 않는지 등 프로그램의 정상적인 실행 흐름과 동작을 확인하는 과정을 포함한다. 테스트의 목적에는 여러가지가 있겠지만 가장 중요한 것은 프로그램의 예기치 않은 동작으로 인해 발생하는 문제를 막는 것이다. 테스트의 주된 대상은 프로그래밍의 기능이지만 로그나 메트릭 지표, 에러의 발생 지점을 확인함으로써 프로그램의 성능 및 리소스 한계를 확인하는 별도의 테스트를 수행하기도 한다.

컴파일 또는 빌드된 프로그램을 직접 실행시켜보면서 동작을 일일이 수동으로 테스트할 수 있지만 규모가 크고 복잡한 프로그램은 테스트를 수동으로 수행하기에 한계가 있다. 테스트 자동화란 프로그램을 직접 실행하여 테스트하는 대신 사전 정의된 다양한 테스트 스위트(suite)를 자동으로 실행하도록 하여 프로그램을 테스트하는 것을 말한다. 테스트 스위트란 프로그램 테스트를 위한 검증 및 확인 대상인 테스트 케이스(case)의 집합을 말한다. 테스트를 위해 테스트 케이스는 그룹화되며 그룹화된 테스트 케이스의 집합이 테스트 스위트가 된다.

테스트는 목적과 대상에 따라 다음과 같이 분류할 수 있다.

  • 프로그램의 기능 및 기술 테스트
    • 단위(unit) 테스트
    • 통합(integration) 테스트
    • 인수(acceptance) 테스트
  • 프로그램의 성능 테스트
    • 성능(performance) 테스트
      • 부하(load) 테스트
      • 스트레스(stress) 테스트


단위 테스트란 프로그램을 구성하는 가장 작고 고립되며 독립적으로 분리하여 테스트 가능한 단위인 클래스 또는 클래스의 메서드(또는 함수)를 테스트하는 것이다.

통합 테스트란 단위의 다양한 집합을 테스트하는 것이다. 일반적으로 프로그램(특히 객체 지향 프로그래밍 방법으로 구현한 프로그램)에서 한 클래스는 다양한 클래스와 상호작용하도록 구현되어 있기 때문에 이를 대상으로 테스트하는 것이 중요하다. 통합 테스트를 프로그램을 구성하는 다양한 모듈(또는 컴포넌트) 간 테스트로 간주하기도 하며, 모듈을 하나의 프로그램으로 볼 수도 있기 때문에 프로그램 간 테스트, 더 넓은 의미에서 다양한 서비스로 구성되어 있는 복잡한 시스템 아키텍처(마이크로서비스 아키텍처) 구조에서 서비스 간 테스트를 통합 테스트로 볼 수도 있다. 즉, 테스트 단위의 정의를 어떻게 할 것인가에 따라 통합 테스트의 테스트 대상과 범위가 달라지게 된다. 중요한 것은 통합 테스트는 단위 간 상호작용을 보다 복합적으로 테스트하는 것이다.

인수 테스트란 프로그램의 전체 동작을 테스트하는 것이다. 전체 동작이란 프로그램의 엔드 사용자가 프로그램을 직접 사용(UI를 통해 직접 사용하거나 정해진 인터페이스를 통해 사용)하여 프로그램을 구성하는 모든 단위를 대상으로 테스트를 수행하는 과정을 말한다. 인수 테스트는 프로그램의 동작이 다양한 요구사항을 충족하는지 확인하기 위한 테스트이다. 인수 테스트는 앞의 테스트에 비해 보다 현실적인 비즈니스 시나리오를 대상으로, 프로그램을 직접 사용하는 사용자 또는 고객에 의해 수행되는 테스트이다.

프로그램을 직접 구현한 개발자에 의해 주로 수행되는 테스트는 단위 테스트와 통합 테스트라고 볼 수 있다. 개발자는 테스트 자동화를 위해 다양한 테스트 도구들을 사용하여 테스트를 위한 별도의 테스트 코드 작성을 통해 자동화된 테스트를 수행한다.


코드 커버리지

테스트 코드가 프로그램의 소스 코드를 얼마나 테스트했는지를 나타내는 척도를 코드 커버리지(code coverage)라고 한다. 코드 커버리지가 높을수록 테스트 코드로 구현한 테스트 스위트가 프로그램을 테스트하는 정도가 높아진다. 코드 커버리지는 소스 코드의 구문, 결정, 조건 등의 코드 구조가 얼마나 테스트되었는지를 나타낸다.

프로그램에는 실제로 코드 동작에 사용되지 않는 자동 생성된 코드가 포함될 수 있기 때문에 코드 커버리지율을 100 퍼센트 달성하는 것은 거의 불가능하다. 코드 커버리지율이 높다고 해서 테스트의 품질이 높은 것은 아니며 비즈니스 상 핵심적인 로직을 구현한 코드를 얼마나 테스트했는지, 에러가 발생 가능한 케이스를 얼마나 테스트했는지가 중요하다고 볼 수 있다.

코드 커버리지는 테스트 코드가 실제 소스 코드 구조 상 어떤 부분을 확인하는지에 따라 다음과 같이 분류된다.

  • 함수(function) 커버리지
  • 구문(statement) 커버리지
  • 라인(line) 커버리지
  • 분기(branch) 커버리지 (또는 결정(decision) 커버리지)
  • 조건(condition) 커버리지


함수 커버리지란 소스 코드 상 정의되어 있는 함수(메서드)가 얼마나 호출되었는지를 기준으로 판단하는 코드 커버리지를 말한다. 함수 커버리지에서 비율은 테스트에 의해 호출된 함수의 개수/전체 함수 개수의 개수이다.

구문 커버리지란 소스 코드의 구문(statement)이 얼마나 수행되었는지를 측정한다. 구문 커버리지는 if와 같은 조건 분기문 제어 구조나 for와 같은 반복문 제어 구조(반복문도 특정 조건에서만 코드를 실행하는 제어 구조에 해당된다)가 존재하는 경우, 조건 분기 중 일부만 만족하면 조건을 만족하지 않는 구문은 실행되지 않기 때문에 모든 조건을 만족하는 테스트 케이스일 때 모든 소스 코드가 테스트되었다고 판단한다. 따라서 구문 커버리지는 코드 커버리지 중 가장 약하다. 구문 커버리지에서 비율은 테스트에 의해 실행된 구문의 개수/전체 구문 개수이다.

라인 커버리지란 실행 가능한 코드 라인이 얼마나 실행되었는지를 측정한다. 소스 코드 상 하나의 라인에 여러 구문을 작성할 수도 있기 때문에 라인 커버리지는 구문 커버리지를 의미할 수도, 그렇지 않을 수도 있다. 라인 커버리지에서 비율은 테스트에 의해 실행된 코드 라인 수/전체 코드 라인 수이다

조건 분기문(또는 반복문) 제어 구조에 따라 실행 가능한 모든 분기 코드를 실행하기 위해서는 각각의 조건을 만족하는 케이스 별 테스트 코드 실행이 필요하다. 이때, 조건 분기문의 부울 표현식이 단일 부울 표현식으로 구성되어 있는가, 여러 하위 부울 표현식으로 구성되어 있는가에 따라 다시 코드 커버리지가 구분된다. 분기 커버리지는 제어 구조의 분기(branch)(제어 흐름) 중 몇 개가 실행되었는지 측정한다. 조건 분기문의 경우 하위 개별 부울 표현식이 아닌 조건 분기문의 전체 분기에 대해서만 최소한 참과 거짓을 한 번씩 만족하는지 측정한다. 분기 커버리지에서 비율은 테스트에 의해 실행된 분기 개수/전체 분기 개수이다. 이에 반해 조건 커버리지는 조건 분기문의 모든 하위 개별 부울 표현식이 최소한 참과 거짓을 한 번씩 만족하는지 측정한다. 조건 커버리지에서 비율은 테스트에 의해 평가된 부울 표현식 개수/전체 부울 표현식 개수이다. 조건 커버리지는 분기 커버리지 보다 강하다.

위에서 설명한 코드 커버리지 외에 여러 코드 커버리지를 조합한, 더 복잡하고 강한 종류의 코드 커버리지가 존재한다.


스텁과 목

객체 지향 프로그래밍에서는 코드 구현의 많은 부분이 객체 간 상호작용으로 이루어져 있다. 따라서 프로그램에 대한 단위 테스트는 하나의 객체에 대한 테스트 뿐만 아니라 객체가 의존하는 다른 의존 객체(협력 객체)와의 상호작용을 검증하는 과정이 포함되는 것이 일반적이다. 프로그램의 실제 구현을 하기 전에 테스트 코드를 작성할 때 의존 객체에 대한 인터페이스(기능 명세)는 정해졌지만 구체적인 구현이 이루어지지 않았다면 테스트 대상 객체에 대한 테스트 코드를 작성하기 어렵다. 이 경우 테스트 더블(test double)을 이용하여 실제 의존 객체를 대신하여 기능을 수행하는 가짜(모의) 객체를 만들어 테스트에 사용할 수 있다. 테스트 더블이란 실제 객체를 대신하는 객체를 의미한다. 제라드 메스자로스(Gerard Meszaros)의 xUnit 테스트 패턴(xUnit test pattern)에 따르면 테스트 더블은 가짜 객체의 동작 방식에 따라 더미(dummy), 페이크(fake), 스텁(stub), 스파이(spy), 목(mock)으로 분류된다. 테스트 대상 객체가 의존 객체의 메서드를 호출하는 구조에서 의존 객체의 기능이 얼마나 완성되었는지에 따라 적절한 테스트 더블의 종류와 테스트 방법을 선택할 수 있다.

메서드가 값을 반환하는 경우 해당 메서드가 특정 값을 반환하는지 확인함으로써 최소한의 기능에 대한 테스트가 가능하다. 이때, 의도된 값이 메서드의 반환값과 동일한지 확인하는 단정문(assertion statement)을 통해 테스트를 수행한다. 이 경우 미리 준비된 데이터(하드코딩된 데이터)를 제공하는 메서드가 정의된 스텁(stub) 객체를 사용한다. 스텁 객체는 특정 케이스에 대해 테스트 대상 기능의 상태 검증(status verification)을 위해 사용한다.

반면 메서드가 아무런 값도 반환하지 않는 경우 단순히 값을 비교하는 단정문으로 해당 메서드가 정상적으로 동작하는지 테스트할 수 없다. 이 경우 목(mock) 객체를 사용하여 객체의 행위 검증(behavior verification)을 하기 위한 방법인 모킹(mocking)이라는 기법이 필요하다. 목 객체란 테스트 대상 객체가 다른 객체(의존 객체)에 의존하고 있는 경우 의존 객체 대신 기능을 수행하는 가짜 객체를 말한다. 목 객체는 테스트 대상 객체의 모든 메서드를 제공하도록 구성된다. 의존 객체의 구체적인 구현이 정해지지 않은 상태에서 테스트를 하는 것이므로 목 객체를 사용하더라도 의존 객체의 기능에 대한 인터페이스는 정해져 있는 것이 좋다.

목 객체를 이용한 테스트에서는 의존 객체를 대체하는 목 객체가 어떻게 동작할 것이라고 설정한 후 해당 의존 객체의 기능을 실행하고 그 결과를 확인함으로써 테스트를 수행한다. 목 객체에게 어떤 메서드가 어떻게 동작해야 할지 지시한 후 어떤 일이 일어났는지, 예상된 일이 일어났는지 확인하는 것이다. 이를 목 객체의 녹음(record) 및 재생(playback) 메커니즘이라고 한다. 스텁 객체가 데이터 및 상태에 초점이 맞추어져 있다면 목 객체는 동작 및 행위에 초점이 맞추어져 있다.

목 객체를 통한 테스트를 위해 사용되는 모킹 라이브러리는 목 객체를 생성하고 목 객체의 기능을 설정(특정 값 반환, 특정 예외 발생 등)하며, 목 객체의 특정 메서드가 호출되었는지, 메서드가 몇 번 호출되었는지, 메서드의 인자가 무엇인지 등 목 객체의 동작을 확인할 수 있는 방법을 제공한다. 테스트 시 모킹을 사용하는 과정은 다음과 같다.

  1. 목 객체를 생성한다.
  2. 객체에 목 객체(의존 객체)를 의존성 주입한다.
  3. 목 객체의 행위를 구성한다.
  4. 객체의 메서드를 실행하여 목 객체의 메서드가 실행되도록 한다.
  5. 목 객체의 메서드 실행을 확인(검증)한다.


모킹은 테스트 코드를 먼저 작성할 때 실제 구현을 대체하는 용도이기도 하지만 테스트의 격리와 일관성을 위해 실제 서비스를 대체하기 위한 목적으로 사용될 수도 있다. 외부 서비스(예: 데이터베이스, API 등)와 연결된 프로그램의 경우 동작을 단위 또는 통합 테스트하기 위해서는 해당 서비스와 관련된 의존 객체들이 모두 준비되어 있어야 한다. 테스트를 위한 외부 서비스들과 관련된 의존 객체들의 구성이 모두 준비되어 있다면 완전한 테스트가 가능하겠지만 그렇지 않은 경우 테스트 대상 의존 객체들을 대신하여 동작할 수 있는 목 객체를 사용하면 테스트를 보다 용이하게 할 수 있다.

마틴 파울러(Martin Fowler)의 글링크에 따르면 스텁을 사용한 상태 검증 테스트는 TDD 방법론 등장 이전부터 단위 테스트에서 사용되었으며 이를 고전적 스타일(classical style)의 테스트라고 한다. 목을 사용한 행위 검증 테스트는 스텁 보다 이후에 등장한 테스트 방법에 관한 패러다임으로 간주되며 이를 목 스타일(mock style)이라고 한다. 두 스타일 모두 TDD와 관계가 있지만 테스트의 진행 방식이나 장단점에 있어 차이가 있기 때문에 적절한 선택이 필요하다.


테스트 주도 개발과 행위 주도 개발

테스트 주도 개발(test driven development, TDD)이란 프로그램의 테스트를 기반으로 하는 소프트웨어 개발 방법론(또는 패러다임)이다. 테스트 주도 개발의 핵심은 구체적인 기능을 구현하기 전에 테스트를 위한 코드를 작성하는 것으로 개발을 시작하는 것이다. 테스트 주도 개발에서는 비즈니스 요구사항에 따라 기능 코드를 완성한 다음 테스트하는 대신 테스트 코드를 먼저 구현한 후 기능에 대한 코드 구현을 진행한다. 테스트 주도 개발에서 개발 생명주기는 테스트 코드 작성 -> 테스트 실패 -> 기능 구현 -> 테스트 성공이다. 위에서 서술한 다양한 테스트 종류 및 전략과 관련된 도구와 프레임워크를 적절히 선택하고 이를 활용하여 테스트 코드를 보다 손쉽게 작성할 수 있다.

행위 주도 개발(behavior driven development, BDD)이란 비즈니스 요구사항과 사용자 행위를 기반으로 하는 개발 방법론이다. 다른 개발 방법론과 달리 프로그램을 사용하는 유저의 행위와 그 목적을 중점으로 한다. 행위 주도 개발은 테스트 주도 개발(TDD)에서 유래하였으며 테스트 주도 개발의 일반적인 기술과 원칙에 도메인 주도 설계(domain driven design, DDD) 방법을 결합한 개념이다. 행위 주도 개발에서 프로그램은 비즈니스(도메인) 요구사항, 특히 사용자 시나리오(유즈케이스)에 따라 구현된다.

행위 주도 개발과 테스트 주도 개발의 차이는 개발 주기의 초반부에 비즈니스 요구사항이 기술적인 개념(프로그래밍 언어의 종류, 시스템 인프라를 구성하는 기반 기술 등)과 결합되는지의 여부이다. 행위 주도 개발에서는 사용자의 행위 기반 요구사항 도출 과정이 이루어진 후 구체적인 비즈니스 로직의 구현에 중점을 둔다. 따라서 프로그램의 개발은 특정 기술과 관련이 없으며 모든 이해관계자가 개발 과정과 내용에 기여한다. 반면에, 테스트 주도 개발에서는 비즈니스 요구사항을 구체화하기 전에 먼저 테스트 코드 구현에 중점을 두므로 프로그램 개발은 기술에 종속적이며 주로 프로그램 개발자들이 기여한다.

테스트 코드의 로직을 구조화하기 위한 다양한 접근법이 존재한다. 이러한 방법들은 특정 테스트 프레임워크나 개발 방법론으로부터 유래하였다. 그 중 행위 주도 개발 개념으로부터 유래된 여러 테스트 코드 작성법이 존재한다. 이러한 방법들은 다양한 테스트 프레임워크에서 테스트 코드 구조화를 위한 DSL이나 인터페이스로 발전되고 있다.

  • GWT(given, when, then) 패턴
  • Four-Phase 테스트 패턴
  • DI(describe, it) 패턴 또는 DCI(describe, context, it) 패턴


GWT(given, when, then) 패턴은 해당 시나리오에 대한 테스트 전 정해져야 하는 전제 조건(사전 조건) 및 상태, 테스트 수행 시 동작할 행위, 행위로 인해 예상되는 변경에 따른 결과를 세 블록에 거쳐 순서대로 명시한다.

Four-Phase 테스트 패턴은 제라드 메스자로스의 xUnit 테스트 패턴에서 소개되었으며 GWT 패턴과 개념적으로 유사하다. setup, exercise, verify, teardown 네 가지 페이즈로 테스트 과정을 명시하며 마지막 teardown 페이즈에서는 테스트가 실행된 후에 테스트를 위해 또는 테스트를 통해 생성된 모든 테스트 픽스처(fixture)를 제거한다. teardown 페이즈의 예로는 시스템 메모리, 네트워크 커넥션과 같은 리소스 해제, 데이터베이스 트랜잭션 롤백 등이 있다. teardown 페이즈는 테스트 프레임워크에 의해 자동으로 수행되는 경우도 존재하므로 필수는 아니지만 일관성 있는, 견고한, 격리된 테스트를 위해 반드시 신경 써야 할 요소이다.

DI(describe, it) 패턴은 자바스크립트, 루비 등의 테스트 프레임워크에서 찾아볼 수 있다. 루비의 행위 주도 개발 테스트 프레임워크인 RSpec은 코드에 대한 자동화된 테스트를 작성하는 간단하고 유연한 방법을 제공한다. Rspec은 읽고 이해하기 쉬운 테스트를 작성하기 위해 코드가 어떻게 동작할 것으로 예상되는지 실행 가능한 예제를 만드는 DSL을 제공하며 그 중 describeit 키워드를 사용하여 테스트 코드의 예상 동작을 구조적으로 그룹화한다. describe은 테스트 스위트를 구조화하며 it은 실제 각각의 테스트를 수행한다. context 키워드는 describe와 기능상 동일하지만 두 키워드를 모두 사용하여 문맥상 테스트 코드를 더 이해하기 쉽도록 만든다.


프로그램의 성능 테스트

성능 테스트란 프로그램이 특정 환경에서 잘 동작하는지 확인하는 테스트이다. 성능 테스트를 통해 프로그램의 성능, 안정성, 내구성, 확장성 뿐만 아니라 리소스 사용 현황 및 한계 지점을 확인할 수 있다.

성능 테스트를 통해 확인하는 프로그램 성능 지표의 예로는 네트워크 응답 시간(response time) 및 지연 시간(또는 대기 시간)(latency), 처리량(throughput)(특정 시간 동안 처리할 수 있는 트랜잭션 수), CPU 및 메모리 점유율 등이 있다.

부하 테스트란 프로그램에 특정 기간 동안 일정한 또는 점진적으로 증가하는 부하를 줌으로써 프로그램의 내구성을 확인하는 테스트를 말한다. 시스템이 부하를 얼마나 견딜 수 있는지 그 한계 지점을 파악하는 테스트이다. 프로그램에 최소한의 부하를 주는 테스트도 부하 테스트의 일부로 볼 수 있으며 이러한 테스트를 스모크(smoke) 테스트라고 한다.


격리된 테스트 환경 구성

일반적으로 프로그램은 다양한 외부 프로그램과 상호 작용한다. 테스트하려는 프로그램이 외부 프로그램과 어떤 방식으로 상호작용하는가는 테스트의 복잡도 및 일관성에 영향을 미친다. 컴파일된 소스 코드가 모여 구성된 바이너리를 프로세스 내에 로드시켜 사용하는 방식으로 동작하는 API 라이브러리 형태의 외부 프로그램의 경우 테스트에 영향을 주는 요소는 보다 단순하며 동일한 프로세스 상에서 외부 프로그램이 의도된 대로 동작하는가이다.

반면 HTTP나 SOAP 프로토콜과 같이 네트워크 통신 프로토콜을 사용하여 통신하는 외부 프로그램의 경우, 네트워크 상에서 물리적 또는 논리적으로 분리된 프로그램과 상호작용하는 것이므로 네트워크 환경에 따른 영향도가 있다. 윈격 프로시저 호출(remote procedure call, RPC)을 사용하는 원격(remote) 라이브러리도 네트워크 통신 시 TCP/IP와 같은 하부 프로토콜을 사용한다.

마이크로서비스 아키텍처 상에서 한 프로그램(클라이언트)이 네트워크 상으로 분리된 다른 프로그램(서버)과 통신하는 경우 테스트는 네트워크 통신 환경, 서버의 상태 등의 영향을 받게 된다. 테스트를 수행하는 주체가 이러한 요소들을 통제할 수 있다면 다양한 케이스 별로 일관성 높은 테스트를 수행할 수 있겠지만 그렇지 않은 경우 테스트는 외부 환경 및 요소들에 의해 제어되며 이로 인해 테스트의 일관성을 유지하기 어렵게 된다. 따라서 이 경우 테스트 환경의 격리 구성을 통해 격리된 테스트(isolated test)를 수행하는 것이 필요하다. 물론 외부 요소가 항상 예상된 상태를 보장하여 높은 일관성을 제공한다는 전제가 있다면 격리된 테스트의 필요성은 낮아진다.

테스트의 격리 수준은 테스트 대상과 범위에 따라 달라진다. 또한 테스트 대상(모듈 또는 컴포넌트)의 크기와 외부 프로그램과의 통신 방법에 따라 격리된 테스트 환경을 구성하기 위해 선택할 수 있는 도구와 방법이 달라진다. 테스트 조건에 따른 테스트 격리는 다음과 같이 구분해볼 수 있다.

  1. 프로그래밍 코드 레벨(애플리케이션 레벨)에서의 격리된 테스트
    • 테스트 대상 객체가 의존 객체에 대해 종속성을 갖고 있지 않음: 단순히 클래스나 함수(메서드)를 대상으로 테스트
    • 테스트 대상 객체가 의존 객체에 대해 종속성을 갖고 있음: 스텁 객체 또는 목 객체 사용
  2. 프로그램이 외부 프로그램과 네트워크 통신을 하고 있을 경우 시스템 레벨에서의 격리된 테스트: 외부 프로그램을 대체하는 시뮬레이션 또는 모킹 서비스 사용


프로그래밍 코드 레벨에서는 테스트 대상 객체가 의존 객체에 대해 종속성을 갖고 있지 않은 경우 단위 테스트 프레임워크를 사용하여 클래스나 함수(메서드)를 대상으로 테스트를 수행할 수 있다. 테스트 대상 객체가 의존 객체에 대해 종속성을 갖고 있는 경우 스텁 객체를 사용하거나 모킹 기법을 통해 의존 객체를 모방한 목 객체를 사용하여 테스트 대상 객체에 대한 격리된 단위 테스트 및 통합 테스트를 수행할 수 있다. 스텁 객체와 목 객체는 테스트하려는 객체를 주변 환경에서 격리(분리)하는 용도로 사용된다.


카오스 엔지니어링

카오스 엔지니어링(chaos engineering)이란 시스템에 의도적으로 장애나 오류, 예상치 못한 이벤트를 주입하여 시스템이 어떻게 동작하는지 확인함으로써 시스템의 안정성, 회복성, 탄력성을 강화하는 실험적인 접근 방법이다. 이를 통해 장애 상황에서 시스템이 어떻게 반응하고 복구하는지 관찰하여 취약점을 사전에 발견하고 이에 대한 개선점을 확인할 수 있다. 실제 운영 환경에서 발생할 수 있는 장애를 사전에 파악하고 가설 수립 및 실험 과정을 통해 장애 상황을 미리 대비하여 시스템의 안정성을 높일 수 있다.


참고

Comments