A topology that only runs on your laptop is a good start, but the real payoff comes when every pull request spins it up, runs the tests and throws it away again. This post walks through doing that in GitHub Actions: generate the topology, start it with a test container, let pytest talk to the RemotiveBroker, and fail the build when a test fails.
A tester container in the instance
RemotiveTopology lets you add your own containers to an instance, next to the ECUs. The nice part for CI is that a test runner can be one of them: it starts inside the topology, on the same networks as everything else, and reaches the TopologyBroker by name. Put it behind a profile so it only runs when you ask for it:
containers:
tester:
profiles: [tester]
build:
dockerfile: ../../../python-runner.dockerfile
volumes:
- ../tests:/app
working_dir: /app
command: "pytest --broker_url=http://topology-broker.com:50051 -s -vv"
The Dockerfile only needs Python and your test dependencies: pytest, pytest-asyncio and the
remotivelabs-topology package. Profiles map straight to docker compose profiles, so the
tester stays out of the way when you start the topology without --profile tester to poke
at it by hand.
One thing to check before moving to a hosted runner: CAN channels default to RemotiveBus, which needs to be installed on the host. If you don't need SocketCAN in CI, switch the instance to UDP emulation, which works anywhere without extra setup:
settings:
can:
default_driver: udp
Writing the tests
Tests use the RemotiveTopology framework,
the Python library you also write behavioral models with. A small conftest.py turns the
--broker_url option from the container command into a BrokerClient fixture. The default
points at localhost:50051, which is where the broker is forwarded when you run the topology
interactively, so the same tests run from your editor too:
# tests/conftest.py
import pytest_asyncio
from remotivelabs.broker import BrokerClient
def pytest_addoption(parser):
parser.addoption("--broker_url", default="http://localhost:50051")
@pytest_asyncio.fixture
async def broker_client(request):
async with BrokerClient(url=request.config.getoption("--broker_url")) as client:
yield client
A test then subscribes to frames in a namespace (named <ECU>-<Channel>) and waits for the
signal values it expects. This one is adapted from the
testing examples in the API docs:
# tests/test_hazard_lights.py
from remotivelabs.broker import BrokerClient
from remotivelabs.topology.testing.frames import capture_frames
async def test_hazard_light_sequence(broker_client: BrokerClient) -> None:
async with capture_frames(
(broker_client, "HazardLightControlUnit-DriverCan0"),
frames=["HazardLightButton"],
) as cap:
await cap.wait_for_signal_values(
"HazardLightButton",
"HazardLightButton.HazardLightButton",
values=[0, 1, 0],
timeout=5.0,
)
The tests are async, so set asyncio_mode = auto in your pytest configuration (or mark
them with @pytest.mark.asyncio). If you're unsure which namespaces exist, start the
topology and run remotive broker signals namespaces --url http://localhost:50051.
Credentials for the runner
Running a topology checks your organization's RemotiveTopology subscription, and a CI job shouldn't do that with someone's personal login. Create a service account with the RemotiveTopology Executor role instead:
remotive cloud service-accounts create ci-runner --role org/topologyRunner --project <project id>
remotive cloud service-accounts tokens create --service-account ci-runner --project <project id>
remotive cloud auth print-access-token --account <email for service account>
remotive cloud organizations list
Store the token as a repository secret, REMOTIVE_CLOUD_AUTH_TOKEN, and the organization
uid as REMOTIVE_CLOUD_ORGANIZATION. If you run with a RemotiveBroker license file, keep it
out of the repository: store it as a secret too and point REMOTIVEBROKER_LICENSE at it.
The workflow
With that in place, the job is four steps: install the RemotiveCLI, generate the topology, start it with the tester profile, and clean up.
# .github/workflows/topology-tests.yml
name: Topology tests
on: [pull_request]
jobs:
topology-tests:
runs-on: ubuntu-latest
env:
REMOTIVE_CLOUD_AUTH_TOKEN: ${{ secrets.REMOTIVE_CLOUD_AUTH_TOKEN }}
REMOTIVE_CLOUD_ORGANIZATION: ${{ secrets.REMOTIVE_CLOUD_ORGANIZATION }}
steps:
- uses: actions/checkout@v4
- name: Install RemotiveCLI
run: pipx install remotivelabs-cli
- name: Generate topology
run: remotive topology generate -n mytopology -f topology/mytopology.instance.yaml build
- name: Run tests
run: >
docker compose -f build/mytopology/docker-compose.yml --profile tester
up --abort-on-container-exit --exit-code-from tester
- name: Clean up
if: always()
run: docker compose -f build/mytopology/docker-compose.yml --profile tester down
--abort-on-container-exit is what makes this work in CI: when pytest finishes, the tester
container exits and docker compose stops the rest of the topology. Adding
--exit-code-from tester, a plain docker compose option, makes the step's exit code pytest's,
so a failing test fails the job. The down step runs even after a failure, so the runner is
left clean.
Generate runs on every job, and that's on purpose: the generated files are completely
overwritten each time, and any change to the platform or instance files needs a new
generate anyway. Don't commit build/.
When it goes red
Most failures are easier to read in the container logs than in the pytest output alone. Add
a step with if: failure() that runs docker compose -f build/mytopology/docker-compose.yml logs
before the clean-up, and you get the behavioral models' and brokers' side of the story too.
The Docker tips page has
more commands for following logs per service.
To reproduce locally, run the same generate, then start the topology without the tester
profile, as described in
Running a topology, and
run pytest from your editor against localhost:50051. Same tests, same topology, just with a
debugger attached.