Devin.KR

테스트와 CI - launch_testing·pytest

개발자KR 조회 10

이 장에서 배우는 것

앞 장에서 지도와 위치추정, 경로계획이 두리의 주행으로 이어지는 흐름을 살펴보았다. 이제 코드를 바꾼 뒤에도 그 흐름을 믿을 수 있는지 확인할 차례다. 사람이 터미널 여러 개를 열어 확인하는 방식은 작은 실험에는 유용하지만, 변경할 때마다 같은 조건을 재현하기 어렵다. 테스트는 확인할 조건과 실패의 기준을 코드로 남기는 작업이다.

이 장에서는 배터리 잔량으로 주행 허용 여부를 판단하는 작은 노드를 만든다. 계산 규칙은 순수 Python으로 확인하고, 노드의 메시지 처리와 별도 프로세스 사이의 통신을 차례로 검사한다. 마지막에는 지속적 통합(Continuous Integration, CI)으로 같은 검사를 GitHub Actions에서 실행한다. 여기서 만드는 허용 신호는 테스트를 설명하기 위한 정책 출력이며, 실제 구동기의 안전 정지 기능을 대신하지 않는다.

  • 판단 로직과 ROS 연결 코드를 분리하여 실패 원인을 좁힌다.
  • pytest로 경계값과 노드 콜백의 출력 메시지를 검사한다.
  • launch_testing으로 실행한 노드와 토픽을 주고받고 종료 상태를 확인한다.
  • GitHub Actions에서 빌드, 테스트, 결과 판정을 서로 다른 단계로 실행한다.

문제 상황

두리는 배터리 잔량이 30% 이상이면 새 주행을 허용하기로 했다. 개발자는 조건문을 수정한 뒤 70%를 보내 보고 허용 신호가 나오는 것을 확인했다. 그러나 실제 배달에서는 잔량이 정확히 30%일 때 주행이 거부되었다. 비교 연산자가 잘못된 것이다. 다른 수정에서는 판단 함수가 맞는데도 출력 토픽 이름이 달라져 주행 관리 노드가 신호를 받지 못했다.

두 문제는 검사할 위치가 다르다. 비교 연산자는 함수를 직접 호출하면 확인할 수 있다. 토픽 연결은 노드를 실행하고 메시지를 주고받아야 확인할 수 있다. 모든 검사를 전체 시스템에서 수행하면 실패 원인을 찾는 데 시간이 걸리고, 함수만 검사하면 배포된 실행 파일과 통신 경로의 문제를 놓친다.

테스트의 합격 조건도 구체적이어야 한다. “노드가 잘 실행된다” 대신 “45를 보내면 제한 시간 안에 참을 받고, 이어서 15를 보내면 거짓을 받는다”라고 정한다. 이 조건에는 입력, 출력, 순서, 시간 제한이 모두 들어 있다. 프로세스가 살아 있다는 사실과 필요한 동작을 한다는 사실을 구분하는 출발점이다.

검사 범위를 나누어 실패 원인을 좁힌다

단위 테스트(unit test)는 작은 동작을 주변 환경에서 분리하여 검사한다. 이 장에서는 배터리 판단 함수와 노드 콜백을 각각 대상으로 삼는다. 통합 테스트(integration test)는 여러 구성 요소가 연결된 상태를 검사한다. 별도 프로세스에서 실행한 노드에 실제 토픽 메시지를 보내는 검사가 여기에 해당한다.

검사 범위마다 발견할 수 있는 문제가 다르다
검사 대상실행 환경발견하는 문제남는 범위
판단 함수Python과 pytest경계값, 범위 처리ROS 연결
노드 콜백rclpy와 pytest메시지 구성, 예외 대응프로세스 간 전달
실행된 노드launch_testing실행 파일, 토픽 연결, 종료실제 로봇 전체 동작

분리의 기준은 파일 개수가 아니라 책임이다. 판단 함수는 ROS 메시지나 노드를 알 필요가 없다. 노드는 메시지에서 정수를 꺼내 함수를 호출하고 결과를 메시지에 넣는다. 이렇게 하면 ROS가 없는 macOS나 Linux에서도 정책을 실행해 볼 수 있다. ROS 검사는 Jazzy와 rclpy를 사용할 수 있는 환경에서 수행한다. CI 예제는 Jazzy의 바이너리 패키지를 사용할 수 있는 Ubuntu 24.04를 기준으로 한다.

판단 함수와 콜백과 프로세스 통신을 따로 검사하면 실패 범위를 좁힐 수 있다

입력의 계약도 먼저 정한다. 잔량은 0부터 100까지의 정수이며, 30 이상이면 참이다. 범위를 벗어나면 판단 함수는 ValueError를 발생시킨다. 노드는 이 예외를 받아 거짓을 발행한다. 잘못된 값을 조용히 0이나 100으로 바꾸지 않으므로, 함수의 호출자는 잘못된 입력을 구별할 수 있다. ROS 메시지의 정수 형식은 Int32가 담당하며, 이 예제의 판단 함수는 추가적인 자료형 검사를 제공하지 않는다.

비동기 테스트는 조건과 마감 시각으로 기다린다

노드를 시작한 직후에는 통신 상대가 아직 발견되지 않았을 수 있다. 따라서 발행 직후 결과 목록을 검사하면 정상적인 구현도 실패한다. 반대로 긴 sleep을 넣으면 빠른 환경에서도 시간을 낭비하고, 느린 환경에서는 여전히 실패할 수 있다. 기다릴 대상은 몇 초의 경과가 아니라 기대한 출력의 도착이다.

통합 테스트는 단조 시계(monotonic clock)로 마감 시각을 정한다. 그때까지 입력을 반복해서 보내고, 실행기를 짧게 진행하여 응답 콜백이 실행되도록 한다. 기대한 값을 받으면 즉시 끝낸다. 시스템 시각 보정으로 대기 시간이 흔들리지 않도록 time.time 대신 time.monotonic을 사용한다.

입력을 반복하는 방식은 이 예제처럼 같은 값을 여러 번 처리해도 문제가 없는 경우에 적합하다. 배달 주문 생성처럼 호출 횟수 자체가 의미를 갖는 기능에서는 그대로 사용하면 안 된다. 그런 테스트에는 준비 상태를 확인하는 절차와 요청을 구분할 식별자가 필요하다.

준비 신호 뒤에도 통신 발견을 기다리며 제한 시간 안에 기대한 응답을 확인해야 한다

launch_testing의 ReadyToTest는 검사 코드를 시작할 수 있게 하는 신호다. 모든 토픽의 상대 발견이 완료되었다는 보증은 아니다. 또한 실행 중 검사를 통과했다고 종료까지 정상인 것은 아니다. 이 장의 통합 테스트는 실행 중에는 메시지 왕복을 확인하고, 실행 종료 후에는 대상 프로세스의 종료 코드를 확인한다.

CI는 테스트 실행과 결과 판정을 구분한다

로컬에서 성공한 검사를 저장소 변경마다 다시 실행하면, 변경을 합치기 전에 회귀를 발견할 수 있다. GitHub Actions 예제는 저장소를 작업 공간의 src 아래에 내려받고, 의존성을 설치한 뒤 colcon으로 빌드하고 테스트한다. 테스트 단계는 빌드 결과의 환경 설정을 읽어 실행 파일과 Python 패키지를 찾는다.

colcon test가 실행되었다는 사실만으로 모든 검사가 통과했다고 판단하지 않는다. 이어서 colcon test-result --verbose로 기록된 결과를 읽고 실패를 작업 상태에 반영한다. 테스트 명령 자체가 실패해도 결과 확인 단계는 실행하도록 구성한다. CI의 초록색 표시는 정의한 검사가 통과했다는 뜻이며, 아직 작성하지 않은 시나리오까지 검증했다는 뜻은 아니다.

아래 설정은 하나의 테스트 작업과 하나의 통합 테스트를 실행한다. 여러 작업을 같은 호스트에서 동시에 실행하도록 확장한다면 ROS_DOMAIN_ID 등의 격리 전략도 함께 정해야 한다. 테스트마다 노드 이름만 바꾸어도 같은 토픽으로 메시지가 섞일 수 있기 때문이다.

완성 코드

저장소의 루트가 duri_checks 패키지 디렉터리라고 가정한다. 로컬에서는 이 디렉터리를 작업 공간의 src 아래에 둔다. 아래 파일을 그대로 만들고 resource/duri_checks와 duri_checks/__init__.py는 내용이 없는 파일로 만든다. 순수 Python 보조 예제는 logic.py이며, ROS나 pytest를 설치하지 않고도 실행할 수 있다.

duri_checks/
    package.xml
    setup.py
    setup.cfg
    resource/
        duri_checks
    duri_checks/
        __init__.py
        logic.py
        gate_node.py
    test/
        test_logic.py
        test_node.py
        test_launch.py
    .github/
        workflows/
            tests.yml

package.xml

<?xml version="1.0"?>
<package format="3">
  <name>duri_checks</name>
  <version>0.1.0</version>
  <description>Battery gate and executable tests for Duri.</description>
  <maintainer email="author@example.com">Duri Author</maintainer>
  <license>Apache-2.0</license>
  <buildtool_depend>ament_python</buildtool_depend>
  <exec_depend>rclpy</exec_depend>
  <exec_depend>std_msgs</exec_depend>
  <test_depend>python3-pytest</test_depend>
  <test_depend>launch</test_depend>
  <test_depend>launch_ros</test_depend>
  <test_depend>launch_testing</test_depend>
  <export>
    <build_type>ament_python</build_type>
  </export>
</package>

setup.py

from setuptools import setup

package_name = "duri_checks"

setup(
    name=package_name,
    version="0.1.0",
    packages=[package_name],
    data_files=[
        (
            "share/ament_index/resource_index/packages",
            ["resource/" + package_name],
        ),
        ("share/" + package_name, ["package.xml"]),
    ],
    install_requires=["setuptools"],
    zip_safe=True,
    maintainer="Duri Author",
    maintainer_email="author@example.com",
    description="Battery gate and executable tests for Duri.",
    license="Apache-2.0",
    entry_points={
        "console_scripts": [
            "battery_gate = duri_checks.gate_node:main",
        ],
    },
)

setup.cfg

[develop]
script_dir=$base/lib/duri_checks

[install]
install_scripts=$base/lib/duri_checks

[tool:pytest]
testpaths = test

duri_checks/logic.py

MIN_BATTERY = 30


def can_depart(percent: int) -> bool:
    if not 0 <= percent <= 100:
        raise ValueError("battery must be between 0 and 100")
    return percent >= MIN_BATTERY


def main() -> None:
    cases = [
        (0, False),
        (29, False),
        (30, True),
        (100, True),
    ]
    for percent, expected in cases:
        actual = can_depart(percent)
        if actual is not expected:
            raise AssertionError(f"unexpected result: {percent}")
        print(f"battery={percent}: allowed={actual}")

    for percent in (-1, 101):
        try:
            can_depart(percent)
        except ValueError:
            print(f"battery={percent}: rejected")
        else:
            raise AssertionError(f"accepted invalid value: {percent}")

    print("pure Python checks: 6 passed")


if __name__ == "__main__":
    main()

duri_checks/gate_node.py

import rclpy
from rclpy.context import Context
from rclpy.executors import ExternalShutdownException
from rclpy.node import Node
from std_msgs.msg import Bool, Int32

from duri_checks.logic import can_depart


class BatteryGate(Node):
    def __init__(self, *, context=None):
        super().__init__("battery_gate", context=context)
        self._publisher = self.create_publisher(
            Bool, "duri/departure_allowed", 10
        )
        self._subscription = self.create_subscription(
            Int32, "duri/battery_percent", self._on_battery, 10
        )

    def _on_battery(self, message: Int32) -> None:
        try:
            allowed = can_depart(message.data)
        except ValueError:
            allowed = False
        self._publisher.publish(Bool(data=allowed))


def main(args=None) -> None:
    context = Context()
    rclpy.init(args=args, context=context)
    node = None
    try:
        node = BatteryGate(context=context)
        rclpy.spin(node)
    except (KeyboardInterrupt, ExternalShutdownException):
        pass
    finally:
        if node is not None:
            node.destroy_node()
        context.try_shutdown()


if __name__ == "__main__":
    main()

test/test_logic.py

import pytest

from duri_checks.logic import can_depart


@pytest.mark.parametrize(
    ("percent", "expected"),
    [(0, False), (29, False), (30, True), (100, True)],
)
def test_boundary_values(percent, expected):
    assert can_depart(percent) is expected


@pytest.mark.parametrize("percent", [-1, 101])
def test_invalid_range(percent):
    with pytest.raises(ValueError, match="between 0 and 100"):
        can_depart(percent)

test/test_node.py

from unittest.mock import patch

import pytest
import rclpy
from rclpy.context import Context
from std_msgs.msg import Bool, Int32

from duri_checks.gate_node import BatteryGate


@pytest.fixture
def gate():
    context = Context()
    rclpy.init(args=[], context=context)
    node = None
    try:
        node = BatteryGate(context=context)
        yield node
    finally:
        if node is not None:
            node.destroy_node()
        context.try_shutdown()


@pytest.mark.parametrize(
    ("percent", "expected"),
    [(30, True), (29, False), (-1, False)],
)
def test_callback_publishes_decision(gate, percent, expected):
    with patch.object(gate, "_publisher") as publisher:
        gate._on_battery(Int32(data=percent))

        publisher.publish.assert_called_once()
        message = publisher.publish.call_args.args[0]
        assert isinstance(message, Bool)
        assert message.data is expected

test/test_launch.py

import time
import unittest

import launch
import launch_ros.actions
import launch_testing
import launch_testing.actions
import launch_testing.asserts
import pytest
import rclpy
from rclpy.context import Context
from rclpy.executors import SingleThreadedExecutor
from rclpy.node import Node
from std_msgs.msg import Bool, Int32


@pytest.mark.launch_test
def generate_test_description():
    duri_node = launch_ros.actions.Node(
        package="duri_checks",
        executable="battery_gate",
        name="battery_gate_under_test",
        output="screen",
    )
    description = launch.LaunchDescription([
        duri_node,
        launch_testing.actions.ReadyToTest(),
    ])
    return description, {"duri_node": duri_node}


class TestBatteryMessages(unittest.TestCase):
    def test_allow_then_deny(self):
        context = Context()
        rclpy.init(args=[], context=context)
        probe = None
        executor = None
        try:
            probe = Node("battery_test_probe", context=context)
            executor = SingleThreadedExecutor(context=context)
            executor.add_node(probe)

            received = []
            subscription = probe.create_subscription(
                Bool,
                "duri/departure_allowed",
                lambda message: received.append(message.data),
                10,
            )
            publisher = probe.create_publisher(
                Int32, "duri/battery_percent", 10
            )

            for percent, expected in [(45, True), (15, False)]:
                received.clear()
                deadline = time.monotonic() + 8.0
                matched = False

                while time.monotonic() < deadline:
                    publisher.publish(Int32(data=percent))
                    executor.spin_once(timeout_sec=0.1)
                    if expected in received:
                        matched = True
                        break

                self.assertTrue(
                    matched,
                    f"battery={percent}, expected={expected}, "
                    f"received={received}",
                )

            probe.destroy_subscription(subscription)
        finally:
            if executor is not None:
                executor.shutdown()
            if probe is not None:
                probe.destroy_node()
            context.try_shutdown()


@launch_testing.post_shutdown_test()
class TestProcessExit(unittest.TestCase):
    def test_clean_exit(self, proc_info, duri_node):
        launch_testing.asserts.assertExitCodes(
            proc_info,
            process=duri_node,
            allowable_exit_codes=[0],
        )

.github/workflows/tests.yml

name: duri-tests

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-24.04
    defaults:
      run:
        shell: bash
    env:
      ROS_DOMAIN_ID: "71"
    steps:
      - uses: actions/checkout@v4
        with:
          path: src/duri_checks

      - uses: ros-tooling/setup-ros@v0.7
        with:
          required-ros-distributions: jazzy

      - name: Install dependencies
        run: |
          sudo apt-get update
          sudo apt-get install -y \
            ros-jazzy-ros-base \
            python3-colcon-common-extensions \
            python3-pytest
          rosdep update
          rosdep install --from-paths src --ignore-src \
            --rosdistro jazzy -y

      - name: Build
        run: |
          source /opt/ros/jazzy/setup.bash
          colcon build --packages-select duri_checks

      - name: Test
        run: |
          source /opt/ros/jazzy/setup.bash
          source install/setup.bash
          colcon test --packages-select duri_checks \
            --event-handlers console_direct+

      - name: Check test results
        if: always()
        run: colcon test-result --verbose

줄별 해설

logic.py의 MIN_BATTERY는 정책의 경계를 한곳에 모은다. can_depart의 첫 조건은 허용된 입력 범위를 확인하고, 마지막 return은 출발 조건만 판단한다. 29와 30을 함께 검사해야 비교 연산자가 한 단계 어긋난 오류를 찾을 수 있다. 0과 100은 유효 범위의 양 끝을 확인한다.

보조 예제의 main은 각 결과를 직접 비교한다. 결과가 다르면 AssertionError를 발생시키므로 화면에 그럴듯한 값을 출력하고 끝나는 시연과 다르다. 범위 밖 입력에서는 ValueError가 발생해야 통과한다. 여기서는 일반 if 문을 사용하므로 Python의 최적화 옵션으로 assert 문이 제거되는 경우에도 검사가 유지된다.

gate_node.py의 생성자는 발행자와 구독자를 연결한다. 콜백은 판단 결과를 Bool 메시지로 바꾸며, 범위를 벗어난 잔량은 거짓으로 처리한다. main의 finally는 정상 종료뿐 아니라 예외가 발생한 경로에서도 노드와 문맥을 정리한다. 종료 요청에 따른 두 예외만 처리하고, 다른 실행 오류는 밖으로 전달하여 프로세스 실패로 드러나게 한다.

test_logic.py의 parametrize는 입력과 예상값의 쌍마다 독립된 검사 사례를 만든다. 반복문 안에 여러 assert를 넣는 것과 달리, 한 사례가 실패해도 다른 사례의 결과를 확인하기 쉽다. pytest.raises는 오류가 발생한다는 사실을 검사한다. 예외 메시지의 일부도 확인하여 입력 범위 오류라는 의도를 명시한다.

test_node.py의 fixture는 테스트 자원을 준비하고 반환 뒤에 정리하는 장치다. yield 앞에서 노드를 만들고 finally에서 해제한다. 각 사례에 별도 Context를 주므로 앞선 검사가 전역 초기화 상태를 남기는 문제를 줄인다. fixture의 기본 범위에 따라 매 사례마다 노드가 새로 만들어진다.

patch.object는 콜백이 사용하는 발행자 참조를 잠시 모의 객체(mock object)로 바꾼다. 실제 발행자의 publish를 실행하지 않고 호출 횟수와 인자를 기록한다. 이 테스트는 한 입력에 한 메시지를 만들었는지, 형식이 Bool인지, 데이터가 맞는지를 검사한다. 실제 발행자는 노드가 계속 관리하며 patch가 끝나면 속성도 복원된다. 통신을 검사하지 않는다는 범위를 알고 사용해야 한다.

test_launch.py의 generate_test_description은 대상 노드와 검사 시작 신호를 반환한다. 함께 반환한 사전의 duri_node는 종료 검사 메서드의 인자로 전달된다. 종료 코드를 검사할 때 이 참조를 지정하면 다른 실행 프로세스의 결과와 혼동하지 않는다.

검사 노드 probe에는 자체 실행기를 붙인다. spin_once가 출력 콜백을 실행해야 received가 바뀐다. 발행만 반복하고 실행기를 진행하지 않으면 메시지가 도착해도 Python의 결과 목록에는 반영되지 않는다. 각 입력의 제한 시간은 8초이며, 응답이 빠르면 그만큼 일찍 끝난다. spin_once의 대기와 운영체제 스케줄링 때문에 실제 종료 시점은 마감 시각을 조금 넘을 수 있다.

received.clear는 앞서 관측한 응답을 지운다. 다만 미처 처리하지 못한 이전 메시지까지 지우는 기능은 아니다. 이 예제는 참을 확인한 뒤 거짓을 확인하므로, 늦게 도착한 이전의 참이 두 번째 검사를 통과시키지 않는다. 여러 입력이 같은 출력을 갖는 검사를 정밀하게 구분하려면 요청 식별자를 포함하는 인터페이스가 필요하다.

setup.cfg는 실행 파일을 ROS 도구가 찾는 패키지별 디렉터리에 설치한다. setup.py의 실행 진입점은 battery_gate라는 명령을 main과 연결한다. 함수 테스트가 성공하더라도 이 설정이 틀리면 통합 테스트가 실행 파일을 찾지 못한다. 설치와 실행 경로도 통합 검사에 포함되는 이유다.

실행 결과

먼저 패키지 루트에서 보조 예제를 실행한다. 아래 출력은 코드가 정의한 예상 결과다. 이 원고에서는 도구를 실행하지 않았으며, ROS 환경의 실행 결과를 실측한 것으로 제시하지 않는다.

python3 duri_checks/logic.py
battery=0: allowed=False
battery=29: allowed=False
battery=30: allowed=True
battery=100: allowed=True
battery=-1: rejected
battery=101: rejected
pure Python checks: 6 passed

다음 명령은 파일을 가져와 실행하지 않고 Python 소스를 컴파일한다. ROS가 없어도 수행할 수 있으며 경고를 오류로 취급한다. 문법 검사 성공은 ROS API의 존재나 메시지 전달 성공을 뜻하지 않는다. 파일로 바이트 코드를 저장하지 않으므로 읽기 전용 디렉터리에서도 사용할 수 있다.

python3 -W error - <<'PY'
from pathlib import Path

paths = [Path("setup.py")]
paths += sorted(Path("duri_checks").glob("*.py"))
paths += sorted(Path("test").glob("*.py"))
for path in paths:
    compile(path.read_text(encoding="utf-8"), str(path), "exec")
print(f"syntax checks: {len(paths)} files passed")
PY
syntax checks: 7 files passed

pytest가 설치되어 있다면 패키지 루트에서 순수 함수 테스트만 실행할 수 있다. 아래 명령은 pytest의 시간과 진행 표시를 로그에 저장하고, 성공한 경우에만 고정된 문장을 출력한다. 실패했을 때는 로그를 확인한다.

python3 -m pytest -q test/test_logic.py > logic-test.log 2>&1 && printf 'logic tests: passed\n'
logic tests: passed

ROS 검사는 Jazzy 환경을 먼저 읽고, 패키지를 포함한 작업 공간 루트에서 수행한다. 다음은 Linux의 bash 예다. macOS에서 소스 설치한 환경이라면 첫 줄을 해당 설치 경로의 환경 설정 파일로 바꾼다. zsh에서는 대응하는 setup.zsh를 사용한다. 의존성 설치는 각 환경에서 먼저 끝내 두어야 한다.

source /opt/ros/jazzy/setup.bash
colcon build --packages-select duri_checks > build.log 2>&1 && printf 'build: passed\n'
build: passed

빌드 성공을 확인한 뒤 같은 작업 공간에서 검사한다. 예제 패키지만 있는 새 작업 공간을 기준으로 한다. 성공 시 터미널 출력은 다음 한 줄이다. 개별 검사 기록은 test.log와 result.log에 남는다.

source install/setup.bash
colcon test --packages-select duri_checks > test.log 2>&1 && colcon test-result --verbose > result.log 2>&1 && printf 'ROS test results: passed\n'
ROS test results: passed

함수 검사 6개와 콜백 검사 3개, 실행 중 검사 1개, 종료 검사 1개를 정의했다. 실행 중 검사 하나가 두 입력을 순서대로 확인한다. 테스트 개수보다 어떤 계약을 확인했는지가 중요하다. 이 검사들은 배터리 정책과 해당 토픽 경로를 대상으로 하며 실제 배달 주행의 성공률을 측정하지 않는다.

실무에서 자주 틀리는 것

기다리기만 하고 콜백을 실행하지 않는다

다음 코드는 테스트를 수행하는 스레드를 멈춘다. 별도 실행기가 없다면 received를 채울 콜백도 실행되지 않는다.

publisher.publish(Int32(data=45))
time.sleep(2.0)
assert True in received

입력을 보내면서 실행기를 진행하고, 기대한 응답 또는 마감 시각을 기준으로 끝낸다.

deadline = time.monotonic() + 8.0
while True not in received and time.monotonic() < deadline:
    publisher.publish(Int32(data=45))
    executor.spin_once(timeout_sec=0.1)
assert True in received

실제 값을 확인하지 않고 호출 성공만 검사한다

예외가 없다는 사실만으로 발행 메시지가 올바르다고 판단할 수 없다. 다음 검사는 항상 참인 값을 발행하는 구현도 통과시킨다.

with patch.object(gate, "_publisher") as publisher:
    gate._on_battery(Int32(data=29))
    publisher.publish.assert_called_once()

전달한 메시지의 형식과 정책 결과를 함께 검사한다.

with patch.object(gate, "_publisher") as publisher:
    gate._on_battery(Int32(data=29))
    publisher.publish.assert_called_once()
    message = publisher.publish.call_args.args[0]
    assert isinstance(message, Bool)
    assert message.data is False

검사 실패 경로에서 정리를 건너뛴다

assert가 실패하면 다음 줄로 진행하지 않는다. 자원 정리를 마지막 줄에만 두면 이후 검사에 초기화 상태나 통신 자원이 남을 수 있다.

node = BatteryGate(context=context)
assert node.get_name() == "battery_gate"
node.destroy_node()
context.try_shutdown()

정리 대상의 존재를 확인할 수 있게 초기화하고 finally를 사용한다. fixture에서도 같은 원칙을 적용한다.

node = None
try:
    node = BatteryGate(context=context)
    assert node.get_name() == "battery_gate"
finally:
    if node is not None:
        node.destroy_node()
    context.try_shutdown()

테스트 실행 명령만 CI의 합격 기준으로 삼는다

다음 구성은 기록된 검사 결과를 별도로 판정하는 단계가 빠져 있다.

- name: Test
  run: colcon test --packages-select duri_checks

실행 결과를 읽는 단계를 추가한다. always 조건은 앞 단계가 실패한 경우에도 진단 결과를 확인하도록 한다. 빌드 자체가 실패했다면 앞 단계의 실패 상태도 그대로 남는다.

- name: Check test results
  if: always()
  run: colcon test-result --verbose

한눈에 보기

두리의 테스트를 구성할 때 확인할 기준
관심사이 장의 방법합격 기준
배터리 정책pytest 매개변수화29는 거짓, 30은 참
잘못된 입력예외와 콜백 검사함수는 예외, 노드는 거짓
메시지 작성발행자 호출 기록Bool 메시지 한 개와 올바른 값
실제 통신launch_testing과 검사 노드제한 시간 안에 기대한 응답
자원 정리finally와 종료 검사정리 실행과 종료 코드 0
자동 검증빌드, 테스트, 결과 확인단계 성공과 실패 결과 없음

API의 세부 계약을 확인할 때는 Jazzy launch_testing 문서, pytest 매개변수화 문서, colcon 결과 확인 문서를 참고할 수 있다. 문서의 예제를 복사하기보다 자신의 입력과 출력 계약에 맞춰 검사를 작성한다.

여기서 사용한 8초는 기능 검사가 끝없이 기다리지 않게 하는 제한이다. 센서 입력부터 제어 출력까지의 지연 목표를 입증하는 수치는 아니다. 다음 장에서는 기능의 참과 거짓을 넘어 지연을 측정하고 통신 보안을 다룬다.

연습 문제

  1. 출발 기준을 35%로 바꾸었다. 정책 상수와 경계값 테스트를 수정하라. 통합 테스트의 45와 15도 반드시 바꾸어야 하는지 설명하라.
  2. 101이라는 입력이 노드에서 거짓으로 발행되는지 확인하는 콜백 검사 사례를 추가하라. 이 검사가 판단 함수의 예외 검사와 다른 이유를 설명하라.
  3. 출력 토픽 이름을 잘못 수정했을 때 함수 테스트와 콜백 테스트는 통과하지만 통합 테스트는 실패하는 이유를 설명하라. 실패 메시지에서 먼저 확인할 정보 두 가지를 고르라.
  4. 통합 테스트에서 45 다음에 60을 보내고 참을 확인하도록 바꾸었다. received.clear만으로 두 번째 입력의 처리가 입증되는지 판단하고, 개선 방법을 설명하라.

정답과 해설

  1. MIN_BATTERY를 35로 바꾸고 유효 입력의 검사 사례를 다음과 같이 수정한다. 콜백 테스트에서도 기존의 30에 대한 참 기대값을 바꿔야 한다. 통합 테스트의 45는 여전히 허용 범위이고 15는 거부 범위이므로 유지할 수 있다.

    [(0, False), (34, False), (35, True), (100, True)]

    보조 예제도 같은 경계값을 사용하도록 수정해야 실행 결과가 새 정책과 일치한다. 정책 변경 시 관련 검사를 함께 검토하되, 테스트의 기대값을 정책 함수로 계산해서는 안 된다. 그러면 같은 오류를 공유하여 잘못된 구현이 통과할 수 있다.

  2. test_node.py의 매개변수 목록에 다음 사례를 추가한다.

    (101, False)

    함수 검사는 범위 밖 값에서 ValueError가 발생하는 계약을 확인한다. 콜백 검사는 그 예외가 콜백 밖으로 새지 않고 Bool의 거짓으로 변환되어 한 번 발행되는 계약을 확인한다. 두 검사는 서로 다른 책임을 검증한다.

  3. 함수 테스트에는 토픽이 없고 콜백 테스트에서는 발행자 참조를 대체하므로 실제 전달 경로를 사용하지 않는다. 통합 테스트의 검사 노드는 정해진 출력 토픽을 구독하므로 이름이 달라지면 응답을 받지 못한다. 먼저 실패 메시지의 입력값과 received 목록을 확인한다. 목록이 비었다면 노드 실행 로그와 실제 토픽 이름을 조사하고, 값이 들어 있다면 정책이나 응답 순서를 조사한다. 빈 목록만으로 원인이 토픽 이름이라고 확정하지는 않는다.

  4. 입증되지 않는다. 45에 대한 참 응답이 통신 계층이나 실행기에서 대기하다가 목록을 지운 뒤 도착할 수 있다. 60도 참을 기대하므로 이전 응답으로 검사가 통과한다. 요청과 응답에 같은 식별자를 포함하고 두 번째 식별자의 응답을 확인하는 방식으로 개선할 수 있다. 인터페이스를 유지한다면 이 예제처럼 참과 거짓의 전환을 검사하여 이전 응답과 구별할 수 있지만, 임의의 입력별 처리까지 증명하는 방법은 아니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.