제14부 시스템 설계·품질·운영
14.6 테스트 II — launch_testing·통합·HIL
14.6.1 이 장에서 배우는 것 / 선수 지식
14.5이 부분을 시험했다면, 이 장은 조립된 시스템을 시험한다. 단위 시험이 모두 통과해도 시스템이 동작하지 않는 경우가 많은데, 그 원인이 대개 경계에 있기 때문이다 — 토픽 이름 불일치, QoS 미스매치(1.5.4), 파라미터 미적용(1.9.7), 시작 순서(14.2.3), tf 누락(5.2.5).
이런 것들은 여러 노드를 실제로 실행해야만 드러난다. 그것이 통합 시험이 필요한 이유다.
배우는 것:
launch_testing— launch를 시험 하네스로.- 통합 시험 설계 — 무엇을 확인하는가.
- 시뮬 기반 시험 — 동작 수준.
- bag 기반 회귀 — 실제 데이터로.
- HIL — 하드웨어를 넣은 시험.
- 현장 승인 시험 — 배포 전 확인.
- 시험 전략 — 무엇을 어디서.
선수 지식: 14.5(단위·모의), 14.3·14.4(launch), 10.1~10.6(시뮬), 11.3.8(회귀 데이터셋), 4.10(CI), 6.2.8(mock 하드웨어).
정직:
launch_testing의 API·데코레이터 이름은 배포판 의존이다(확인 필요)(→ 167차 확인: Jazzy에서 이 장이 쓴 API(generate_test_description·ReadyToTest·post_shutdown_test·assertExitCodes·assertWaitFor·get_node_names)를 전부 실제로 실행해 확인했다 — 14.6.2·14.6.3 실행 확인(167차) 참고). HIL 구성은 하드웨어와 조직에 따라 크게 다르므로 개념과 판단만 다룬다. 안전 관련 승인 시험은 안전 표준·전문가의 영역이며(14.10), 이 책의 서술을 근거로 삼아서는 안 된다. 이 환경에서 검증하지 않았다(목차 3).
14.6.2 launch_testing
launch로 시스템을 기동하고, 그 위에서 시험을 실행한다.
# test/test_perception_pipeline.py (개념 — 확인 필요: API)(→ 167차 확인: 아래 API 전부 Jazzy에서 실재·동작 확인)
import unittest
import launch, launch_ros, launch_testing
import rclpy
from sensor_msgs.msg import PointCloud2
def generate_test_description():
driver = launch_ros.actions.Node(
package='my_pkg', executable='fake_lidar', name='fake_lidar')
filter_node = launch_ros.actions.Node(
package='my_pkg', executable='cloud_filter', name='cloud_filter',
parameters=[{'leaf_size': 0.05}])
return (
launch.LaunchDescription([
driver, filter_node,
launch_testing.actions.ReadyToTest(), # ★ 여기부터 시험 시작
]),
{'driver': driver, 'filter': filter_node}, # 시험에서 참조할 프로세스
)
class TestPipeline(unittest.TestCase):
@classmethod
def setUpClass(cls):
rclpy.init()
cls.node = rclpy.create_node('test_node')
@classmethod
def tearDownClass(cls):
cls.node.destroy_node()
rclpy.shutdown()
def test_filtered_output_published(self):
received = []
sub = self.node.create_subscription(
PointCloud2, '/cloud_filtered', lambda m: received.append(m), 10)
end = time.time() + 10.0
while time.time() < end and not received:
rclpy.spin_once(self.node, timeout_sec=0.1)
self.assertTrue(received, '필터 출력이 발행되지 않음')
@launch_testing.post_shutdown_test()
class TestExitCodes(unittest.TestCase):
def test_clean_exit(self, proc_info):
launch_testing.asserts.assertExitCodes(proc_info) # 비정상 종료 없음
# CMakeLists.txt
if(BUILD_TESTING)
find_package(launch_testing_ament_cmake REQUIRED)
add_launch_test(test/test_perception_pipeline.py)
endif()
| 기능 | 용도 |
|---|---|
ReadyToTest | 기동 완료 시점 표시 |
| 프로세스 참조 | 출력·종료 코드 확인 |
post_shutdown_test | 종료 후 검증 |
assertExitCodes | 비정상 종료 탐지 |
| 출력 매칭 | 로그에 특정 문구(확인 필요)(→ 167차 확인: proc_output.assertWaitFor(text, ...)로 실재·동작 — 단 기본값이 stdout이 아니라 stderr인 함정 발견, 아래 실행 확인(167차) 참고) |
견해:
post_shutdown_test의 종료 코드 확인이 의외로 큰 가치를 준다. 시험이 통과해도 노드가 종료 시 크래시하는 경우가 있는데, 이것을 자동으로 검출한다. "조용한 크래시" 를 발견하는 저렴한 수단이다.
함정:
ReadyToTest시점이 "노드가 준비됨"을 뜻하지 않는다. 프로세스가 기동된 것일 뿐이므로, 시험 안에서 준비 상태를 기다려야 한다(14.2.4). 이것을 빠뜨리면 불안정한 시험이 된다.(→ 168차 확인: 실제로 재현했다 — 아래 실행 확인(167차) 블록의 168차 추가분 참고)
견해:
launch_testing은 "구성이 맞는가"를 시험하는 데 가장 적합하다. 토픽 연결·QoS·파라미터·기동 순서처럼 launch 구성에서 오는 문제를 검출한다. 알고리즘 품질은 단위 시험(14.5)이나 시뮬 시험이 맡는다.
실행 확인(167차, Jazzy) —
post_shutdown_test+assertExitCodes의 "조용한 크래시" 탐지를 양성·음성 둘 다 실측했다. 장기 실행(무한루프) 노드 둘을 만들어 SIGINT를 보냈다 — ①well_behaved_node(SIGINT를 처리해 정상적으로sys.exit(0))는 능동 시험 통과 → SIGINT 종료 →assertExitCodes통과. ②buggy_shutdown_node(SIGINT 핸들러 자체가RuntimeError를 던짐)는 능동 시험도 통과했지만(정상 동작 중이었으므로) 종료 시점에만 크래시(exit code 1)했고,post_shutdown_test가 이를 정확히 검출했다("Proc target_node-1 exited with code 1") — 위 견해가 그대로 확인됐다. 부수 발견 둘 — (a)assertExitCodes의 기본allowable_exit_codes는 소스(assert_exit_codes.py)로 직접 확인한 결과[0]뿐이다(비-Windows) — SIGINT로 종료되는 노드를 기본 인자로 통과시키려면 노드 스스로 SIGINT를 처리해exit(0)으로 끝나야 한다. (b) 시험 대상 프로세스가 능동 시험 구간 중에 스스로 끝나면(성공이든 실패든)launch_testing은"Processes under test stopped before tests completed"로 전체 실행을 중단시킨다 —post_shutdown_test는 "능동 시험이 끝날 때까지 계속 실행 중이던 프로세스"를 대상으로 설계된 것이지, 시험 중간에 스스로 끝나는 짧은 프로세스에는 다른 방식이 필요하다.
assertWaitFor(출력 매칭) — 실재하지만 기본값이 stderr라는 실제 함정을 발견했다. 노드가print(...)(stdout)로 특정 마커 문자열을 출력했는데도proc_output.assertWaitFor('마커', process=target, timeout=10)가 10초 타임아웃으로 실패했다.io_handler.py소스를 읽어 원인을 확정했다 —waitFor()의 기본 인자가stream='stderr'다(stdout이 아님).stream='stdout'을 명시하자 즉시 통과했다(마커가 실제로 출력된 시점(t≈3초)과 정확히 일치). 이는 rclpy의get_logger()가 기본적으로 stderr에 쓰는 관례와는 맞지만, 일반print()나 다른 언어의 stdout 로깅과는 조용히 어긋나는 문서화되지 않은 함정이다 — 이 장의 새 함정 카탈로그 후보다.컨테이너
gap167j(ros:jazzy-ros-base+ros-jazzy-launch-testing+ros-jazzy-launch-testing-ros). 원본:tools/gap_harness/gap167/rj118_test/(test_exit_codes2.py·well_behaved_node.py·buggy_shutdown_node.py·test_output_match.py·assert_exit_codes_source.log·io_handler_source.log).168차 추가 —
ReadyToTest타이밍 함정을 양성·음성 둘 다 실제로 재현했다. 프로세스는 즉시 기동되지만 실제 "준비"(퍼블리셔 생성+발행 시작)까지 3초 걸리는slow_starter.py를 만들고,ReadyToTest직후 곧바로WAIT_SECONDS만큼만 기다리는 시험을 작성했다.WAIT_SECONDS=1.0(책이 경고하는, 준비 상태를 안 기다리는 패턴)은 정확히 실패했다(AssertionError: [] is not true : 1.0초 안에 메시지를 받지 못함, 1.353초 만에 FAIL).WAIT_SECONDS=5.0(3초 지연을 넘겨 충분히 기다림)은 정확히 통과했다(로그에 "slow_starter NOW actually ready, starting to publish"가 시험 성공 전에 출력됨). 위 함정 문구가 양성·음성 둘 다로 결정적으로 확인됐다. 원본:tools/gap_harness/gap168/readytotest_test/(test_readiness.py·slow_starter.py·run_naive_1s.log·run_proper_5s.log).
14.6.3 통합 시험 설계
무엇을 확인할 것인가.
| 확인 | 예 |
|---|---|
| ① 기동 | 기대 노드 목록 전부 존재 |
| ② 연결 | 각 토픽이 기대 주기로 발행 |
| ③ 규약 | tf 트리 완결·프레임 이름·단위(7.1.9) |
| ④ 기능 | 장애물 입력 → 회피 명령 출력 |
| ⑤ 이상 | 센서 끊김 → 안전 정지(14.2.6) |
| ⑥ 종료 | 종료 코드·자원 해제 |
def test_expected_nodes_running(self):
time.sleep(2.0) # 또는 조건 대기
names = self.node.get_node_names()
for expected in ['cloud_filter', 'costmap_node', 'controller']:
self.assertIn(expected, names, f'{expected} 노드 없음')
def test_tf_tree_complete(self):
buf = tf2_ros.Buffer(); tf2_ros.TransformListener(buf, self.node)
# base_link → map 이 조회되는가 (5.2.5)
ok = wait_for_transform(buf, 'map', 'base_link', timeout=10.0)
self.assertTrue(ok, 'map → base_link 변환 없음')
견해: ③(규약 검증)을 자동화하는 것이 특히 가치 있다. 7.1.9에서 직접 확인하라고 한 항목들 — 프레임 이름, 단위, tf 완결성, 스탬프 유효성 — 을 시험으로 만들면 회귀가 자동으로 검출된다. 사람이 매번 확인하는 것은 지속되지 않는다.
실행 확인(167차, Jazzy) —
test_expected_nodes_running패턴과 tf 트리 완결성 패턴을 실측했다.demo_nodes_cpp의 실제talker·listener노드 둘을launch_testing으로 실행하고self.node.get_node_names()로 둘 다 발견됨을 확인했다(2 tests, OK).test_tf_tree_complete패턴 자체는 5.7.8에서 훨씬 자세히(양성·음성 둘 다) 실행 확인했다 — 이 절의 두 함수 모두 위 함정 카탈로그·견해와 정확히 일치하는 실측 결과를 냈다. 원본:tools/gap_harness/gap167/rj118_test/test_node_names.py·run_node_names.log; 5.7.8 실행 확인(167차) 참고.
견해: ⑤(이상 대응)가 가장 소홀히 되면서 가장 중요하다. 정상 경로만 시험하면 고장 시 거동은 현장에서 처음 실행된다. 모의 노드를 강제 종료하고 시스템이 안전하게 반응하는지 확인하는 시험을 넣으라(14.5.7의 연장).
함정: 통합 시험이 느리면 아무도 안 실행한다. 하나에 5분 걸리는 시험이 스무 개면 CI가 무의미해진다. 핵심 소수를 PR 검증에, 전체를 야간에 두는 분업이 실용적이다(4.10.2).
14.6.4 시뮬 기반 시험
동작 수준을 검증한다(10.1.7의 회귀 시험).
| 시험 | 확인 |
|---|---|
| 목표 도달 | 성공률·시간(8.8.5) |
| 충돌 없음 | 시뮬 충돌 이벤트 |
| 경로 품질 | 길이·부드러움 |
| 복구 동작 | 막혔을 때 |
| 조작 성공 | 파지·배치(9.5.8) |
| 성능 | 종단 지연(11.7) |
# 개념: 헤드리스 시뮬에서 시나리오 실행 후 판정
def test_reaches_goal_in_corridor(self):
send_goal(self.node, x=5.0, y=0.0)
result = wait_for_result(self.node, timeout=60.0)
self.assertEqual(result.status, SUCCEEDED)
self.assertLess(elapsed_time(), 40.0) # 여유 있는 기준
self.assertEqual(collision_count(), 0)
견해: 판정 기준을 여유 있게 설정하라(4.10.6·11.7.9의 반복). 시뮬은 CI 머신 부하에 따라 RTF가 변하므로(10.2.8), 빠듯한 시간 기준은 오탐을 만든다. "명백한 회귀"를 검출하는 것이 목적이다.
견해: "충돌 0"과 "성공/실패" 같은 이산 판정이 안정적이다. 연속 지표(경로 길이·시간)는 변동이 크지만, 이산 결과는 재현성이 높다. 먼저 이산 판정으로 안정된 시험을 만들고, 연속 지표는 추세 관찰용으로 기록한다.
함정: 시뮬 시험 통과가 실기 동작을 보장하지 않는다(10.6.7). 이 시험의 역할은 "회귀 감지" 이지 "동작 증명"이 아니다. 10.6.5의 신뢰도 표가 이 해석의 근거다.
14.6.5 bag 기반 회귀
실제 데이터로 인지·측위를 검증한다(11.3.8).
| 대상 | 지표 |
|---|---|
| 측위 | 궤적 오차·발산 여부 |
| SLAM | 지도 품질·폐루프 |
| 검출 | 정밀도·재현율 |
| 분류 | 정확도 |
| 처리 시간 | 프레임당 시간 |
견해: bag 기반 회귀가 인지 개발의 핵심 인프라다(10.6.6). 알고리즘을 고칠 때마다 전체 데이터셋에 실행해 지표 변화를 보면, 개선인지 국소 최적화인지 구분된다. 이것 없이 인지를 개선하는 것은 감에 의존하는 일이다.
견해: 기준값을 "고정 숫자"가 아니라 "이전 실행 대비"로 두는 것도 방법이다. 절대 기준은 데이터셋이 바뀌면 무의미해지지만, 직전 대비 유의미한 악화는 언제나 신호다. 다만 서서히 악화되는 것을 놓칠 수 있으므로, 절대 하한도 함께 두는 것이 좋다.
함정: bag이 크면 시험이 느리다(11.3.8). 대표 소수를 PR에, 전체를 야간에 두고, 데이터셋 자체를 관리(추가·정리)하는 절차를 두라.
14.6.6 HIL — 하드웨어 인 더 루프
실제 하드웨어의 일부를 시험 루프에 넣는다(10.1.4의 ⑤ 충실도).
| 구성 | 시험 대상 |
|---|---|
| SW + 실제 컨트롤러 + 시뮬 물리 | 통신·타이밍·펌웨어 |
| SW + 시뮬 + 실제 센서 | 센서 인터페이스 |
| 부하 없는 실제 구동계 | 명령·응답·안전 |
| 완전 실기 | 최종 |
| 얻는 것 | 설명 |
|---|---|
| 실제 통신 타이밍 | 시뮬이 근사 못 하는 것(10.6.2의 ③⑤) |
| 펌웨어 검증 | 실제 코드 |
| 인터페이스 규약 | 실제 프로토콜 |
| 고장 주입 | 케이블 분리 등 |
견해: HIL이 메우는 간극은 "실행 환경"이다(10.6.2의 ⑤). 시뮬에서 절대 나타나지 않는 통신 지연·지터·임베디드 성능 문제가 여기서 드러난다. 완전 실기 시험보다 안전하고 반복 가능하다는 점이 가치다.
견해: HIL 구축은 비용이 크므로 목적을 좁히라. "모든 것을 HIL로"가 아니라, 시뮬로 검증 불가하고 실기 시험이 위험·비용이 큰 것에 집중한다 — 통신 프로토콜, 타이밍, 고장 주입.
함정: HIL 장비 자체가 유지보수 대상이 된다. 배선·전원·고정구가 손상되면 시험이 실패하는데 그것이 소프트웨어 문제로 보인다. HIL 자체의 자가 점검을 두는 것이 좋다.
14.6.7 현장 승인 시험
배포 전 실기에서 확인하는 것.
| 항목 | 확인 |
|---|---|
| 버전 확인 | 무엇이 설치됐나(11.1.9) |
| 기동·준비 | 정상 부팅(14.2) |
| 규약 점검 | tf·프레임·센서(7.1.9) |
| 정지 상태 검증 | 움직이기 전(10.6.8의 3단계) |
| 저속 동작 | 기본 기능 |
| 안전 기능 | 비상정지·감시 동작 |
| 대표 시나리오 | 실제 작업 |
| 반복 시험 | 성공률(9.6.9) |
| 성능 | 지연·주기(11.7) |
| 종료·재시작 | 안정성 |
견해: 승인 시험을 체크리스트로 만들고 결과를 기록하라. 매번 같은 항목을 같은 방법으로 확인해야 비교가 가능하고, 문제가 생겼을 때 "그때는 통과했는가"를 답할 수 있다. 기록이 없으면 승인이 아니라 인상이다.
견해: 10.6.8의 단계적 신뢰 구축이 곧 승인 시험의 순서다. mock → 시뮬 → 실기 정지 → 저속 → 정상속도 → 반복 → 예외. 이 순서를 지키면 문제를 안전한 단계에서 만난다.
정직: 안전 기능의 승인은 이 책의 범위를 넘는다. 비상정지·안전 감시의 검증 방법과 판정 기준은 적용되는 안전 표준과 전문가의 영역이며(14.10), 여기 나열한 항목은 그것을 대체하지 않는다.
14.6.8 시험 전략
무엇을 어디서 시험할 것인가.
| 확인 대상 | 시험 층 |
|---|---|
| 알고리즘 로직 | 단위(14.5) |
| 수치·경계 | 단위 |
| 토픽 연결·QoS | launch_testing |
| 파라미터 적용 | launch_testing |
| tf 규약 | launch_testing |
| 인지 품질 | bag 회귀 |
| 항법 동작 | 시뮬 |
| 고장 대응 | 모의 그래프·시뮬 |
| 타이밍·통신 | HIL |
| 펌웨어 | HIL |
| 최종 동작 | 실기 승인 |
견해: "빠른 것을 자주, 느린 것을 가끔"이 실용적 배치다. PR마다 실행되는 시험은 몇 분 안에 끝나야 하고, 오래 걸리는 것은 야간·릴리스 시점에 실행한다. 이 배치가 CI의 지속 가능성을 결정한다(4.10.2).
견해: 시험이 없는 영역을 명시적으로 인정하라. 모든 것을 시험할 수는 없으므로, "이 부분은 시험하지 않으며 실기 승인에 의존한다" 를 문서로 남기면 위험을 인지하고 관리할 수 있다. 암묵적 공백이 더 위험하다.
견해: 시험 실패를 방치하지 말라(14.5.8의 반복). 빨간 CI가 일상이 되면 시험 체계 전체가 무의미해진다. 고치거나 명시적으로 비활성화하고, 비활성화한 것은 목록으로 관리한다.
14.6.9 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | ReadyToTest를 준비 완료로 오해 | 불안정 | 조건 대기 |
| 2 | 종료 코드 미확인 | 조용한 크래시 | post_shutdown_test |
| 3 | 통합 시험이 느림 | CI 무력화 | 분업 배치 |
| 4 | 정상 경로만 시험 | 고장 시 미검증 | 이상 대응 시험 |
| 5 | 규약 검증 수동 | 지속 안 됨 | 자동화 |
| 6 | 빠듯한 시간 기준 | 오탐 | 여유 있게 |
| 7 | 연속 지표로 판정 | 불안정 | 이산 판정 우선 |
| 8 | 시뮬 통과를 동작 증명으로 | 과신 | 회귀 감지 목적 |
| 9 | bag 시험이 느림 | 실행 안 됨 | 소수/전체 분업 |
| 10 | 절대 기준만·상대만 | 놓침 | 둘 다 |
| 11 | HIL 범위 과다 | 비용 폭증 | 목적 좁히기 |
| 12 | HIL 장비 문제를 SW 문제로 | 오진 | 자가 점검 |
| 13 | 승인 시험 미기록 | 비교 불가 | 체크리스트·기록 |
| 14 | 안전 승인을 자체 판단 | 위험 | 표준·전문가 |
| 15 | 시험 공백 미인지 | 위험 누락 | 명시적 문서화 |
| 16 | 실패 방치 | 체계 붕괴 | 즉시 대응 |
| 17 | assertWaitFor가 stdout 출력을 못 찾음(167차 실측 발견) | 타임아웃 실패 | 기본값이 stream='stderr' — print() 등 stdout 출력은 stream='stdout' 명시 |
14.6.10 체크포인트
launch_testing의 다섯 기능과,post_shutdown_test의 가치는?ReadyToTest의 의미와 흔한 오해는?launch_testing이 가장 적합한 시험 대상은?- 통합 시험의 여섯 확인 항목을 말할 수 있는가.
- 규약 검증 자동화가 특히 가치 있는 이유는?
- 이상 대응 시험이 소홀해지는 이유와 그 위험은?
- 시뮬 기반 시험에서 판정 기준을 여유 있게 설정하는 근거는?
- 이산 판정과 연속 지표의 차이, 그리고 권장 순서는?
- 시뮬 시험의 역할을 "회귀 감지"로 한정하는 근거는?
- bag 기반 회귀가 인지 개발의 핵심 인프라인 이유는?
- 기준값을 절대와 상대 둘 다 두라는 조언의 근거는?
- HIL의 네 구성과, 메우는 간극은 무엇인가.
- HIL 범위를 좁히라는 조언과, HIL 자체의 함정은?
- 현장 승인 시험 열 항목과, 기록의 필요성은?
- 승인 시험 순서가 10.6.8과 같은 이유는?
- 시험 층별 배치와, "빠른 것을 자주"의 근거는?
- 시험 공백을 명시적으로 인정하라는 조언의 이유는?
다음 장 14.7는 시험 너머의 품질 — REP 2004 품질 수준과 의존성 위생을 다룬다.