Current section

Files

Jump to
rclex_testing README.md
Raw

README.md

# RclexTesting
A [`Phoenix.LiveViewTest`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveViewTest.html)-style
testing library for ROS 2 applications built with [rclex](https://github.com/rclex/rclex).
Describe robot behaviour as executable scenario specifications — without
manually creating nodes, publishers, subscribers, timers, clients, or
assertion loops.
```elixir
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
| Area | Functions |
|------|-----------|
| **Topics** | `publish/2`, `watch/2`, `expect/2,3`, `refute_message/2,3,4`, `expect_count/2,3`, `expect_sequence/2,3`, `messages/1`, `eventually/1,2` |
| **Services** | `mock_service/3`, `call_service/3,4`, `expect_service_call/2,3`, `refute_service_call/2,3,4` |
| **Actions** | `mock_action/3`, `send_goal/3,4`, `expect_goal/2,3`, `refute_goal/2,3,4`, `expect_feedback/2,3`, `expect_result/2,3` |
| **Matchers** | `matches/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 events** | `expect_sequence/1`, `expect_before/2,3`, `expect_after/2,3`, `expect_together/1,2`, `expect_workflow/1,2` |
| **Event descriptors** | `message/1,2`, `service_call/1,2`, `goal/1,2`, `feedback/1,2`, `result/1,2` |
| **Graph assertions** | `expect_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 time** | `set_time/1`, `advance_time/1`, `sim_time/0` |
## Installation
Add `rclex_testing` to your test dependencies in `mix.exs`:
```elixir
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`:
```elixir
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:
```sh
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.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
refute_message "/fault_topic", timeout: 500
refute_message "/fault_topic", FaultMsg, &(&1.severity == :critical)
```
### `expect_count/2,3`
Assert that exactly N messages arrive.
```elixir
msgs = expect_count "/sensor/data", 5
```
### `messages/1`
Return all buffered messages for a topic in arrival order.
```elixir
all = messages("/robot/state")
```
### `eventually/1,2`
Poll a zero-arity predicate until truthy or timeout.
```elixir
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.
```elixir
mock_service "/safety/check", SafetyCheck,
fn _req -> %SafetyCheck.Response{allowed: true} end
```
### `call_service/3,4`
Call a service synchronously and return the response.
```elixir
response = call_service("/safety/check", SafetyCheck, %SafetyCheck.Request{})
```
### `expect_service_call/2,3`
Assert that the mocked service received a (matching) request.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
expect "/robot/state",
matches(%{
mode: :running,
speed: gt(0),
battery: between(20, 100),
pose: matches(%{x: approx(10.0, delta: 0.1)})
})
```
| Matcher | Description |
|---------|-------------|
| `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.
```elixir
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:
```elixir
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.
```elixir
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:
```elixir
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:
```elixir
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).
```elixir
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:
```elixir
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.
```elixir
# 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).
```elixir
[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.
```elixir
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
- **Scenario node** — each `scenario` block starts a uniquely-named ROS node
owned by the test; all entities are stopped in `on_exit`.
- **Agent buffer** — subscription and action callbacks write to a per-scenario
`Agent`; the test process retains the buffer for `messages/1` and
structural inspection.
- **Unified event log** — every incoming message, service call, goal, feedback, and
result is also appended (with a monotonic sequence number) to a global log. The
multi-stream assertions (`expect_sequence`, `expect_before`, etc.) query this log
to verify ordering across streams.
- **Message-passing wakeup** — callbacks `send` a tagged notification to the
test process; `expect` uses `receive` with a deadline instead of polling,
giving sub-millisecond response latency. Cross-stream assertions additionally
receive an `:any` notification after each event.
- **Cursor semantics** — each `expect`/`expect_service_call`/`expect_goal`
etc. advances a per-stream cursor so consecutive assertions on the same
stream always observe distinct events.