# Pytest 기반 Python 단위 테스트 작성

Pytest는 Python 생태계에서 널리 사용되는 테스트 프레임워크로써, 간결하고 직관적인 방식으로 단위 테스트를 작성하고 실행할 수 있게 해준다. ROS2 Humble 환경에서도 Python 기반 기능을 개발할 때, Pytest를 활용하면 다음과 같은 이점을 얻을 수 있다.

* 최소한의 설정으로 시작 가능
* 다양한 플러그인을 통한 기능 확장
* 직관적이고 가독성이 높은 테스트 코드 작성
* 테스트 함수 별로 독립적인 실행 환경 확보

ROS2 Humble에서 Python 노드나 라이브러리를 작성할 때, 별도의 테스트 폴더(test 디렉터리)나 패키지를 만들어 관리하는 것이 일반적이다. 이때 CMakeLists.txt나 setup.py 등에 테스트 실행 과정을 추가해 두면, 빌드 시 자동으로 테스트를 돌려볼 수 있다.

#### ROS2에서의 테스트 환경 구성

ROS2 Python 패키지의 구조는 일반적으로 다음과 같은 디렉터리 형태를 가진다.

```
my_ros2_package/
├─ package.xml
├─ setup.py
├─ CMakeLists.txt
├─ my_ros2_package/
│   ├─ __init__.py
│   ├─ node.py
│   └─ 기타 소스 파일...
└─ test/
    ├─ test_node.py
    └─ 다른 테스트 스크립트...
```

위 구조에서 `test/` 디렉터리에 Pytest 기반 테스트 스크립트를 배치하고, `setup.py` 혹은 `CMakeLists.txt`에 테스트를 등록하여 사용할 수 있다. 예를 들어 `pytest` 명령을 통해 직접 실행하거나, colcon 빌드 시 테스트를 자동으로 수행하도록 설정할 수 있다.

**직접 실행**: 다음과 같이 단순히 `pytest` 명령을 실행한다.

```bash
cd my_ros2_package
pytest
```

이 경우 `test/` 디렉터리 안의 테스트 스크립트를 자동으로 탐색하여 실행한다.

**colcon 빌드와 연동**: `colcon test` 명령을 사용할 수 있도록 `setup.py`나 `CMakeLists.txt`에 다음과 같은 방식으로 테스트를 등록한다.

```cmake
if(BUILD_TESTING)
  find_package(ament_cmake_pytest REQUIRED)
  ament_add_pytest_tests(test test)
endif()
```

이렇게 하면 `colcon build --packages-select my_ros2_package && colcon test --packages-select my_ros2_package` 명령을 통해 같은 테스트를 실행할 수 있다.

#### Pytest 구조와 작성 방식

Pytest를 사용하기 위한 가장 간단한 구조는 다음과 같다.

```python
# test_node.py
import pytest
from my_ros2_package.node import some_function

def test_some_function_behavior():
    # given
    x = 10
    y = 5
    # when
    result = some_function(x, y)
    # then
    assert result == 15
```

* **테스트 함수**: `test_`로 시작하는 함수 이름을 Pytest가 자동으로 인식한다.
* **assert**문: Python 내장 `assert`를 사용하며, 실패하면 바로 예외가 발생하여 테스트가 종료된다.
* **given/when/then** 패턴: 테스트 코드를 직관적으로 작성하기 위해 사용하는 구조다. “어떤 조건(given)에서, 특정 로직을 실행했을 때(when), 기대되는 결과가 나오는지(then)”를 명확히 기술한다.

#### fixture 활용

Pytest에는 반복적으로 필요한 테스트 환경 세팅 및 해제를 간소화하기 위한 **fixture**라는 개념이 존재한다. 예를 들어, 매번 테스트 함수가 실행되기 전에 특정 자원을 할당하고, 끝난 후에는 자원을 해제해야 하는 상황에서 fixture를 활용할 수 있다.

```python
# test_node.py
import pytest
from my_ros2_package.node import NodeHandler

@pytest.fixture
def node_handler():
    handler = NodeHandler()
    yield handler
    handler.shutdown()

def test_node_handler_init(node_handler):
    assert node_handler.is_initialized() is True

def test_node_handler_computation(node_handler):
    result = node_handler.compute(1, 2)
    assert result == 3
```

* `@pytest.fixture` 데코레이터: fixture 함수를 정의하고, 테스트 함수에 **매개변수**로 fixture 함수를 지정하면, Pytest는 자동으로 fixture를 실행하여 필요한 자원을 준비해 준다.
* `yield` 구문: fixture 함수 내에서 자원을 할당하고, `yield` 전후로 해제 작업을 정의한다. Pytest는 fixture가 끝난 후 `yield` 뒤의 코드를 수행하여 자원을 회수한다.

#### 간단한 예제

다음은 ROS2 환경에서 사용되는 함수 예시와, 그에 대해 작성한 테스트 코드 예시다.

```python
# my_ros2_package/node.py
def add_two_ints(a: int, b: int) -> int:
    return a + b

def multiply_two_ints(a: int, b: int) -> int:
    return a * b
# test/test_node.py
import pytest
from my_ros2_package.node import add_two_ints, multiply_two_ints

def test_add_two_ints():
    assert add_two_ints(2, 3) == 5
    assert add_two_ints(-1, 1) == 0

def test_multiply_two_ints():
    assert multiply_two_ints(2, 3) == 6
    assert multiply_two_ints(-1, 5) == -5
```

이런 식으로 테스트 코드를 간단히 작성한 후 다음 명령으로 실행할 수 있다.

```bash
pytest
```

혹은

```bash
colcon build --packages-select my_ros2_package
colcon test --packages-select my_ros2_package
colcon test-result --verbose
```

#### 파라미터화 테스트

Pytest는 동일한 테스트 함수를 여러 입력값에 대해 반복 실행해야 할 때 파라미터화 기능을 제공한다. 예를 들어 다음과 같이 작성하면, 다른 매개변수 쌍에 대해서도 동일한 테스트 로직을 반복 실행할 수 있다.

```python
@pytest.mark.parametrize(
    "a,b,expected", 
    [
        (2, 3, 5),
        (0, 0, 0),
        (-1, 1, 0),
    ]
)
def test_add_two_ints_param(a, b, expected):
    assert add_two_ints(a, b) == expected
```

#### Mocking과 의존성 격리

실제 ROS2 환경은 노드 간 통신 및 외부 의존성을 많이 활용한다. 테스트 시에는 외부 자원을 직접 호출하지 않고, **Mock** 객체로 대체하여 테스트를 독립적이고 재현 가능하게 만들 수 있다. Python의 표준 라이브러리인 `unittest.mock` 모듈을 이용하거나, Pytest용 플러그인/fixture를 활용하여 필요한 부분만 모의(Mock) 처리할 수 있다.

아래 예시는 `unittest.mock`에서 제공하는 `patch` 데코레이터로 ROS2 노드의 일부 함수를 모의 처리하는 예시다.

```python
# my_ros2_package/node.py
import rclpy
from rclpy.node import Node

def create_and_spin_node(node_name: str):
    rclpy.init()
    node = Node(node_name)
    rclpy.spin_once(node)
    return node
# test/test_node_with_mock.py
import pytest
from unittest.mock import patch
from my_ros2_package.node import create_and_spin_node

@patch("my_ros2_package.node.rclpy.init")
@patch("my_ros2_package.node.rclpy.spin_once")
def test_create_and_spin_node(mock_spin_once, mock_init):
    node = create_and_spin_node("test_node")
    mock_init.assert_called_once()        # rclpy.init()가 한 번 호출되었는지 확인
    mock_spin_once.assert_called_once()   # rclpy.spin_once()가 한 번 호출되었는지 확인
    assert node.get_name() == "test_node"
```

* `@patch("모듈.함수")`: 원하는 모듈의 특정 함수나 클래스, 또는 객체를 Mock으로 대체한다.
* `mock_init`, `mock_spin_once`: 패치된 Mock 객체가 자동 주입된다.
* 실제로 ROS2를 초기화하지 않아도 테스트가 수행되므로, 빠르고 독립적인 테스트가 가능하다.

#### Monkeypatch를 활용한 Mock

Pytest에서 제공하는 `monkeypatch` fixture를 활용해 직접 특정 함수나 속성을 원하는 Mock으로 교체할 수도 있다. 예를 들어, 다음과 같이 할 수 있다.

```python
# test/test_node_with_monkeypatch.py
import pytest
from my_ros2_package.node import create_and_spin_node

def mock_init():
    print("Mock rclpy.init called")

def mock_spin_once(node):
    print("Mock rclpy.spin_once called")

@pytest.mark.usefixtures("monkeypatch")
def test_create_and_spin_node(monkeypatch):
    monkeypatch.setattr("my_ros2_package.node.rclpy.init", mock_init)
    monkeypatch.setattr("my_ros2_package.node.rclpy.spin_once", mock_spin_once)
    
    node = create_and_spin_node("test_node")
    assert node.get_name() == "test_node"
```

* `monkeypatch.setattr("모듈.함수", 교체할_함수)`: 실행 시점에 기존 함수를 Mock 함수로 교체한다.
* 실행 결과로 실제 `rclpy.init`과 `rclpy.spin_once`가 호출되지 않으며, 우리가 지정한 `mock_init`과 `mock_spin_once`가 대신 호출된다.

#### 테스트 커버리지(coverage)

테스트 커버리지는 코드가 테스트에 의해 얼마나 실행되는지를 수치화한 것이다. Pytest와 함께 커버리지를 측정하기 위해서는 `coverage` 또는 `pytest-cov`와 같은 라이브러리를 사용할 수 있다. `pytest-cov` 플러그인을 이용하면 간단히 다음과 같이 실행 가능하다.

```bash
pytest --cov=my_ros2_package --cov-report=term-missing
```

* `--cov=my_ros2_package`: `my_ros2_package` 디렉터리(또는 패키지)에 대해 커버리지 측정
* `--cov-report=term-missing`: 어떤 줄(line)이 테스트되지 않았는지 콘솔에 표시

ROS2 프로젝트에서는 다음과 같이 colcon 빌드 설정을 통해 자동화할 수 있다.

```bash
colcon build --packages-select my_ros2_package
colcon test --packages-select my_ros2_package --merge-install
colcon test-result --verbose
```

`ament_cmake_pytest` 또는 `ament_cmake_python`을 이용하면 `pytest-cov`를 빌드 종속성에 추가하고, 빌드 단계에서 자동으로 측정하도록 설정할 수도 있다.

#### 의존성 있는 노드 테스트

ROS2 노드는 서로 다른 프로세스에서 동작하고, 토픽, 서비스, 액션 등을 통해 통신한다. 따라서 단순히 함수 단위 테스트로는 충분하지 않은 경우가 많다. Pytest에서는 ROS2 노드를 실제로 스핀(spin) 시키면서 통신 테스트를 작성할 수도 있다.

**간단한 통신 테스트 예시**

다음 예시는 두 노드 간 토픽 통신을 테스트하기 위해 로컬에서 스핀하며 메시지를 주고받는 시나리오다.

```python
# my_ros2_package/publisher_node.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class PublisherNode(Node):
    def __init__(self):
        super().__init__('publisher_node')
        self.publisher_ = self.create_publisher(String, 'chatter', 10)

    def publish_message(self, msg_text: str):
        msg = String()
        msg.data = msg_text
        self.publisher_.publish(msg)
# my_ros2_package/subscriber_node.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class SubscriberNode(Node):
    def __init__(self):
        super().__init__('subscriber_node')
        self.subscription_ = self.create_subscription(
            String, 'chatter', self.callback, 10
        )
        self.last_msg = None

    def callback(self, msg: String):
        self.last_msg = msg.data
# test/test_pub_sub.py
import pytest
import rclpy
from my_ros2_package.publisher_node import PublisherNode
from my_ros2_package.subscriber_node import SubscriberNode
from std_msgs.msg import String

@pytest.fixture
def rclpy_init_shutdown():
    rclpy.init()
    yield
    rclpy.shutdown()

def test_pub_sub(rclpy_init_shutdown):
    publisher_node = PublisherNode()
    subscriber_node = SubscriberNode()
    
    # Executor 설정
    executor = rclpy.executors.SingleThreadedExecutor()
    executor.add_node(publisher_node)
    executor.add_node(subscriber_node)
    
    # 메시지 발행
    publisher_node.publish_message("Hello ROS2")
    
    # 스핀한 다음 메시지를 수신했는지 확인
    for _ in range(10):
        executor.spin_once(timeout_sec=0.1)
        if subscriber_node.last_msg is not None:
            break
    
    assert subscriber_node.last_msg == "Hello ROS2"
    
    # 노드 제거
    executor.remove_node(publisher_node)
    executor.remove_node(subscriber_node)
```

* `SingleThreadedExecutor`를 이용해 한 스레드에서 두 노드를 동시에 관리한다.
* 테스트가 완료될 때까지 `executor.spin_once`를 반복하며, 메시지가 수신되는지 확인한다.
* `rclpy.init()`과 `rclpy.shutdown()`을 fixture로 묶어서 테스트마다 ROS2를 초기화/해제하도록 구성했다.

이처럼 실제 노드 간 통신을 포함한 테스트도 Pytest로 작성할 수 있다. 다만, 물리적 장비나 외부 환경 의존성이 있는 경우에는 Mock이나 별도의 시뮬레이션 환경을 통해 테스트를 분리하는 것이 유리하다.

#### 고급 테스트 기법과 베스트 프랙티스

**통합 테스트와 단위 테스트의 분리**

Pytest를 활용해 작성되는 테스트는 일반적으로 "단위 테스트"와 "통합 테스트"로 구분할 수 있다.

* 단위 테스트(Unit Test): 개별 함수나 클래스가 기대한 대로 동작하는지 검증
* 통합 테스트(Integration Test): 서로 다른 모듈 간 결합이 올바르게 이뤄졌는지 검증

ROS2 환경에서 노드 단위 테스트(통합 테스트)와 로직 단위 테스트(단위 테스트)를 명확히 분리하면, 유지보수성과 테스트 속도를 모두 확보할 수 있다. 특히 다음 사항을 유의한다.

1. **단위 테스트**는 최대한 외부 종속성 없이 순수 Python 함수(또는 클래스)만 테스트한다.
2. **통합 테스트**에서는 여러 노드를 동시에 실행하거나, ROS2 통신을 실제로 경험해야 하는 경우에 집중한다.
3. 가능한 한 Mocking 또는 Monkeypatch를 통해 외부 의존성을 격리하고, 테스트 속도를 높인다.

**테스트 함수 이름과 구조**

Pytest는 함수 이름이 `test_`로 시작하는 모든 함수를 자동 탐색한다. 특히 다음과 같은 규칙을 따르면 가독성을 높일 수 있다.

`test_functionName_caseOrCondition`:

```python
def test_add_two_ints_positive():
    ...

def test_add_two_ints_negative():
    ...
```

`test_class`:

형태로 클래스 단위 구조를 만들어 관련된 테스트를 그룹화할 수도 있다.

```python
class TestAddTwoInts:
    def test_positive(self):
        ...
    def test_negative(self):
        ...
```

**Pytest 플러그인 활용**

Pytest는 플러그인 생태계가 풍부하므로, 다양한 플러그인을 활용하여 테스트 환경을 강화할 수 있다.

* **pytest-cov**: 코드 커버리지 측정
* **pytest-xdist**: 멀티코어 혹은 멀티머신 분산 테스트
* **pytest-timeout**: 특정 테스트가 너무 오래 걸릴 경우 타임아웃 처리
* **pytest-rerunfailures**: flaky(간헐적) 테스트가 실패했을 때 자동으로 재시도

ROS2 프로젝트의 규모가 커질수록 빌드 및 테스트 시간이 늘어나므로, `pytest-xdist` 같은 병렬화 도구를 사용하는 것이 빌드 시간을 단축하는 데 도움이 될 수 있다. 예를 들어 아래 명령으로 4개의 코어에서 병렬로 테스트를 수행할 수 있다.

```bash
pytest -n 4
```

**Pytest 설정 파일(pytest.ini)**

프로젝트 루트나 테스트 디렉터리에 `pytest.ini` 파일을 배치해, 공통 Pytest 설정을 관리할 수 있다. 예를 들어 아래와 같은 설정을 통해, 특정 디렉터리를 테스트 탐색 대상에서 제외하거나, 엄격한 경고를 활성화할 수 있다.

```ini
# pytest.ini
[pytest]
minversion = 6.0
addopts = --strict-markers
testpaths =
    test
    src
python_files = test_*.py
```

* `testpaths`: 테스트 파일을 찾을 디렉터리
* `python_files`: 테스트 파일의 네이밍 패턴
* `addopts`: 추가 옵션(예: 마커 사용 시 strict-mode 활성화)

**ROS2와 Pytest 융합 시 주의사항**

ROS2 기반 Python 테스트를 Pytest로 진행할 때에는 다음을 유의한다.

1. ROS2 초기화/해제
   * `rclpy.init()`와 `rclpy.shutdown()`은 전역 레벨에서 한 번씩만 호출하는 것이 권장되나, 테스트 스크립트마다 독립적으로 실행해야 할 수도 있다. fixture나 `conftest.py` 등을 활용해 중앙 집중식 관리가 가능하다.
2. 멀티프로세스 환경
   * ROS2 노드는 프로세스 격리로 구동되는 경우가 많다. Pytest에서 스폰된 프로세스 간 통신을 테스트하려면, 서브프로세스나 로컬 exec를 사용하거나 Docker/VM 환경에서 테스트를 구성할 수도 있다.
3. 동기 vs. 비동기
   * ROS2 함수나 노드 콜백이 비동기적으로 동작할 수 있다. Pytest 테스트 함수는 기본적으로 동기적으로 동작하므로, 비동기 처리에 대한 대기 로직(예: `spin_once` 반복, 조건을 만족할 때까지 대기 등)을 적절히 구현해 주어야 한다.

**테스트 시각화와 결과 보고**

규모가 커진 프로젝트에서는 테스트 결과를 시각적으로 확인하거나 자동화 시스템(CI/CD)에서 활용할 필요가 있다. 예를 들어, GitHub Actions, GitLab CI/CD, Jenkins 등에서 Pytest의 XML 리포트를 활용해 테스트 결과(성공/실패/스킵 등)를 집계할 수 있다.

```bash
pytest --junitxml=report.xml
```

이렇게 생성된 `report.xml` 파일을 CI 환경에 업로드하면, 테스트 결과를 UI에서 확인할 수 있다. 또한 커버리지 레포트를 HTML 형태로 만들어서, 어느 부분이 테스트되지 않았는지 쉽게 파악할 수도 있다.

```bash
pytest --cov=my_ros2_package --cov-report=html
```

**mermaid를 이용한 테스트 구조 시각화 예시**

아래는 패키지 구조와 테스트 간 의존관계를 도식화한 간단한 다이어그램 예시다.

{% @mermaid/diagram content="flowchart LR
A\[my\_ros2\_package<br>add\_two\_ints] --> B(test\_add\_two\_ints.py)
A\[my\_ros2\_package<br>multiply\_two\_ints] --> B(test\_multiply\_two\_ints.py)
C\[PublisherNode] --> D(test\_pub\_sub.py)
E\[SubscriberNode] --> D(test\_pub\_sub.py)" %}

이처럼 다이어그램을 통해 테스트가 어떤 컴포넌트를 검증하는지 한눈에 파악할 수 있다.
