RclexTesting

A Phoenix.LiveViewTest-style testing library for ROS 2 applications built with rclex.

Describe robot behaviour as executable scenario specifications — without manually creating nodes, publishers, subscribers, timers, clients, or assertion loops.

defmodule RobotTest do
use ExUnit.Case
use RclexTesting
scenario "robot starts cleaning" do
mock_service "/safety/check", SafetyCheck,
fn _req -> %SafetyCheck.Response{allowed: true} end
watch "/robot/state", RobotState
publish "/mission/start", %MissionStart{id: 42}
expect "/robot/state",
matches(%{mode: :cleaning, speed: gt(0)})
end
end

Features

AreaFunctions
Topicspublish/2, watch/2, expect/2,3, refute_message/2,3,4, expect_count/2,3, expect_sequence/2,3, messages/1, eventually/1,2
Servicesmock_service/3, call_service/3,4, expect_service_call/2,3, refute_service_call/2,3,4
Actionsmock_action/3, send_goal/3,4, expect_goal/2,3, refute_goal/2,3,4, expect_feedback/2,3, expect_result/2,3
Matchersmatches/1, gt/1, gte/1, lt/1, lte/1, between/2, approx/1,2, one_of/1, none_of/1, contains/1, empty/0, not_empty/0, any/1, all/1, is_true/0, is_false/0, is_nil/0, not_nil/0, negate/1, satisfies/1
Multi-stream eventsexpect_sequence/1, expect_before/2,3, expect_after/2,3, expect_together/1,2, expect_workflow/1,2
Event descriptorsmessage/1,2, service_call/1,2, goal/1,2, feedback/1,2, result/1,2
Graph assertionsexpect_node/1,2, refute_node/1,2, expect_topic/1, refute_topic/1, expect_publisher/1, expect_publisher_count/2, expect_publisher_node/2, expect_subscriber/1, expect_subscriber_count/2, expect_subscriber_node/2, expect_service/1, refute_service/1, expect_service_provider/2,3, expect_action/1, refute_action/1, expect_action_provider/2,3, expect_node_publishes/2,3, expect_node_subscribes/2,3, expect_node_services/2,3, expect_node_actions/2,3, eventually_node/1,2,3, eventually_topic/1,2, eventually_service/1,2, eventually_action/1,2, scenario_node_name/0
Simulated timeset_time/1, advance_time/1, sim_time/0

Installation

Add rclex_testing to your test dependencies in mix.exs:

def deps do
[
{:rclex, "~> 0.12"},
{:rclex_testing, path: "path/to/rclex_testing", only: :test}
]
end

Add the ROS 2 message/service/action types you need to config/config.exs:

import Config
config :rclex,
ros2_message_types: [
"std_msgs/msg/String",
"geometry_msgs/msg/Twist"
# add your types here
],
ros2_service_types: [
"std_srvs/srv/SetBool"
],
ros2_action_types: []

Then run:

mix rclex.gen.msgs
mix rclex.gen.srvs
mix rclex.gen.action

Usage

Add use RclexTesting to any ExUnit.Case module. Each scenario block automatically starts a unique ROS node, cleans up all publishers, subscriptions, services, and action servers when the test exits.

defmodule MyApp.RobotTest do
use ExUnit.Case, async: false
use RclexTesting
alias Rclex.Pkgs.StdMsgs
scenario "publishes and receives a message" do
watch "/chatter", StdMsgs.Msg.String
publish "/chatter", %StdMsgs.Msg.String{data: "hello"}
msg = expect("/chatter", &(&1.data == "hello"))
assert msg.data == "hello"
end
end

Topics

watch/2

Subscribe to a topic and buffer all arriving messages.

watch "/robot/state", RobotState

publish/2

Publish a message. The type is inferred from the struct. A publisher is started lazily and reused within the scenario.

publish "/cmd_vel", %Twist{linear: %Vector3{x: 1.0}}

expect/2,3

Assert that a message matching the predicate arrives. Returns the matched message. Cursor-based: consecutive expect calls on the same topic advance past already-matched messages.

expect "/robot/state", &(&1.mode == :running)
expect "/robot/state", RobotState, &(&1.mode == :running)
expect "/robot/state", &(&1.mode == :running), timeout: 10_000

expect_sequence/2,3

Assert a sequence of matchers in order on the same topic.

expect_sequence "/robot/state", [
matches(%{mode: :idle}),
matches(%{mode: :starting}),
matches(%{mode: :running})
]

refute_message/1,2,3,4

Assert that no (matching) message arrives within a timeout window.

refute_message "/fault_topic", timeout: 500
refute_message "/fault_topic", FaultMsg, &(&1.severity == :critical)

expect_count/2,3

Assert that exactly N messages arrive.

msgs = expect_count "/sensor/data", 5

messages/1

Return all buffered messages for a topic in arrival order.

all = messages("/robot/state")

eventually/1,2

Poll a zero-arity predicate until truthy or timeout.

eventually(fn -> length(messages("/data")) >= 10 end, timeout: 10_000)

Services

mock_service/3

Register a test service on the scenario node. Requests are recorded in the collector and forwarded to the handler function.

mock_service "/safety/check", SafetyCheck,
fn _req -> %SafetyCheck.Response{allowed: true} end

call_service/3,4

Call a service synchronously and return the response.

response = call_service("/safety/check", SafetyCheck, %SafetyCheck.Request{})

expect_service_call/2,3

Assert that the mocked service received a (matching) request.

expect_service_call "/safety/check", SafetyCheck
expect_service_call "/safety/check", SafetyCheck, fn req -> req.robot_id == "r1" end

refute_service_call/2,3,4

Assert that no (matching) request arrives.

refute_service_call "/delete_map", DeleteMap

Actions

mock_action/3

Register a test action server. Goals, feedback, and results are all recorded. The handler receives (goal, publish_feedback) and must return a result struct.

mock_action "/navigate", Navigate, fn goal, publish_feedback ->
publish_feedback.(%Navigate.Feedback{progress: 50})
publish_feedback.(%Navigate.Feedback{progress: 100})
%Navigate.Result{success: true}
end

send_goal/3,4

Send a goal to an action server and return the goal UUID.

uuid = send_goal("/navigate", Navigate, %Navigate.Goal{x: 10, y: 20})

expect_goal/2,3, refute_goal/2,3,4

Assert that a (matching) goal was or was not submitted.

goal = expect_goal "/navigate", Navigate, fn g -> g.x == 10 end
refute_goal "/navigate", Navigate

expect_feedback/2,3, expect_result/2,3

Assert that feedback or a result was produced.

expect_feedback "/navigate", Navigate, fn fb -> fb.progress == 100 end
expect_result "/navigate", Navigate, fn r -> r.success end

Matchers

Matchers return predicate functions and compose with all assertion DSL.

expect "/robot/state",
matches(%{
mode: :running,
speed: gt(0),
battery: between(20, 100),
pose: matches(%{x: approx(10.0, delta: 0.1)})
})
MatcherDescription
matches(%{key: matcher})Structural match; plain values use ==
gt(n), gte(n), lt(n), lte(n)Numeric comparisons
between(low, high)Inclusive range
approx(n), approx(n, delta: d)Floating-point equality
one_of(list), none_of(list)Membership
contains(item)Substring or list membership
empty(), not_empty()Collection emptiness
any(matcher), all(matcher)List element matchers
is_true(), is_false()Boolean exact match
is_nil(), not_nil()Nil checks
negate(matcher)Invert any matcher
satisfies(fn val -> ... end)Escape hatch for custom logic

Simulated Time

Test-Controlled Time (use_sim_time: true)

The test controls time deterministically. Use this for unit tests and CI pipelines.

scenario "timer fires after 1 second", use_sim_time: true do
advance_time(1_000) # advance 1000 ms
expect "/tick", MyPkg.Msg.Tick
end

The scenario:

  1. Starts the node with use_sim_time:=true so it subscribes to /clock
  2. Creates a :ros_time clock with manual override enabled
  3. Publishes an initial Clock message at t=0
  4. On each advance_time(ms), updates the override and publishes the new time

Use set_time(ms) to jump directly to a specific time:

scenario "multiple phases", use_sim_time: true do
# Phase 1: around t=0
expect "/phase", &(&1 == :startup)
# Jump to t=5s for phase 2
set_time(5_000)
expect "/phase", &(&1 == :running)
# Advance gradually during phase 3
advance_time(2_000)
expect "/phase", &(&1 == :complete)
end

External Time (use_sim_time: :external)

An external simulator (Gazebo, motion capture, etc.) publishes on /clock. Use this when testing against live simulators.

scenario "robot moves in Gazebo", use_sim_time: :external do
# Gazebo publishes on /clock; don't call advance_time
watch "/robot/pose", GeometryMsgs.Msg.Pose
expect "/robot/pose", &(&1.position.z < 10)
end

The scenario:

  1. Starts the node with use_sim_time:=true so it subscribes to /clock
  2. Creates a :ros_time clock (without override) — it syncs automatically from /clock
  3. Publishes nothing; the external source drives all time

Call sim_time() to read the current clock value (driven by external source).

Ensuring Your Application Uses Sim Time

Any ROS node—in your application under test or spawned in a scenario—must start with the ROS arg to recognize sim time:

Rclex.start_node("my_node", ros_args: ["--ros-args", "-p", "use_sim_time:=true"])

The rclex_testing scenario automatically passes this to the test node when use_sim_time: true or use_sim_time: :external is set. For other nodes you spawn or test against, ensure they also receive this argument or they will ignore /clock and use wall time.

Multi-Stream Event Assertions

Beyond single-stream expect, rclex_testing provides a set of cross-stream workflow assertions that verify ordering and causality across topics, services, and actions.

Event Descriptors

Build blocks for multi-stream assertions. Each descriptor references a stream and an optional predicate:

message("/robot/state") # any message
message("/robot/state", &(&1.mode == :running)) # with predicate
message("/robot/state", matches(%{mode: :running})) # with matcher
service_call("/safety/check")
service_call("/safety/check", fn req -> req.allowed end)
goal("/navigate")
goal("/navigate", fn g -> g.x > 0 end)
feedback("/navigate", fn fb -> fb.progress >= 50 end)
result("/navigate", fn r -> r.success end)

The stream (watch/2, mock_service/3, mock_action/3) must be set up before the assertion runs.


expect_sequence/1

Assert an ordered sequence of events across any mix of topics, services, and actions. Each event must arrive after the previous one (based on the global event log).

watch "/mission/start", MissionStart
watch "/robot/state", RobotState
mock_service "/safety/check", SafetyCheck, fn _ -> ... end
mock_action "/navigate", Navigate, fn _, _ -> ... end
expect_sequence [
message("/mission/start"),
service_call("/safety/check"),
goal("/navigate"),
feedback("/navigate", fn fb -> fb.progress >= 100 end),
result("/navigate", fn r -> r.success end),
message("/robot/state", matches(%{mode: :completed}))
]

Returns the list of matched payloads in order.

For single-topic ordered assertions, the shorter form in Topics DSL is still available:

expect_sequence "/robot/state", [
matches(%{mode: :starting}),
matches(%{mode: :running}),
matches(%{mode: :completed})
]

expect_before/2 and expect_after/2

Assert a causal relationship between two events.

# Safety check must happen before robot enters running state
{req, state} = expect_before(
service_call("/safety/check"),
message("/robot/state", matches(%{mode: :running}))
)
# Completed must happen after running
{earlier, later} = expect_after(
message("/robot/state", matches(%{mode: :completed})),
message("/robot/state", matches(%{mode: :running}))
)

expect_before(a, b) returns {a_payload, b_payload}. expect_after(later, earlier) returns {earlier_payload, later_payload}.


expect_together/1

Assert all conditions are eventually satisfied in any order. Each event in the log can satisfy at most one descriptor (greedy left-to-right matching).

[state_msg, battery_msg] = expect_together [
message("/robot/state", matches(%{mode: :running})),
message("/battery/state", matches(%{charging: false}))
]

expect_workflow/1

Readable alias for expect_sequence/1 using :given / :then keyword steps. Ideal for describing cause-and-effect workflows.

expect_workflow [
given: message("/mission/start"),
then: service_call("/safety/check"),
then: message("/robot/state", matches(%{mode: :running})),
then: message("/robot/state", matches(%{mode: :completed}))
]

Failure Messages

When a sequence assertion fails, the error includes which event was expected and what was actually observed:

expect_sequence: timed out waiting for event.
Expected:
message on "/robot/state"
Events observed after seq 2:
[seq 3] message on /robot/state: %RobotState{mode: :completed}

Design