ADR - Docker-Based Parallel Testing Framework¤
Status¤
Proposed.
Date¤
2026-07-28.
Decision¤
Build a NorFab test orchestration framework that runs isolated Docker Compose test cells in parallel. A test cell is a complete, disposable test environment:
- one Compose project name;
- one generated inventory and runtime directory;
- one Docker network;
- one pytest runner container;
- the NorFab services needed by that test slice;
- optional external dependencies such as NetBox or Containerlab support;
- isolated artifacts, logs, coverage, and junit output.
The first implementation should not try to make one pytest process start many NorFab environments concurrently. Instead, parallelism should happen above pytest:
test orchestrator
cell py312-core-aio -> docker compose up -> pytest -m core
cell py312-nornir-split -> docker compose up -> pytest services/nornir
cell py312-netbox-split -> docker compose up -> pytest services/netbox
cell py313-fastmcp-split -> docker compose up -> pytest services/fastmcp
This avoids fixture, port, __norfab__, and external-state collisions while
letting each cell keep normal pytest behavior.
The framework should be driven by a small matrix file, for example:
python:
- "3.10"
- "3.11"
- "3.12"
- "3.13"
- "3.14"
cells:
- name: core
topology: aio
pytest: ["core"]
scale: small
- name: nornir
topology: split
pytest: ["services/nornir"]
workers:
nornir: 2
scale: medium
- name: netbox
topology: split
pytest: ["services/netbox"]
dependencies: ["netbox"]
workers:
netbox: 1
scale: large
The orchestrator expands the matrix, starts cells with unique Compose project names, collects results, and tears everything down automatically.
Context¤
NorFab is a multiprocessing and distributed-service framework. Tests currently
use pytest fixtures such as nfclient and picle_shell in tests/conftest.py
to start a real NorFab process tree from tests/nf_tests_inventory/inventory.yaml.
Current pressure points:
- fixtures use one inventory and one runtime folder per pytest process;
- test inventory and several tests assume fixed broker endpoints;
- Docker development Compose uses fixed container names, static IP addresses, and fixed host ports;
- a single shared environment makes raw in-process pytest parallelism risky;
- service suites touch shared external state, especially NetBox and Containerlab-style resources;
- some dependencies have Python-version limits, for example FakeNOS currently supports a narrower Python range than NorFab as a whole.
The repository already has useful building blocks:
- pytest markers in
pyproject.toml; - service-oriented test folders under
tests/services/<service>; - Docker role images under
docker/norfab-docker-build; - Docker development Compose under
docker/norfab-docker-dev; - documented test conventions in
docs/development/testing/norfab_testing_framework.md; - Python support declared as 3.10 through 3.14.
The new framework should use those pieces instead of replacing the test suite.
Goals¤
- Use available large local compute efficiently, including high parallel cell counts on an 80-core / 128 GB machine.
- Fully automate container build, startup, readiness checks, pytest execution, artifact collection, and teardown.
- Support multiple Python versions.
- Support multiple deployment topologies.
- Support scaling knobs for broker, workers, services, runner concurrency, and external dependencies.
- Keep test cells isolated from each other by default.
- Preserve existing pytest markers and service test layout.
- Keep the first implementation small and maintainable.
- Make failures easy to debug through collected logs, generated inventories, Compose config, junit XML, and coverage artifacts.
- Allow local developer runs and CI runs to use the same matrix format.
Non-Goals¤
- Do not rewrite service tests into a new testing framework.
- Do not require Kubernetes for the first implementation.
- Do not require pytest-xdist for integration tests in the first implementation.
- Do not make Linux Docker provide true Windows or macOS coverage.
- Do not require all optional service dependencies to pass on every Python version when their upstream packages do not support that version.
- Do not depend on fixed host ports or fixed Docker container names.
- Do not make Docker-based tests the only way to run a small local pytest command.
Proposed Architecture¤
Test Orchestrator¤
Add a small command-line orchestrator later, for example:
tests/orchestrator/nftest.py
or:
scripts/norfab-test
Responsibilities:
- read the test matrix;
- select cells by name, marker, topology, Python version, or scale;
- build or pull the required images;
- generate per-cell Compose files and inventory;
- assign unique project names;
- start dependencies and NorFab services;
- wait for readiness;
- run pytest inside the test runner container;
- collect artifacts;
- tear down containers, networks, and volumes;
- produce one summary at the end.
Example commands:
python tests/orchestrator/nftest.py run --matrix tests/matrix.yaml
python tests/orchestrator/nftest.py run --cell nornir --python 3.12
python tests/orchestrator/nftest.py run --profile smoke
python tests/orchestrator/nftest.py run --profile full --max-cells 16
python tests/orchestrator/nftest.py clean --run-id 20260728-101500
Use subprocess calls to docker compose in the first implementation. The
Docker SDK is optional later, but shelling out keeps behavior close to what
developers already know.
Test Cell Layout¤
Each cell should get its own generated workspace under a gitignored runtime directory:
__norfab_test_runs__/
20260728-101500/
py312-nornir-split/
compose.yaml
inventory/
inventory.yaml
nornir/
netbox/
artifacts/
junit.xml
coverage.xml
pytest.log
compose.log
docker-events.log
logs/
Do not write generated inventories into tests/nf_tests_inventory/ in place.
The orchestrator should copy or render from templates into the cell directory.
Compose Isolation Rules¤
Test Compose files must avoid the fixed-name patterns used by the development Compose stack:
- no
container_name; - no static IP addresses unless a specific topology test requires them;
- no fixed host port publishing by default;
- service-to-service communication should use Compose service DNS names;
- each cell uses a unique
--project-name; - every volume name and network name belongs to the Compose project;
- host-mounted artifact paths include the cell ID.
Broker endpoint inside generated inventory should prefer service DNS:
broker:
endpoint: "tcp://norfab-broker:5555"
The pytest runner container joins the same Compose network and uses the same inventory, so host port publication is unnecessary for most tests.
Runner Container¤
Each cell includes a runner container that executes pytest:
norfab-test-runner
command: pytest services/nornir --junitxml=/artifacts/junit.xml
The runner image should install test dependencies and the relevant NorFab extras for that cell. Source code should be mounted read-only where practical, while runtime and artifacts are writable per cell.
For development speed, the runner can mount the local source tree and set
PYTHONPATH=/workspace. CI can choose between source mounts and built wheels.
Topology Profiles¤
Support these topology profiles:
| Topology | Purpose |
|---|---|
unit |
No broker or workers; fast tests that do not need NorFab runtime. |
aio |
Broker, workers, and client in one process/container. |
split |
Broker in one container, workers in role-specific containers, runner as client. |
client-remote |
Runner connects to a broker stack started by the cell. |
multi-broker-negative |
Failure and collision tests around ports, auth, or startup behavior. |
distributed |
Multiple worker containers per service to test worker selection and scale. |
The first useful target is split, because it resembles deployment and
naturally isolates broker, workers, and tests.
Scaling Profiles¤
Use scale profiles to describe resource cost and breadth:
| Scale | Intent |
|---|---|
smoke |
Small marker subset, one Python version, minimal services. |
small |
Core and light service tests. |
medium |
One full service suite with normal worker counts. |
large |
Heavy service suites such as NetBox or Containerlab. |
stress |
Many workers, many concurrent jobs, longer timeouts. |
soak |
Long-running reliability tests, not part of normal PR runs. |
The orchestrator should map these to CPU, memory, worker counts, timeouts, and parallel cell limits.
Example:
scales:
small:
cpus: 2
memory: 4g
pytest_workers: 0
timeout: 10m
large:
cpus: 8
memory: 16g
pytest_workers: 0
timeout: 45m
stress:
cpus: 16
memory: 24g
worker_scale:
nornir: 8
netbox: 4
timeout: 2h
Python Version Matrix¤
Build images with existing PYTHON_VERSION Docker build args:
3.10
3.11
3.12
3.13
3.14
The matrix should allow exclusions where optional dependencies do not support a Python version.
Example:
exclude:
- python: "3.13"
cell: fakenos
reason: "fakenos dependency supports Python <3.13"
- python: "3.14"
cell: fakenos
reason: "fakenos dependency supports Python <3.13"
Keep the default developer smoke profile on one Python version, likely the project's current default Docker build version. Run the full Python matrix in nightly or explicit full validation.
Operating System Coverage¤
Use Docker for Linux distribution coverage first:
python:3.12-slim-trixie
python:3.12-slim-bookworm
python:3.12-alpine # only if dependencies support musl cleanly
True Windows and macOS coverage should use native CI runners or VMs, not Linux Docker containers. Docker cannot make Linux containers validate Windows file locking, Windows process semantics, or macOS behavior.
Recommended OS strategy:
- local Docker matrix for Linux behavior and deployment topologies;
- Windows runner for NFAPI, NFCLI, logging, and filesystem-sensitive tests;
- macOS runner for core/client compatibility if required;
- avoid Containerlab or privileged networking tests on Windows/macOS unless a dedicated environment supports them.
External Dependencies¤
Treat external dependencies as cell-owned services unless the cell explicitly targets a shared external environment.
Examples:
- NetBox cell starts its own NetBox and PostgreSQL containers, then seeds test data before pytest.
- FastAPI/FastMCP cells use service containers inside the same project network.
- Containerlab cells run only on hosts marked as privileged-capable and should use a dedicated runtime namespace.
- Nornir tests that need fake network devices should prefer FakeNOS or containerized network simulators inside the same cell.
Each dependency should have a readiness probe and a cleanup path. Seed data should be idempotent and scoped to the cell.
Fixture Changes¤
Keep fixture changes narrow. Existing test code should mostly keep using
nfclient and picle_shell.
Modify fixtures so they can read test runtime settings from environment variables supplied by the orchestrator:
NORFAB_TEST_INVENTORY
NORFAB_TEST_BASE_DIR
NORFAB_TEST_BROKER_ENDPOINT
NORFAB_TEST_RUN_ID
NORFAB_TEST_CELL_ID
If these variables are absent, keep current local behavior:
NorFab(inventory="./nf_tests_inventory/inventory.yaml")
Inside Docker cells, the orchestrator sets:
NORFAB_TEST_INVENTORY=/cell/inventory/inventory.yaml
NORFAB_TEST_BASE_DIR=/cell/inventory
NORFAB_TEST_BROKER_ENDPOINT=tcp://norfab-broker:5555
This keeps the existing tests usable while allowing generated inventories and remote/split broker endpoints.
Pytest Split Strategy¤
Prefer cell-level splitting by path and marker:
core
nfcli
nornir
netbox
fastapi
fastmcp
filesharing
fakenos
containerlab
workflow
dummy
Avoid running pytest-xdist across integration tests in the first version. Many
tests share nfclient and external service state, so concurrency inside one
cell can create subtler collisions than cell-level parallelism.
Use pytest-xdist later for pure unit/core tests or test groups proven safe.
Artifact Collection¤
Each cell should always collect:
- pytest stdout/stderr;
- junit XML;
- coverage XML or coverage data;
- generated inventory;
- rendered Compose config;
docker compose ps;docker compose logs --timestamps;- NorFab
__norfab__logs and databases where useful; - dependency logs, for example NetBox/PostgreSQL logs;
- orchestrator timing and resource summary.
On failure, preserve the cell directory by default for local debugging. In CI, upload artifacts and then tear down containers.
Scheduling¤
The orchestrator should be resource-aware but simple:
- each cell declares CPU and memory cost;
- the host declares total usable CPU and memory;
- the scheduler starts cells while the sum of costs fits the host budget;
- cells can also declare exclusivity labels such as
containerlaborhost-network; - failed cells do not stop unrelated cells unless
--fail-fastis set.
For an 80-core / 128 GB host, a first practical default could be:
max-cells: 12-20
reserve-cpus: 4
reserve-memory: 8g
Tune this empirically. Network automation tests often bottleneck on startup, external services, and IO before they use all CPU cores.
Alternatives Considered¤
Plain pytest-xdist In One Environment¤
This is attractive because it is simple:
pytest -n auto
But NorFab tests start brokers, workers, databases, logs, and external state.
Running many tests inside one process namespace risks port collisions, shared
__norfab__ state, and order-dependent failures. It is useful later for
pure core/unit tests, not as the integration-test foundation.
tox or nox Matrix Only¤
tox/nox is good for Python-version environments and command organization, but it does not solve distributed service isolation by itself. It can wrap the orchestrator later:
tox env py312-docker-smoke -> nftest run --python 3.12 --profile smoke
GitHub Actions Matrix Only¤
CI matrix jobs are useful for official validation, especially Windows and macOS. They are not enough for the local 80-core machine because the local goal is to pack many isolated test cells onto one host with controlled cleanup and artifacts.
Kubernetes-Based Test Farm¤
Kubernetes can model this well, but it is too much for the first step. Compose is easier to inspect and closer to existing NorFab Docker development assets. Keep Kubernetes as a future remote executor if Compose starts to creak.
One Long-Lived Shared Test Stack¤
A shared stack is convenient for manual debugging, but it weakens test isolation. State leaks between suites become normal. It can remain a developer mode, but automated validation should default to disposable cells.
Docker-In-Docker Per Test¤
Starting nested Docker inside each test or test container adds security and performance complexity. Prefer the host orchestrator controlling Compose projects directly.
Proposed Minimal Implementation¤
Implement this in stages.
Stage 1 - Matrix And Compose Cells¤
- Add a matrix file under
tests/, such astests/docker_matrix.yaml. - Add a small orchestrator script.
- Generate Compose files into
__norfab_test_runs__/. - Support
core,nfcli, and one service suite first. - Use one Python version first.
- Collect logs, junit, and Compose state.
Stage 2 - Fixture Environment Hooks¤
- Update
tests/conftest.pyto honorNORFAB_TEST_INVENTORY,NORFAB_TEST_BASE_DIR, andNORFAB_TEST_BROKER_ENDPOINT. - Keep existing local defaults when env vars are absent.
- Make test inventories generated per cell rather than mutating source fixtures.
Stage 3 - Service Matrix¤
- Add cells for Nornir, NetBox, FastAPI, FastMCP, FileSharing, FakeNOS, Containerlab, Workflow, and Dummy service tests.
- Add external dependency startup and seeding for NetBox.
- Add skip/exclusion rules for unsupported Python/dependency combinations.
Stage 4 - Scaling And Stress¤
- Add worker scale parameters.
- Add high-concurrency job tests.
- Add long-running stress and soak profiles.
- Add resource-aware scheduling.
Stage 5 - OS And CI Integration¤
- Add Linux distro image variants where useful.
- Add CI jobs that call the same orchestrator.
- Add native Windows and macOS jobs for non-Docker or limited Docker coverage.
- Publish combined junit and coverage reports.
Code Change Guidelines¤
Keep initial codebase changes surgical:
- add orchestration files rather than refactoring NorFab runtime;
- update
tests/conftest.pyonly enough to accept orchestrator-provided inventory and broker endpoint values; - keep existing pytest markers and test paths;
- avoid broad changes to service tests in the first pass;
- keep Docker development Compose intact for manual development;
- create separate generated test Compose files for parallel automation;
- prefer templates and small helpers over a large framework.
Risks¤
Fixed Ports And Names¤
Any fixed container_name, static IP, or host port can break parallel cells.
Test Compose generation must default to project-scoped names and service DNS.
Shared External State¤
NetBox and Containerlab tests can leak state if they share an external service. Automated cells should own their dependency containers and seed data.
Python Version Dependency Gaps¤
Optional dependencies may lag NorFab's declared Python support. The matrix must support explicit exclusions with reasons.
Windows And macOS Expectations¤
Linux Docker validates Linux behavior. It does not validate Windows file locking, Windows process behavior, or macOS behavior. Native runners or VMs remain necessary for those.
Over-Parallelization¤
An 80-core machine can still bottleneck on Docker image pulls, disk IO, PostgreSQL startup, network namespace creation, or test data seeding. The scheduler should default to bounded parallelism and make limits explicit.
Debugging Failed Cells¤
Disposable environments can make failures hard to inspect if cleanup runs too
early. Local failed cells should be preserved by default unless --cleanup or
CI mode is selected.
Documentation Updates¤
Update testing documentation when this is implemented:
- add a Docker parallel testing tutorial;
- document the matrix schema;
- document common local commands for smoke, service, full, and stress runs;
- explain generated test cell directories and cleanup;
- explain how to add a new service test cell;
- explain Python-version exclusions;
- explain OS coverage boundaries;
- document artifact locations and failure debugging workflow.
Open Questions¤
- Should the orchestrator live under
tests/orchestrator/orscripts/? - Should the first runner install from editable source, built wheel, or both as separate cell modes?
- Should NetBox dependency containers be built into NorFab test assets or use upstream images directly?
- Which service suites are safe to run against scaled worker counts without rewriting cleanup assumptions?
- Should coverage be collected per cell and combined automatically in the first implementation, or added after the matrix is stable?