ROS2 測試框架

2026-07-18
  • ros2
  • testing
  • launch-testing
  • pytest
  • ci

問題定義

前面章節寫的所有範例,驗證方式都是「手動啟動、觀察終端機輸出」,這在開發階段沒問題,但沒辦法規模化——每次改完程式碼都要手動重跑一遍所有場景,既耗時又容易漏掉某個沒注意到的邊界情況。這一節介紹怎麼幫 ROS2 系統寫自動化測試,讓修改程式碼後能快速、可重複地驗證行為是否符合預期,也是接入持續整合(CI)流程的基礎。

核心概念說明

兩種測試層級:純邏輯單元測試 vs 整合測試

延續 FAQ 提到的分層概念,ROS2 系統的測試大致分兩類:

  • 純邏輯單元測試:如果節點裡的核心運算邏輯(例如第 6.3 節代價地圖的某個計算函式、第 8.2 節點雲降採樣的演算法)能被抽成不依賴 ROS2 通訊機制的獨立函式,就能用一般的 pytest 直接測試,速度快、不需要啟動真正的節點行程。
  • 整合測試:驗證「節點之間的通訊行為是否符合預期」,例如「送出這個服務請求,應該收到這樣的回應」「發布這則訊息後,另一個節點應該做出對應反應」。這類測試需要真正啟動節點、透過真實的 ROS2 通訊機制互動,用的是 launch_testing 這個框架。

launch_testing:把測試斷言接進 launch 系統

launch_testing 建立在第 3.4 節介紹的 launch 檔案機制之上,讓你可以在一份 launch 描述裡,除了正常啟動要測試的節點之外,額外定義測試斷言邏輯,測試框架會負責啟動節點、等待就緒、執行測試函式、最後清理所有啟動的行程。這種方式測試的是節點「作為一個真正獨立行程運作」時的實際行為,跟直接在同一個 Python 程序裡 import 節點類別測試相比,更貼近真實部署時的情境。

實作範例:測試第 2.3 節的加法服務

延續第 2.3 節寫的 add_two_ints 服務,這裡示範怎麼寫一份整合測試,驗證服務確實能正確回應請求。

檔案位置:~/ros2_ws/src/my_package/test/test_add_two_ints.py

python
import unittest
import launch
import launch_ros.actions
import launch_testing.actions
import pytest
import rclpy
from rclpy.node import Node
from my_package_msgs.srv import AddTwoInts


@pytest.mark.launch_test
def generate_test_description():
    add_server = launch_ros.actions.Node(
        package="my_package",
        executable="add_server",
        name="add_server",
    )
    return launch.LaunchDescription([
        add_server,
        launch_testing.actions.ReadyToTest(),
    ])


class TestAddTwoInts(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        rclpy.init()

    @classmethod
    def tearDownClass(cls):
        rclpy.shutdown()

    def setUp(self):
        self.node = Node("test_client")
        self.client = self.node.create_client(AddTwoInts, "add_two_ints")

    def tearDown(self):
        self.node.destroy_node()

    def test_add_returns_correct_sum(self):
        self.assertTrue(
            self.client.wait_for_service(timeout_sec=10.0),
            "服務在 10 秒內沒有變成可用狀態",
        )

        request = AddTwoInts.Request()
        request.a = 4
        request.b = 6

        future = self.client.call_async(request)
        rclpy.spin_until_future_complete(self.node, future, timeout_sec=5.0)

        self.assertTrue(future.done(), "請求在 5 秒內沒有得到回應")
        self.assertEqual(future.result().sum, 10)

    def test_add_handles_negative_numbers(self):
        self.client.wait_for_service(timeout_sec=10.0)
        request = AddTwoInts.Request()
        request.a = -3
        request.b = 5

        future = self.client.call_async(request)
        rclpy.spin_until_future_complete(self.node, future, timeout_sec=5.0)

        self.assertEqual(future.result().sum, 2)

這份測試示範了幾個處理非同步時序的關鍵做法:

  • wait_for_service(timeout_sec=10.0):不假設服務「應該已經」啟動完成,而是明確等待、附上逾時上限,並且用 assertTrue 明確斷言等待結果,讓「服務根本沒啟動」跟「服務啟動了但回應錯誤」這兩種失敗原因,在測試報告裡能被清楚區分。
  • spin_until_future_complete(..., timeout_sec=5.0):呼應第 2.3 節提到的 Future 用法,同樣附上逾時,避免測試在某個環節卡住時無限期掛起,而是在合理時間後明確回報「沒有得到回應」這個失敗原因。

執行測試

bash
colcon test --packages-select my_package
colcon test-result --verbose

預期輸出

text
test_add_two_ints.py::TestAddTwoInts::test_add_returns_correct_sum PASSED
test_add_two_ints.py::TestAddTwoInts::test_add_handles_negative_numbers PASSED

2 passed in 3.42s

常見錯誤與除錯技巧

錯誤一:測試在本機執行都正常,但在 CI 環境裡偶爾失敗

text
AssertionError: 請求在 5 秒內沒有得到回應

原因:CI 環境的運算資源通常比開發機更緊張、負載更不穩定,節點初始化與服務回應的時間可能明顯變長,如果測試裡的逾時設定是照著本機開發時「感覺夠用」的數值訂的,換到資源受限的 CI 環境裡就可能不夠。

排除方式:CI 環境的逾時設定通常需要比本機開發測試更寬鬆一些,並且確保失敗訊息本身有意義(如範例程式碼的 assertTrue 附帶說明文字),讓測試失敗時能一眼看出是「真的邏輯錯誤」還是「單純環境比較慢,逾時設定不夠寬鬆」,這兩種原因的排查方向完全不同。

錯誤二:generate_test_description 裡的節點啟動了,但測試斷言在節點真正就緒前就開始執行

現象:測試間歇性失敗,錯誤訊息顯示服務找不到,但如果單獨重跑一次同一份測試又會通過。

原因ReadyToTest() 這個 action 標記了「launch 系統認為可以開始測試了」的時間點,但這通常只代表行程已經啟動,不保證節點內部所有初始化(例如服務伺服器真正註冊完成)都已經完成——這也是為什麼測試程式碼本身還需要額外的 wait_for_service 這類主動等待機制,不能只依賴 ReadyToTest() 就假設一切已經就緒。

排除方式:確保每個實際互動(服務呼叫、等待特定訊息)前面都有對應的主動等待邏輯(如範例程式碼所示),不要假設「launch 系統標記為 ready」就等於「節點內部所有子系統都已經初始化完成」,這是兩個不同層級的「就緒」概念。

小結

ROS2 測試分成不依賴通訊機制的純邏輯單元測試,跟需要真正啟動節點互動的 launch_testing 整合測試,後者的關鍵是妥善處理非同步時序——每個等待都要有明確的逾時與有意義的失敗訊息,而不是假設固定的等待時間一定足夠。有了測試作為安全網,下一節要處理系統設計裡另一個常見的複雜度來源:當同一個系統裡需要運作不只一台機器人時,怎麼避免它們互相干擾。

延伸閱讀

常見問題

faq_01.log
測試 ROS2 節點一定要真的啟動 rclpy.init() 嗎,不能單純測試裡面的邏輯函式嗎?
如果你的節點邏輯有妥善拆分,把純運算邏輯(不依賴 ROS2 通訊的部分)抽成獨立函式或類別方法,這部分確實可以脫離 rclpy 直接用一般 pytest 測試,效率更高、也更容易寫。但只要牽涉到實際的訂閱、發布、服務呼叫這些通訊行為,就必須要有一個真正初始化的 rclpy 環境,這是本節主要示範的部分——兩種測試各有適用場景,實務上通常會同時存在。
faq_02.log
launch_testing 執行一次要花多久?會不會拖慢 CI?
比起純單元測試會慢不少,因為每個測試都要真的啟動節點行程、等待它們初始化完成、透過真實的 DDS 通訊互動,通常是秒級而非毫秒級的耗時。實務上會把測試分層:大量的純邏輯單元測試放在 CI 裡頻繁執行、快速回饋;牽涉多節點通訊的整合測試數量精簡一些,只涵蓋關鍵的互動路徑,避免整體測試耗時膨脹到無法接受的程度。
faq_03.log
測試裡怎麼確保節點真的『來得及』處理完訊息,而不是測試斷言執行得太早?
這是 ROS2 測試裡最常見的陷阱——訊息傳遞是非同步的,呼叫 publish() 之後不代表訂閱端已經處理完成。正確做法是用帶有逾時機制的等待迴圈(例如反覆呼叫 rclpy.spin_once 搭配檢查條件是否達成,直到條件成立或逾時),而不是假設一個固定的 sleep 時間就一定足夠,這在系統負載不穩定的 CI 環境裡特別容易因為時序不穩定導致測試偶發性失敗。