테스트 커버리지 분석 방법

테스트 커버리지의 개념

테스트 커버리지(Test Coverage)란 소스코드에서 실제 테스트가 수행된 부분과 수행되지 않은 부분을 정량적으로 측정하는 지표다. 소프트웨어 품질을 관리하는 측면에서, 아래와 같은 이점을 제공한다.

  • 테스트에서 누락된 영역 식별: 커버리지가 낮은 부분은 추후 발생할 수 있는 잠재적 결함의 위험이 높다.

  • 테스트 전략 보강: 부족한 테스트 항목을 발굴하고, 테스트 설계를 더욱 체계적으로 계획할 수 있다.

  • 유지보수 용이성 확보: 테스트가 충분히 수행된 코드 영역은 리팩터링과 같은 유지보수 작업에 대해 비교적 안전하다.

테스트 커버리지 분석을 수행하기 위해선, 컴파일 단계에서 추가적인 플래그 혹은 도구를 사용해 테스트 실행 중 어떤 부분의 코드가 실행되었는지 추적해야 한다. 일반적으로 커버리지 도구는 기호화(Symbolization), 인스트루먼테이션(Instrumentation), **트레이싱(Tracing)**과 같은 메커니즘을 통해 실행 경로에 대한 정보를 수집한다.

커버리지 측정 범주

소프트웨어 테스트 커버리지를 측정할 때 가장 널리 사용되는 유형은 크게 다음과 같다.

라인(Line) 커버리지: 테스트가 소스코드의 각 라인을 몇 번이나 실행했는지 측정한다. 이를 정량화하면 다음과 같은 형태로 표현할 수 있다.

Line Coverage=Number of executed linesTotal lines of code×100% \text{Line Coverage} = \frac{\text{Number of executed lines}}{\text{Total lines of code}} \times 100\%

라인 커버리지는 가장 기초적이며, 구현 로직의 모든 라인에 대해 테스트가 어느 정도 수행되었는지 확인하는 데 유용하다.

브랜치(Branch) 커버리지: 조건문(if, switch 등)으로 인해 분기가 발생하는 모든 경로(분기)가 테스트에서 실행되었는지 측정한다. 예를 들어, if 조건문이 참일 때와 거짓일 때의 처리가 모두 테스트되었는지를 확인한다.

Branch Coverage=Number of executed branchesTotal branches×100% \text{Branch Coverage} = \frac{\text{Number of executed branches}}{\text{Total branches}} \times 100\%

브랜치 커버리지가 높을수록 분기 로직에서 발생할 수 있는 예외적인 상황을 놓칠 확률이 낮아진다.

함수(Function) 커버리지: 함수 또는 메서드 단위로 호출 여부를 측정한다. 모든 함수를 한 번 이상 호출했는지, 특정 파라미터 조합으로 충분히 테스트되었는지 등을 확인한다.

조건/결정(Condition/Decision) 커버리지: 단순히 분기가 나뉘었는지 여부만 보는 것이 아니라, 복합 분기(예: 복수의 조건이 결합된 if 문)에 대한 내부 조건들이 모두 테스트되었는지 확인하는 방식이다. 일반적인 브랜치 커버리지보다 측정이 더 복잡할 수 있지만, 더욱 정교한 커버리지 분석을 가능하게 한다.

ROS2 Humble에서의 커버리지 측정 개요

ROS2 Humble 환경에서 테스트 커버리지를 분석하기 위해서는 다음과 같은 과정을 고려할 수 있다.

  • 빌드 시스템 구성: ROS2의 공식 빌드 도구인 colcon을 사용하며, CMake 옵션 또는 컴파일러 플래그를 통해 커버리지 측정 기능을 활성화한다.

  • 테스트 실행: colcon test를 통해 빌드된 패키지에 대한 테스트를 수행한다. 이때, 커버리지 데이터를 수집할 수 있도록 빌드 및 실행 환경을 구성해야 한다.

  • 보고서 생성: 커버리지 데이터를 시각화할 수 있는 툴(예: lcov, gcovr 등)을 통해 최종적인 커버리지 리포트를 생성한다.

일반적인 워크플로우는 다음과 같다.

테스트가 끝난 뒤, 생성된 커버리지 정보(예: .gcda, .gcno 등)를 기반으로 리포트를 생성한다. 예시는 다음과 같이 작성할 수 있다.

커버리지 측정 도구와 활용

ROS2 Humble에서 C++ 및 Python 등 여러 언어가 혼합되어 쓰일 수 있으므로, 어떤 언어로 작성된 노드인지에 따라 측정 도구가 달라질 수 있다.

  1. GCOV & LCOV (C/C++용)

    • GCOV: GCC(GNU Compiler Collection)에서 제공하는 커버리지 측정 도구로, 실행 파일을 인스트루먼테이션하여 .gcno.gcda 파일을 생성한다.

    • LCOV: GCOV의 결과물을 좀 더 보기 좋게 HTML 형태로 변환해주는 도구다.

  2. gcovr (C/C++용)

    • GCOV 기반이지만, gcovr 명령어로 손쉽게 커버리지 레포트를 HTML이나 XML 형태로 만들 수 있다.

  3. Coverage.py, Coverage.pyxml (Python용)

    • Python 코드를 대상으로 테스트 커버리지를 측정해주는 도구다.

    • pytest-cov 플러그인을 통해 손쉽게 통합할 수도 있다.

ROS2 프로젝트가 C++로 작성되어 있다면, colcon 명령어와 GCOV/LCOV 혹은 gcovr를 함께 사용하는 것이 대표적이다. 여기서는 C++ 예시를 위주로 설명하되, Python이 주를 이루는 패키지의 경우에도 유사한 프로세스를 따르며 적합한 커버리지 측정 도구를 선택하면 된다.

빌드 플래그와 주의 사항

테스트 커버리지를 측정하기 위해 가장 먼저 고려해야 할 부분은 컴파일 옵션링커 옵션이다. ROS2 Humble 환경에서 C++ 코드를 기준으로 설명하면 다음과 같은 부분을 주의 깊게 살펴봐야 한다.

  • Debug 빌드 유형 일반적으로 커버리지 수집을 위해서는 $-O0$(최적화 끔) 또는 $-Og$(디버깅 친화적 최적화) 모드로 컴파일하는 것이 권장된다. 최적화 레벨이 높으면 커버리지 데이터가 왜곡될 수 있다.

  • 커버리지 전용 플래그

    • --coverage: GCC 또는 Clang 컴파일러에서 사용된다. 내부적으로 -fprofile-arcs-ftest-coverage를 활성화한다.

    • -fprofile-arcs: 각 분기 지점에 대한 실행 횟수를 기록한다.

    • -ftest-coverage: 테스트 시나리오에 대해 추가적으로 커버리지 데이터를 생성한다.

  • 정적/동적 링크 시 고려 사항 공유 라이브러리를 사용하는 경우, 라이브러리 단에서 커버리지 플래그가 적용되지 않으면 전체적인 커버리지에 포함되지 않을 수 있다. 만약 특정 라이브러리도 테스트 범위에 포함시키려면, 해당 라이브러리 또한 같은 빌드 플래그로 빌드되어야 한다.

  • 배포 빌드와 혼동 실제 제품(런칭 시점)에서 사용하는 빌드 설정과 테스트 커버리지 빌드는 분리 관리하는 것이 좋다. 배포 빌드에서는 최적화를 적용해야 성능이나 용량 면에서 유리하지만, 커버리지 빌드에서는 테스트 범위 측정이 우선이므로 오버헤드가 더 발생해도 괜찮다.

아래는 예시로 colcon build 시 커버리지 옵션을 추가하는 방식이다.

CI(Continuous Integration)와 연동

개발 프로세스에서 커버리지 측정은 단발적인 분석보다는 지속적 통합(CI) 파이프라인에 포함시켜 자동화하는 편이 효율적이다. 예를 들어, GitHub Actions나 GitLab CI/CD, Jenkins 등 다양한 CI 환경에서 다음과 같은 단계를 자동화할 수 있다.

  1. 빌드 스텝 커버리지 플래그를 적용하여 소스코드를 빌드한다.

  2. 테스트 스텝 colcon test 명령을 사용하여 모든 단위테스트와 통합테스트를 수행한다.

  3. 커버리지 리포트 생성 GCOV, LCOV, gcovr 등을 이용해 .gcda 파일을 취합하고, 결과를 HTML 혹은 XML로 변환한다.

  4. 리포트 업로드 CI에서 생성된 커버리지 보고서를 아티팩트(Artifacts) 또는 서드파티 서비스(Codecov, Coveralls 등)에 업로드해 시각화한다.

일례로 GitHub Actions의 YAML 구성에서 빌드, 테스트, 그리고 커버리지 리포트 생성을 스크립트 형태로 추가할 수 있다.

이와 같이 CI 환경에 커버리지 측정을 연동해두면, 풀 리퀘스트(merge request)가 생성될 때마다 자동으로 커버리지 결과를 확인할 수 있어 편리하다.

커버리지 측정 결과 해석과 효율적 활용

테스트 커버리지 리포트를 생성한 뒤에는 각 범주의 커버리지 지표를 해석하고, 이를 바탕으로 테스트 전략을 개선하는 단계가 필수적이다. 일반적으로 HTML 보고서를 생성해 살펴보는 경우가 많으며, 각 파일별/함수별/라인별로 어느 정도 테스트되었는지 시각화가 가능하다.

  • 라인(Line) 커버리지 지표 해석 코드를 작성할 때 단순히 “커버리지 100%”에 집착하기보다는, 실제로 해당 라인을 테스트하는 로직이 충분히 존재하는지 판단해야 한다. 경우에 따라선 데드 코드(dead code)나 테스트 불가능한 코드를 발견하는 계기가 될 수 있다.

  • 브랜치(Branch) 커버리지 지표 해석 예외 처리나 에러 핸들링 구문이 제대로 커버되고 있는지 확인할 수 있는 유용한 지표다. 브랜치 커버리지가 낮다면 조건문이나 예외 처리 구간에 대한 테스트 케이스가 부족한지를 검토해야 한다.

  • 함수(Function) 커버리지 특정 함수가 전혀 호출되지 않는다면, 불필요한 코드인지 혹은 테스트 작성이 누락되었는지 고민할 수 있다.

  • 조건/결정(Condition/Decision) 커버리지 복합 조건에 대해 세밀한 테스트가 필요한 경우 유용하다. 간단한 예시로, if 문에 &&, ||와 같은 여러 조건이 결합되어 있다면 각각의 케이스가 제대로 검증되는지 파악 가능하다.

커버리지 임계값(Threshold) 설정

현실적으로 모든 프로젝트에 대해 “커버리지 100%”를 달성하는 것은 과도한 비용이 들 수 있다. 따라서 프로젝트 규모, 중요도, 리스크를 고려하여 **적절한 커버리지 임계값(Threshold)**을 설정해 두는 것이 좋다.

  • 예시: “라인 커버리지 80%, 브랜치 커버리지 70% 이상은 충족해야 한다.”

  • CI 파이프라인에서 이 임계값을 자동으로 체크하도록 구성하면, 커버리지가 기준 미달일 때 빌드가 실패(fail)하도록 처리할 수 있다.

  • 단순 수치보다 더 중요한 것은 “어떤 부분이 누락되었는지”를 파악하고, “어떤 테스트 케이스를 추가해야 하는지” 결정하는 과정이다.

다중 패키지/프로젝트 커버리지 병합

ROS2 프로젝트에서는 여러 패키지를 동시에 빌드하고 테스트하게 된다. 이 경우, 각 패키지마다 생성된 커버리지 데이터를 병합하는 작업이 필요할 수 있다.

  1. 패키지별로 .gcda 파일 수집 각 빌드 디렉터리에서 테스트 실행 후 생성되는 .gcda, .gcno 파일을 별도로 모은다.

  2. 병합 유틸리티 사용 LCOV의 경우 lcov --add-tracefile 옵션을 통해 서로 다른 정보 파일(coverage.info)을 하나로 결합할 수 있다. GCOVR 역시 유사한 방식으로 여러 디렉터리나 파일을 통합한다.

  3. 최종 보고서 생성 병합된 커버리지 정보를 바탕으로 최종 HTML 보고서를 만든다.

이 과정을 CI 스크립트나 별도의 쉘 스크립트로 자동화하면, 모든 패키지에 대한 테스트가 끝난 후 단일 보고서로 일괄 확인할 수 있어 훨씬 관리가 용이하다.

Python 기반 노드의 커버리지

ROS2 프로젝트에서 Python 노드를 사용한다면, pytest 혹은 unittest를 활용하여 테스트를 작성할 수 있다. 커버리지 분석을 위해선 다음과 같은 도구/방법을 고려할 수 있다.

  • coverage.py

    • Python 전용 커버리지 측정 툴이다.

    • pytest 플러그인(예: pytest-cov)과 함께 사용하면, 테스트 실행과 동시에 커버리지 결과를 수집할 수 있다.

  • 명령어 예시

    또는

  • ROS2와 연동 ROS2 파이썬 패키지(rclpy)를 사용해 작성한 노드들도 colcon 빌드 후 colcon test로 실행 가능하다. 다만, C++ 커버리지와는 별도의 측정 도구를 사용해야 하므로, CI 상에서 두 결과를 병행하거나 합산하는 과정이 필요할 수 있다.

고급 커버리지 기법과 주의 사항

테스트 커버리지 수치는 소프트웨어 품질의 전부를 대변하지 않지만, 결함 발견률을 높이고 테스트 누락 영역을 찾는 중요한 수단이다. 특정 프로젝트 상황(멀티쓰레드, 분산 환경, 실시간성 요구사항 등)에 따라 다음과 같은 고급 기법이나 주의 사항을 고려할 수 있다.

멀티쓰레드 및 비동기 코드에서의 커버리지

ROS2 노드 중에는 멀티쓰레드를 사용하거나 콜백 스핀(Callback spinning) 구조로 병렬 처리를 수행하는 코드가 많다. 비동기적으로 동작하는 코드의 커버리지를 확보하기 위해선 아래 사항을 신경 써야 한다.

  • 테스트 시나리오 다양화

    • 다중 스레드가 동시에 특정 함수를 호출하거나 공유 리소스에 접근하는 상황을 다양하게 만들어야 한다.

    • 예: 콜백이 동시에 여러 개 등록된 경우, 각 콜백이 충돌 없이 잘 동작하는지 확인.

  • 교착 상태(Deadlock) 테스트

    • 잠재적 교착 상태를 유발할 수 있는 락(lock) 사용 구간, 조건 변수를 사용하는 구간 등이 충분히 커버되고 있는지 점검한다.

  • 일정 시간이 필요한 테스트

    • 비동기 코드를 테스트할 때는 일정 시간 지연(sleep)이 필요하거나, 이벤트가 트리거되기를 기다리는 방식을 고려해야 한다.

    • 테스트가 지나치게 길어지지 않도록, 가상의 Clock 등을 활용해 효율적으로 시뮬레이션할 수 있다.

분산 환경 테스트와 통합 커버리지

ROS2의 핵심은 네트워크를 통한 다중 노드 간의 통신이다. 따라서 테스트 환경이 단일 호스트에서만 수행되는 것이 아니라, 여러 호스트 또는 컨테이너(Docker 등) 간에 분산 실행되어야 하는 경우도 있다.

  • 분산 테스트 전략

    • 실제 네트워크 지연이나 QoS 설정 등에 의해 결과가 달라질 수 있으므로, 여러 시나리오(동일 네트워크, 클라우드, 에지 디바이스 등)에서 테스트를 수행하여 커버리지에 반영한다.

  • 커버리지 데이터 수합(Collect)

    • 노드별로 생성되는 커버리지 아티팩트가 서로 다른 머신 혹은 컨테이너에 생성될 수 있다.

    • 각 실행 환경에서 생성된 .gcda, .gcno 등을 중앙 서버나 공유 스토리지로 모아야 최종 통합된 커버리지 보고서를 만들 수 있다.

실시간(Real-time) 환경에서의 커버리지

일부 ROS2 시스템(예: 실시간 확장 rtt_ros2 등)은 RT-Preempt 커널이나 실시간 프레임워크를 사용한다. 이런 환경에서는 테스트 자체가 시스템의 타이밍 특성에 영향을 줄 수 있으므로 주의가 필요하다.

  • 인스트루먼테이션 부담

    • 커버리지 측정을 위해 삽입되는 인스트루먼테이션 코드가 실시간성 보장을 저해할 수 있다.

    • 실시간 애플리케이션에서는 개발 단계에서 커버리지를 충분히 측정하되, 실제 실시간 배포 빌드에는 인스트루먼테이션을 제거하는 게 일반적이다.

  • 성능 측정 병행

    • 실시간성 코드를 테스트할 때, 함수나 분기에 대한 단순 커버리지 외에 응답 시간(latency) 측정이 중요하다.

    • 커버리지와 더불어 성능 프로파일링(예: perf, LTTng 등)을 함께 수행하면, 안정성과 성능 두 측면 모두에서 테스트를 강화할 수 있다.

지속적으로 업데이트되는 ROS2 패키지

ROS2는 꾸준히 버전이 업데이트되며, 배포판(Humble, Iron 등) 간에도 마이그레이션되는 API가 많다. 커버리지 측정 자동화 파이프라인을 구축해두면 다음과 같은 장점이 있다.

  • 하위 호환성(Backward compatibility) 체크

    • 새 버전의 ROS2로 업데이트 시, 이전 버전에서 작성된 테스트 케이스가 모두 통과하는지 확인 가능.

  • API 변경 시 리그레션(Regression) 방지

    • ROS2 핵심 API 사용에 변경이 발생했을 때, 커버리지 하락이나 에러 발생 여부를 신속히 모니터링할 수 있다.

커버리지 측정 시 발생할 수 있는 문제와 해결 방법

커버리지 측정은 일반적인 테스트 실행과 달리 코드에 추가적인 **오버헤드(Overhead)**를 발생시킨다. 다음은 주로 발생하는 문제와 대응 방안이다.

  1. 테스트 실행 시간 증가

    • 인스트루먼테이션된 바이너리는 보통 실행 속도가 느려지고, 파일 입출력이 증가한다.

    • CI 파이프라인에서 테스트 시간을 제한(TTL)하는 경우, 커버리지 측정용 작업을 별도 단계로 분리하거나, 최적화 수준($-Og$ 정도)을 어느 정도 조정하는 방안을 검토한다.

  2. 메모리 사용량 증가

    • 대규모 프로젝트에서 커버리지 데이터를 수집하면, .gcda 파일 등의 크기가 상당히 커질 수 있다.

    • 필요 없는 서드파티 라이브러리나 외부 디렉터리는 제외하도록 lcov --remove 옵션 등을 활용한다.

  3. 동적 라이브러리와 코드 경로 불일치

    • 커버리지 툴이 소스코드 경로와 실제 실행 바이너리(공유 라이브러리 등)의 경로를 정상적으로 매핑하지 못하면 결과에서 누락이 발생할 수 있다.

    • 빌드 디렉터리 구조, 설치 경로(install/ vs build/)를 명확히 파악하고, 경로 설정을 올바르게 구성해야 한다.

테스트 케이스 보강 전략

커버리지 측정의 궁극적인 목적은 “테스트가 충분한가?”를 확인하고 “부족한 부분에 대한 테스트를 추가”하는 것이다. 커버리지가 낮은 부분(라인, 브랜치, 함수 등)에 대해서는 다음과 같은 프로세스로 보강할 수 있다.

  1. 원인 파악

    • 테스트로는 접근하기 어려운 로직인지, 혹은 단순히 테스트 케이스가 누락된 것인지 구분한다.

  2. 새로운 테스트 케이스 설계

    • 예외 상황(오류 발생, 예외 처리), 극단적 입력값(엣지 케이스), 멀티스레드 경합 등이 대표적인 누락 원인이 된다.

  3. 자동화 및 회귀 테스트 강화

    • 한 번 작성된 테스트 케이스는 CI 파이프라인에 추가해, 코드 수정 후에도 지속적으로 검증되도록 설정한다.

  4. 기술 부채(Technical Debt) 청산

    • 불필요하게 복잡한 로직이나 테스트가 어려운 구조는 리팩터링을 고려한다. 간단한 코드로 정리하면 테스트 작성을 수월하게 만들 수 있다.

Last updated