Project Setup
What we are testing
This tutorial walks through building an integration test suite for a small but realistic system: three HTTP services that depend on each other.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ task_service │────▶│ auth_service │ │ │
│ :9020 │ │ :9010 │ │ db_service │
│ │──┐ └──────┬───────┘ │ :9000 │
└──────────────┘ │ │ │ │
│ └────────────▶│ │
└──────────────────────▶│ │
└──────────────┘
db_service is a key-value database. It stores data in flat files, exposes a JSON REST API, and logs every operation to stdout. You create named databases, then read and write keys inside them.
auth_service handles authentication. It stores login/password pairs in the db service and exposes register and login endpoints. It depends on a running db_service with a pre-created database.
task_service is the system under test. It provides a task management API with full CRUD, authenticates users through the auth service, and stores tasks in the db service. It depends on both other services.
All three are small Python scripts — no frameworks, no dependencies beyond the standard library. You can read their full specification in SPEC.md.
Prerequisites
This tutorial assumes you have completed the DSL tutorial and are comfortable with all Relux language features: shells, operators, variables, functions, effects, imports, and condition markers.
You will also need:
- Relux installed and on your
PATH - Python 3 (any recent version — the services use only the standard library)
- Some other common tools, but we will get to it later
Scaffolding the project
Copy the services scripts into new directory: this will be out “monorepo”. Now, from the project root:
relux new
This creates Relux.toml and the relux/ directory structure:
project/
├── Relux.toml
├── SPEC.md
├── db_service.py
├── auth_service.py
├── task_service.py
└── relux/
├── .gitignore
├── tests/
└── lib/
The generated Relux.toml is fully commented out — feel free to play with it. Although, I do not recommend to change the jobs number yet:
name = "suite-tutorial"
# [shell]
# command = "/bin/sh"
# prompt = "relux> "
[timeout]
match = "2s"
test = "30s"
suite = "5m"
# [run]
# jobs = 1
Creating the first test file
Let’s scaffold a test file for the database service:
relux new --test db/smoke
Created relux/tests/db/smoke.relux
The command creates relux/tests/db/smoke.relux with a starter test:
test "hello-relux" {
shell myshell {
> echo hello-relux
<? ^hello-relux$
match_ok()
}
}
This is a placeholder — it just sends echo hello-relux and matches the output. Not useful yet, but it proves the toolchain works.
Checking and running
First, validate the file without executing it:
relux check
check passed shows that the relux code is fine. Now run it:
relux run
...
test result: ok. 1 passed; 0 failed; finished in 8.7ms
...
The template test passes. We have a working project and a confirmed toolchain. The placeholder test will be replaced with real database tests in the next chapter.
Starting services manually
You will not need to start the services by hand once the test suite is written — effects will handle that. But it helps to understand how they work before automating them.
Each service is a standalone Python script with command-line arguments:
# Start the database (port 9000, data in ./data)
python3 db_service.py --port 9000 --data-dir /tmp/db-data
# In another terminal: create a database and write a key
curl -X POST http://localhost:9000/db/mydb
curl -X PUT http://localhost:9000/db/mydb/greeting -d '{"value": "hello"}'
curl http://localhost:9000/db/mydb/greeting
Every service prints listening on PORT when it is ready to accept requests, and logs each operation as a plain-text line to stdout. This is important — Relux tests will match these log lines to verify service behavior from the inside, not just through HTTP responses.
Next: Testing the Database Service — write the first real tests
Testing the Database Service
The two-shell pattern
Integration testing a service always starts the same way: one shell runs the service, another shell talks to it. Open relux/tests/db/smoke.relux and replace the placeholder with a real test.
The database service takes a --data-dir argument and listens on port 9000 by default. We will use a temporary directory for storage. Here is the first test — create a database and verify the response:
test "create a database" {
"""
Start db_service, create a database, and verify the JSON response.
"""
shell db {
> ${__RELUX_SUITE_ROOT}/db_service.py
<? ^listening on 9000$
}
shell client {
> curl -s -X POST http://localhost:9000/db/testdb
<? "created": "testdb"
match_ok()
}
}
${__RELUX_SUITE_ROOT} is the directory containing relux manifest (Relux.toml file). Relux sets this variable, and some others, for every spawned PTY.
The db shell starts the service and waits for the readiness message. The client shell sends a curl request and matches part of the JSON response. match_ok() confirms the command exited successfully — without it, we would only know the output looked right, not that the exit code was zero.
Now, it is time to run the first actual test.
relux run
...
test result: ok. 1 passed; 0 failed; finished in 127.4 ms
...
Nice, the test passed. The test actually started our database service, and created a new database in it. But what would happen if we run the test again?
running 1 tests
test db/smoke.relux/create-a-database: FAILED (2.1 s)
Error: match timeout in shell `client`
╭─[ relux/tests/db/smoke.relux:12:9 ]
│
12 │ <? "created": "testdb"
│ ───────────┬──────────
│ ╰──────────── timed out waiting for `"created": "testdb"`
────╯
Event log: file://.../logs/relux/tests/db/smoke/create-a-database/event.html
Oh no, what happened? Let’s take a look into the log that was generated by relux.
In the ‘match timeout’ line you will see a part of the shell output buffer:
...ST http://localhost:9000/db/testdb
{"error": "db testdb already exists"}relux>
We expected to match on "created": "testdb" line, but the database actually returned an error: database already exists. Right, we created it during the first test run, and it persisted on the disk.
A good test suite incorporates tests that are isolated from each other. If your test can observe some leftover artifacts from previous runs, or, what’s worse, even rely on them, you are doomed to have flaky tests, that would run fine 9 out of 10 times, but eventually will fail, destroying your trust. After that, if a test in your suite fails, you will not know if something is broken, or if the suite is once again playing jokes with you. Such a test suite is in the best case useless.
What can we do to isolate the test runs from each other in our case? Right, we need the cleanup.
Add another special shell block:
cleanup {
> rm -rf /tmp/database/*
match_ok()
}
Cleanup blocks run after the test is finished, no matter what the result was: pass or fail.
Rerun the suite a few times. It should pass every time now. Of course, this is brittle — you’re relying on cleanup to undo side effects. A better approach is to avoid shared state entirely, giving each test run its own directory. One may use something like mktemp, but there is a better way:
shell db {
let db_root = "${__RELUX_TEST_ARTIFACTS}/database"
> mkdir ${db_root}
match_ok()
> ${__RELUX_SUITE_ROOT}/db_service.py --data-dir ${db_root}
<? ^listening on 9000$
}
${__RELUX_TEST_ARTIFACTS} is a directory that is created for every test and every suite run. It is always unique for the particular test. What is better, this directory will not be removed: it is bundled with the test logs. If your test ever fails, and you have a suspicion that something is wrong with the database, you will have the whole data directory to analyze.
~> tree -d relux/out/run-2026-03-28-12-36-10-7WQvaZT7Yk/logs
logs
└── relux
└── tests
└── db
└── smoke
└── create-a-database
└── artifacts
└── database
└── testdb
Now, we can remove the cleanup block, we don’t need it anymore.
Fail patterns
The db_service prefixes all error log lines with error:. Instead of explicitly matching the log lines after every request, set a fail pattern on the service shell once:
shell db {
let db_root = "${__RELUX_TEST_ARTIFACTS}/database"
> mkdir ${db_root}
match_ok()
!? ^error:
> ${__RELUX_SUITE_ROOT}/db_service.py --data-dir ${db_root}
<? ^listening on 9000$
}
Now if the database logs any line starting with error: at any point during the test, the test fails immediately. This catches problems you didn’t anticipate — a corrupted data directory, a port conflict, an internal exception. Set it early, before the first command, and it monitors the shell for the entire test.
Don’t set the fail pattern when you’re intentionally provoking errors. Or unset it when entering an error-triggering section (this is done with the same empty operator !?).
CRUD operations
One database operation is not much of a test. Let’s exercise the full lifecycle — create, write, read, delete — all in one test:
test "key-value CRUD" {
"""
Create a database, write a key, read it back, and delete it.
"""
shell db {
let db_root = "${__RELUX_TEST_ARTIFACTS}/database"
> mkdir ${db_root}
match_ok()
!? ^error:
> ${__RELUX_SUITE_ROOT}/db_service.py --data-dir ${db_root}
<? ^listening on 9000$
}
shell client {
// create the database
> curl -s -X POST http://localhost:9000/db/mydb
<? "created": "mydb"
match_ok()
// write a key
> curl -s -X PUT http://localhost:9000/db/mydb/greeting -d '{"value": "hello"}'
<? "wrote": "greeting"
match_ok()
// read it back
> curl -s http://localhost:9000/db/mydb/greeting
<? "value": "hello"
match_ok()
// delete it
> curl -s -X DELETE http://localhost:9000/db/mydb/greeting
<? "deleted": "greeting"
match_ok()
// read again — should be gone
> curl -s http://localhost:9000/db/mydb/greeting
<? "error":
match_ok()
}
}
Run these tests. The suite should run and pass every time. Now imagine writing five more tests like this — the copy-pasted boilerplate gets annoying fast.
The duplication problem
Look at what we have so far. Every curl call follows the same pattern:
> curl -s -X METHOD http://localhost:9000/PATH BODY
<? expected response
match_ok()
The URL prefix, the -s flag, the match-then-check dance — it’s all repeated. When you have five CRUD operations and three error cases, that’s a lot of identical scaffolding. If the port changes, you update it in dozens of places.
Let’s define helper functions in the same file, above the tests.
fn curl(url) {
curl(url, "GET")
}
fn curl(url, method) {
curl(url, method, "")
}
# skip unless which("curl")
fn curl(url, method, req_body) {
let outdir = "${__RELUX_TEST_ARTIFACTS}/http"
> mkdir -p ${outdir}
match_ok()
let file_rand = rand(10)
let filename = "${outdir}/${file_rand}.http_response.txt"
> curl -v -X ${method} -d '${req_body}' -o ${filename} ${url}
<? ^* Request completely sent off
filename
}
fn match_http_code(code) {
<? ^< HTTP/(\d\.\d) ${code} (.*)$
}
fn http_request(expected_code, url) {
http_request(expected_code, url, "GET")
}
fn http_request(expected_code, url, method) {
http_request(expected_code, url, method, "")
}
fn http_request(expected_code, url, method, req_body) {
let response_filename = curl(url, method, req_body)
match_http_code(expected_code)
match_ok()
response_filename
}
# skip unless which("jq")
fn jq_match_query(filename, query, pattern) {
> jq -r '${query}' ${filename}
<? ${pattern}
match_ok()
}
The # skip unless which("curl") and # skip unless which("jq") are condition markers. They tell relux to skip any test that calls these functions if the required tool is not installed. The built-in which() function checks the PATH for an executable — if it returns an empty string, the condition is not met and the test is skipped, not failed. This distinction matters: a test that fails because curl is missing is not a real failure — the test was never actually run. Condition markers make this explicit in the test report.
You see four functions with different arities (number of parameters). Since a relux function always returns the last expression value, each function with lower arity can be read as a default value definition. For example, curl("http://example.com") call will execute curl("http://example.com", "GET", "").
curl creates the directory for output bodies, generates the output filename via call to the built-in function rand, and executes the curl command. It returns the generated filename that contains the output body.
match_http_code matches the expected HTTP code.
http_request combines the two, and also checks the exit code of the curl command. curl and match_http_code can still be used independently, for example, if you need to also match some specific response header.
jq_match_query runs the jq query against the file contents, and matches the output.
Now, with all these functions, our tests look very compact:
test "key-value CRUD" {
"""
Create a database, write a key, read it back, and delete it.
"""
shell db {
let db_root = "${__RELUX_TEST_ARTIFACTS}/database"
> mkdir ${db_root}
match_ok()
!? ^error:
> ${__RELUX_SUITE_ROOT}/db_service.py --data-dir ${db_root}
<? ^listening on 9000$
}
shell client {
log("create the database")
let response_filename = http_request(200, "http://localhost:9000/db/mydb", "POST")
jq_match_query(response_filename, ".created", "^mydb$")
log("write a key")
let response_filename = http_request(200, "http://localhost:9000/db/mydb/greeting", "PUT", "{\"value\": \"hello\"}")
jq_match_query(response_filename, ".wrote", "^greeting$")
log("read it back")
let response_filename = http_request(200, "http://localhost:9000/db/mydb/greeting")
jq_match_query(response_filename, ".value", "^hello$")
log("delete it")
let response_filename = http_request(200, "http://localhost:9000/db/mydb/greeting", "DELETE")
jq_match_query(response_filename, ".deleted", "^greeting$")
log("read again — should be gone")
let response_filename = http_request(404, "http://localhost:9000/db/mydb/greeting")
jq_match_query(response_filename, ".error", "^.*not found.*$")
}
}
This might not seem to be a very big win in code size, but there are few notable changes:
- All the HTTP requests check for the HTTP response code now.
- These functions are universal: any other test can now reuse it.
- All the response bodies are now stored in the test artifacts directory, making the test easier to debug.
Next: Extracting a Library — move shared functions into relux/lib/
Extracting a Library
Previous: Testing the Database Service
A second test file
The smoke tests work, but we only covered the happy path. The database has several error conditions worth testing: creating a database that already exists, reading a key that doesn’t exist, operating on a nonexistent database. These deserve their own file.
Create relux/tests/db/errors.relux:
relux new --test db/errors
Replace the placeholder with an error-path test. You’ll want to use the same helper functions from smoke.relux — http_request, jq_match_query — but of course they’re not available here. Try writing the test anyway:
# skip if SMOKE
test "create duplicate database" {
"""
Verify creating a database that already exists returns 409.
"""
shell db {
let db_root = "${__RELUX_TEST_ARTIFACTS}/database"
> mkdir ${db_root}
match_ok()
!? ^error:
> ${__RELUX_SUITE_ROOT}/db_service.py --data-dir ${db_root}
<? ^listening on 9000$
}
shell client {
log("create the database")
let response_filename = http_request(200, "http://localhost:9000/db/testdb", "POST")
jq_match_query(response_filename, ".created", "^testdb$")
log("create it again — should fail")
let response_filename = http_request(409, "http://localhost:9000/db/testdb", "POST")
jq_match_query(response_filename, ".error", "^.*already exists.*$")
}
}
Notice the # skip if SMOKE marker. As the suite grows, you will want a way to run just the smoke tests — the fast, happy-path checks that confirm services are alive. Error-path tests are important but slower and less critical for a quick sanity check. The marker reads the SMOKE environment variable: if it is set to any non-empty value, the test is skipped. Run SMOKE=true relux run and only the smoke tests execute. Skipped tests appear in the report as “skipped”, not “failed” — because they were never run. We will add this marker to all non-smoke tests from here on.
Run relux check and you’ll get errors: http_request and jq_match_query are not defined. They live in smoke.relux, and there’s no way to reach them from here.
Moving functions to lib/
The helper functions fall into two groups: HTTP plumbing (curl, http_request) and JSON inspection (jq_match_query). Let’s split them accordingly.
Create relux/lib/api/http.relux with the HTTP helpers:
// curl functions
fn curl(url) {
curl(url, "GET")
}
fn curl(url, method) {
curl(url, method, "")
}
# skip unless which("curl")
fn curl(url, method, req_body) {
let outdir = "${__RELUX_TEST_ARTIFACTS}/http"
> mkdir -p ${outdir}
match_ok()
let file_rand = rand(10)
let filename = "${outdir}/${file_rand}.http_response.txt"
> curl -v -X ${method} -d '${req_body}' -o ${filename} ${url}
<? ^> $
filename
}
// http functions
fn http_match_code(code) {
<? ^< HTTP/(\d\.\d) ${code} (.*)$
}
fn http_request(expected_code, url) {
http_request(expected_code, url, "GET")
}
fn http_request(expected_code, url, method) {
http_request(expected_code, url, method, "")
}
fn http_request(expected_code, url, method, req_body) {
let response_filename = curl(url, method, req_body)
http_match_code(expected_code)
match_ok()
response_filename
}
Create relux/lib/jq.relux with the JSON helper:
# skip unless which("jq")
fn jq_match_query(filename, query, pattern) {
> jq -r '${query}' ${filename}
<? ${pattern}
match_ok()
}
Neither file has tests — they are pure library code. The api/ subdirectory groups API-related helpers together; as the library grows, you might add api/grpc.relux or api/mqtt.relux next to it.
Imports
Update smoke.relux: remove the function definitions and add imports at the top:
import api/http
import jq
test "key-value CRUD" {
...
}
Do the same for errors.relux:
import api/http
import jq
test "create duplicate database" {
...
}
Import paths resolve from the <project_root>/relux/lib/ directory (reminder: project_root is the directory where Relux.toml lives). api/http thus points to <project_root>/relux/lib/api/http.relux.
These are wildcard imports — they bring in everything from the module. For a small project with well-named functions, this is fine. If you want to be explicit about what you use, selective imports list the names in curly braces:
import api/http { http_request }
import jq { jq_match_query }
Selective imports make it obvious which functions are in use and prevent name collisions. For now, wildcards keep things simple.
Run both files:
relux run
...
test result: ok. 3 passed; 0 failed; finished in 382.1 ms
...
Pure functions
Look at the test files again. The URL http://localhost:9000 is hardcoded everywhere. When we later need to support dynamic ports (for parallel test execution), this will be a problem. Let’s prepare by extracting URL construction into a function.
URL construction is just string concatenation — no shell commands needed. This is a good case for pure fn. Create relux/lib/service/db.relux:
pure fn url(path) {
"http://localhost:9000${path}"
}
A pure fn cannot contain shell operators (>, <?, <=, !?, etc.). It can only do variable assignments, call other pure functions, and return values. The compiler enforces this — if you accidentally add a send or match operator inside a pure fn, relux check will reject it.
Why bother? Pure functions can be called in more places than regular functions: inside condition markers, inside overlay blocks, and inside other pure functions. They are also easier to reason about since they have no side effects.
The function is named url — generic within its module. To avoid confusion at the call site, import it with an alias:
import service/db { url as db_url }
import api/http
import jq
The as keyword renames the imported function. Now db_url("/db/mydb") reads clearly, and later we can add service/auth.relux with its own url function aliased to auth_url.
With all three imports in place, the tests look like this:
import service/db { url as db_url }
import api/http
import jq
test "key-value CRUD" {
...
shell client {
log("create the database")
let response_filename = http_request(200, db_url("/db/mydb"), "POST")
jq_match_query(response_filename, ".created", "^mydb$")
log("write a key")
let response_filename = http_request(200, db_url("/db/mydb/greeting"), "PUT", "{\"value\": \"hello\"}")
jq_match_query(response_filename, ".wrote", "^greeting$")
...
}
}
The gain is small for now. But in chapter 6, when we replace the hardcoded port with a dynamic one, we’ll only need to change url in service/db.relux — not every test.
What we have so far
project/
├── Relux.toml
├── db_service.py
├── auth_service.py
├── task_service.py
└── relux/
├── tests/
│ └── db/
│ ├── smoke.relux
│ └── errors.relux
└── lib/
├── jq.relux
├── api/
│ └── http.relux
└── service/
└── db.relux
The HTTP and JSON helpers are shared. Both test files are focused on testing, not on plumbing. Adding a third test file for the database — say, multi-key workflows or concurrency — means writing only the test logic and a one-line import.
But look at the shell db { ... } block. It’s still duplicated across every test in both files: create the artifacts directory, start the service, match the readiness line. When we add auth tests in the next chapter, we’ll need the same db startup there too — plus auth on top of it. This is the kind of boilerplate that effects were designed to eliminate.
Next: Effects and Dependencies — extract service startup into reusable effects
Effects and Dependencies
Previous: Extracting a Library
The problem
Every test that needs the database service repeats the same setup:
shell db {
let db_root = "${__RELUX_TEST_ARTIFACTS}/database"
> mkdir ${db_root}
match_ok()
!? ^error:
> ${__RELUX_SUITE_ROOT}/db_service.py --data-dir ${db_root}
<? ^listening on 9000$
}
Many lines, copy-pasted into every test in every file. But the real pain starts now: the auth service needs a running database with a pre-created auth database. Every auth test would need to start db, create the database, then start auth – that is 15+ lines of setup before a single assertion. And if you get any of it wrong, you get a cryptic match timeout instead of a clear error.
Effects solve this. An effect is a reusable setup block that starts infrastructure and exposes shells for tests to use.
The Db effect
Open service/db.relux – we already have the pure fn url there. Add the effect to the same file:
import api/http
effect Db {
expose service
shell service {
let db_root = "${__RELUX_TEST_ARTIFACTS}/database"
> mkdir ${db_root}
match_ok()
!? ^error:
> ${__RELUX_SUITE_ROOT}/db_service.py --data-dir ${db_root}
<? ^listening on 9000$
}
}
pure fn url(path) {
"http://localhost:9000${path}"
}
The expose service declaration means: the service shell is part of this effect’s public interface. After the effect runs, tests that started it with an alias can interact with that shell via dot-access (e.g. shell db.service { ... }). If a test only needs the effect for its side effects (in this case, just for running the database), it can omit the alias and skip shell access entirely.
Co-locating the effect with the url function keeps everything about the database service in one module. Tests import both from the same place:
import service/db { url as db_url, Db }
Now update smoke.relux:
import service/db { url as db_url, Db }
import api/http
import jq
test "key-value CRUD" {
"""
Create a database, write a key, read it back, and delete it.
"""
start Db
shell client {
log("create the database")
let response_filename = http_request(200, db_url("/db/mydb"), "POST")
jq_match_query(response_filename, ".created", "^mydb$")
...
}
}
start Db tells relux: before this test runs, execute the Db effect. The setup block is gone – replaced by a single line. If you would need to match log lines on the running database shell, you would use start Db as db. After that, shell db.service { ... } blocks would be executed on the shell that was exposed by the Db effect.
Do the same for errors.relux. The manual shell db { ... } block is replaced by start Db in both files.
The Auth effect
The auth service depends on db. It needs:
- A running db_service
- The
authdatabase created in it - The auth_service itself started and ready
This is our first dependency chain – an effect that depends on another effect. Create relux/lib/service/auth.relux:
import api/http
import service/db { url as db_url, Db }
effect Auth {
start Db
expose service
shell setup {
log("create the auth database")
http_request(200, db_url("/db/auth"), "POST")
}
shell service {
!? ^error:
> ${__RELUX_SUITE_ROOT}/auth_service.py
<? ^listening on 9010$
}
}
pure fn url(path) {
"http://localhost:9010${path}"
}
start Db inside the effect declares a dependency. When a test says start Auth, relux resolves the chain: it starts Db first, then runs Auth.
The shell setup { ... } block opens a fresh shell to send the HTTP request that creates the auth database in the already-running db_service. The effect then starts auth_service in its own exposed service shell, sets a fail pattern, and waits for readiness.
Just like service/db.relux, the module co-locates the effect with a pure fn url for the auth service’s base URL. Tests import both:
import service/auth { url as auth_url, Auth }
Seeding test data
Before writing auth tests, think about what most of them need: pre-existing users. A registration test can create its own user, but every login test would repeat the same registration calls as setup noise.
We can solve this with another effect that builds on Auth. Add SeededAuth to service/auth.relux:
effect SeededAuth {
start Auth as auth
expose auth.service as service
shell seeder {
log("create seed database users")
http_request(200, url("/register"), "POST", "{\"login\": \"alice\", \"password\": \"alice_secret\"}")
http_request(200, url("/register"), "POST", "{\"login\": \"bob\", \"password\": \"bob_secret\"}")
http_request(200, url("/register"), "POST", "{\"login\": \"eva\", \"password\": \"eva_secret\"}")
}
}
SeededAuth doesn’t define its own service shell — the auth service is already running inside Auth. The expose auth.service as service declaration re-exposes the service shell from the Auth dependency (aliased auth) so that tests starting SeededAuth can access it the same way they would with Auth.
The chain grows:
- Db – raw database service running
- Auth – auth service running,
authdatabase created - SeededAuth – auth service with pre-registered test users
Each layer adds exactly one concern. Tests choose the layer they need: a registration test starts Auth, a login test starts SeededAuth.
Writing auth tests
Create relux/tests/auth/smoke.relux:
relux new --test auth/smoke
import api/http
import service/auth { url as auth_url, Auth }
test "register and login" {
"""
Register a new user, log in with correct and incorrect passwords.
"""
start Auth
shell client {
log("register a new user")
http_request(200, auth_url("/register"), "POST", "{\"login\": \"alice\", \"password\": \"alice_secret\"}")
log("login with correct password")
http_request(200, auth_url("/login"), "POST", "{\"login\": \"alice\", \"password\": \"alice_secret\"}")
log("login with wrong password")
http_request(401, auth_url("/login"), "POST", "{\"login\": \"alice\", \"password\": \"wrong\"}")
}
}
One line of setup: start Auth. The test registers its own user, then exercises both success and failure login paths. This is a smoke test – it only needs the auth service running, not pre-seeded data.
Now add error-path tests in relux/tests/auth/errors.relux. These use SeededAuth where pre-existing users are needed:
import api/http
import service/auth { url as auth_url, Auth, SeededAuth }
# skip if SMOKE
test "register duplicate user" {
"""
Verify registering a user that already exists returns 409.
"""
start SeededAuth
shell client {
log("register alice again")
http_request(409, auth_url("/register"), "POST", "{\"login\": \"alice\", \"password\": \"other\"}")
}
}
# skip if SMOKE
test "login unknown user" {
"""
Verify logging in with a nonexistent user returns 404.
"""
start Auth
shell client {
http_request(404, auth_url("/login"), "POST", "{\"login\": \"nobody\", \"password\": \"whatever\"}")
}
}
Notice the different effect choices: the duplicate registration test starts SeededAuth because alice must already exist. The unknown user test only starts Auth – no seeded users, just a running auth service.
What we have so far
project/
├── Relux.toml
├── db_service.py
├── auth_service.py
├── task_service.py
└── relux/
├── tests/
│ ├── db/
│ │ ├── smoke.relux
│ │ └── errors.relux
│ └── auth/
│ ├── smoke.relux
│ └── errors.relux
└── lib/
├── jq.relux
├── api/
│ └── http.relux
└── service/
├── db.relux
└── auth.relux
Each service module in lib/service/ owns its effects and its url function. Tests import both from the same place. Effects compose into layers – Db -> Auth -> SeededAuth – and tests pick the layer they need.
The pattern scales: when we add the task service in the next chapter, it will start both db and auth. Since Auth already starts Db, relux will resolve the full dependency graph automatically – and run each effect exactly once.
Next: Shared Dependencies – add the task service and see effect deduplication in action
Shared Dependencies
Previous: Effects and Dependencies
The task service
The task service is the most complex piece of the stack. It depends on both db and auth:
- It stores tasks in the db service (in a
tasksdatabase) - It authenticates users through the auth service
- It has its own
/loginendpoint that forwards credentials to auth and issues a Bearer token
To start it, we need a running db_service, a running auth_service, and the tasks database created. But Auth already starts Db internally. If Tasks also starts Db, does the database start twice?
The Tasks effect
Create relux/lib/service/tasks.relux:
import api/http
import service/db { url as db_url, Db }
import service/auth { Auth }
effect Tasks {
start Db
start Auth
expose service
shell setup {
log("create the tasks database")
http_request(200, db_url("/db/tasks"), "POST")
}
shell service {
!? ^error:
> ${__RELUX_SUITE_ROOT}/task_service.py
<? ^listening on 9020$
}
}
pure fn url(path) {
"http://localhost:9020${path}"
}
Tasks declares two dependencies: start Db and start Auth. But Auth itself also starts Db. This creates a diamond in the dependency graph:
Tasks
| |
| Auth
| |
+-- Db
If relux started a new db_service for each start Db, we would end up with two database instances on the same port – and the second one would fail to bind. This is where effect deduplication comes in.
Relux identifies each effect instance by its name and overlay values (we will cover overlays in the next chapter). Two start Db statements with no overlay both refer to the same identity: (Db, {}). Relux resolves the full dependency graph as a DAG and runs each unique instance exactly once.
In our case, the execution order is:
- Db – starts the database (once)
- Auth – creates the
authdatabase, starts auth_service - Tasks setup – creates the
tasksdatabase, starts task_service
Step 2 and the setup part of step 3 both use the single running database from step 1. No port conflicts, no duplicated work.
Authenticated requests
The task service requires a Bearer token on every request except /login. We need to extend the HTTP library to support authentication headers.
Add a 4-arity curl and http_request_authorized functions to api/http.relux:
# skip unless which("curl")
fn curl(url, method, req_body, extra_headers) {
let outdir = "${__RELUX_TEST_ARTIFACTS}/http"
> mkdir -p ${outdir}
match_ok()
let file_rand = rand(10)
let filename = "${outdir}/${file_rand}.http_response.txt"
> curl -v -X ${method} ${extra_headers} -d '${req_body}' -o ${filename} ${url}
<? ^> $
filename
}
The existing 3-arity curl now delegates to the 4-arity version with an empty extra_headers. The new http_request_authorized functions pass the Bearer header:
fn http_request_authorized(expected_code, url, token) {
http_request_authorized(expected_code, url, "GET", token, "")
}
fn http_request_authorized(expected_code, url, method, token) {
http_request_authorized(expected_code, url, method, token, "")
}
fn http_request_authorized(expected_code, url, method, token, req_body) {
let response_filename = curl(url, method, req_body, "-H 'Authorization: Bearer ${token}'")
http_match_code(expected_code)
match_ok()
response_filename
}
We also need a way to extract values (not just match them) from JSON responses. The login endpoint returns a token that we need to capture and use in subsequent requests. Add jq_extract to jq.relux:
# skip unless which("jq")
fn jq_extract(filename, query) {
> jq -r '${query}' ${filename}
<? ^jq (.*)$
<? ^(.+)$
let value = $0
match_ok()
value
}
The first <? ^jq (.*)$ skips past the echoed command in the output buffer. The second <? ^(.+)$ matches the actual jq output, and let value = $0 captures the full match into value. The function returns it so the caller can store the result.
Writing task tests
Create relux/tests/tasks/smoke.relux:
relux new --test tasks/smoke
import api/http
import jq
import service/auth { SeededAuth }
import service/tasks { url as tasks_url, Tasks }
test "task CRUD" {
"""
Log in, create a task, read it back, update it, and delete it.
"""
start Tasks
start SeededAuth
shell client {
log("login as alice")
let response_filename = http_request(200, tasks_url("/login"), "POST", "{\"login\": \"alice\", \"password\": \"alice_secret\"}")
let token = jq_extract(response_filename, ".token")
log("create a task")
let response_filename = http_request_authorized(200, tasks_url("/tasks"), "POST", token, "{\"title\": \"buy milk\", \"status\": \"todo\"}")
let task_id = jq_extract(response_filename, ".id")
jq_match_query(response_filename, ".title", "^buy milk$")
log("read it back")
let response_filename = http_request_authorized(200, tasks_url("/tasks/${task_id}"), token)
jq_match_query(response_filename, ".title", "^buy milk$")
jq_match_query(response_filename, ".status", "^todo$")
log("update the status")
let response_filename = http_request_authorized(200, tasks_url("/tasks/${task_id}"), "PUT", token, "{\"status\": \"done\"}")
jq_match_query(response_filename, ".status", "^done$")
log("delete it")
let response_filename = http_request_authorized(200, tasks_url("/tasks/${task_id}"), "DELETE", token)
jq_match_query(response_filename, ".deleted", "^${task_id}$")
}
}
The test starts both Tasks and SeededAuth. Behind the scenes, this pulls in the entire stack: db starts once, auth starts with the auth database and seed users, tasks starts with the tasks database. The test itself is just login and CRUD.
Notice how jq_extract captures the token and task ID into variables that are used in subsequent requests. The ${task_id} in tasks_url("/tasks/${task_id}") is interpolated at the call site – relux variables are just strings.
The dependency graph
With all three services, the full effect graph looks like this:
test "task CRUD"
| |
Tasks SeededAuth
| | |
| +--------> Auth
| |
+------------> Db
Five start statements across effects and the test, but only four unique effect instances. Auth is started by both Tasks and SeededAuth – it runs once. Db is started by both Tasks and Auth – it runs once. You declare what you need; relux figures out the rest.
What we have so far
project/
├── Relux.toml
├── db_service.py
├── auth_service.py
├── task_service.py
└── relux/
├── tests/
│ ├── db/
│ │ ├── smoke.relux
│ │ └── errors.relux
│ ├── auth/
│ │ ├── smoke.relux
│ │ └── errors.relux
│ └── tasks/
│ └── smoke.relux
└── lib/
├── jq.relux
├── api/
│ └── http.relux
└── service/
├── db.relux
├── auth.relux
└── tasks.relux
The suite now tests all three services. Each service has its own effect module. Dependencies are declared, not managed – relux resolves the graph and deduplicates automatically.
But all the effects use hardcoded ports: 9000, 9010, 9020. Running tests sequentially works because each test tears down before the next starts. Run with -j 4 and multiple tests will try to bind the same ports simultaneously. The next chapter solves this with dynamic ports and overlays.
Next: Parallel Execution – replace hardcoded ports with dynamic allocation
Parallel Execution
The problem: port collisions
Every effect in the suite uses a hardcoded port: db on 9000, auth on 9010, tasks on 9020. This works when tests run one at a time – each test tears down its effects before the next starts. But run the suite with -j 4 and four tests spin up simultaneously. Four copies of Db all try to bind port 9000 – the first succeeds, the other three crash.
The fix is to stop hardcoding ports. Each effect instance should get its own port, and downstream effects should know which port their dependency is listening on.
Dynamic ports with available_port()
Relux provides a built-in pure function available_port() that binds to an ephemeral TCP port, records the port number, and releases the socket. Call it as close to startup as possible to minimize the window for another process to claim the same port.
Environment overlay variables let us pass a port into an effect at the start site. Combine the two and each effect instance gets a unique port.
Update service/db.relux:
import api/http
effect Db {
expect DB_PORT
expose service
shell service {
let db_root = "${__RELUX_TEST_ARTIFACTS}/database"
> mkdir ${db_root}
match_ok()
!? ^error:
> ${__RELUX_SUITE_ROOT}/db_service.py --port ${DB_PORT} --data-dir ${db_root}
<~10s? ^listening on ${DB_PORT}$
}
}
pure fn url(port, path) {
"http://localhost:${port}${path}"
}
The expect DB_PORT declaration says: this effect requires DB_PORT to be provided by whoever starts it. If a caller forgets to pass it, relux reports an error at check time. The <~10s? is an inline tolerance timeout – it means “wait up to 10 seconds for this pattern”. When multiple tests run in parallel, services compete for CPU and may take longer to start than the default match timeout.
The url function takes the port as an argument – functions cannot read overlay variables, so the caller must pass it explicitly.
Propagating ports through the chain
Auth depends on Db. It needs to know the database port so it can pass --db-port to auth_service. The expect declaration names the required variables, and the start site passes them through an overlay block.
Update service/auth.relux:
import api/http
import service/db { url as db_url, Db }
effect Auth {
expect DB_PORT, AUTH_PORT
start Db
expose service
shell setup {
log("create the auth database")
http_request(200, db_url(DB_PORT, "/db/auth"), "POST")
}
shell service {
!? ^error:
> ${__RELUX_SUITE_ROOT}/auth_service.py --port ${AUTH_PORT} --db-port ${DB_PORT}
<~10s? ^listening on ${AUTH_PORT}$
}
}
effect SeededAuth {
expect DB_PORT, AUTH_PORT
start Auth as auth
expose auth.service as service
shell seeder {
log("create seed database users")
http_request(200, url(AUTH_PORT, "/register"), "POST", "{\"login\": \"alice\", \"password\": \"alice_secret\"}")
http_request(200, url(AUTH_PORT, "/register"), "POST", "{\"login\": \"bob\", \"password\": \"bob_secret\"}")
http_request(200, url(AUTH_PORT, "/register"), "POST", "{\"login\": \"eva\", \"password\": \"eva_secret\"}")
}
}
pure fn url(port, path) {
"http://localhost:${port}${path}"
}
Auth declares expect DB_PORT, AUTH_PORT — both must be provided by whoever starts it. Note that it does not need to pass the expected environment variables explicitly: these are passed inside the inherited enviroment overlay. This works as long as the expected environment variables in different overlays have the same name.
SeededAuth declares the same expects and passes them through to Auth. Each effect in the chain declares what it requires and forwards what its dependencies need.
Update service/tasks.relux:
import api/http
import jq
import service/db { url as db_url, Db }
import service/auth { SeededAuth }
effect Tasks {
expect DB_PORT, AUTH_PORT, TASKS_PORT
start Db
start SeededAuth
expose service
shell setup {
log("create the tasks database")
http_request(200, db_url(DB_PORT, "/db/tasks"), "POST")
}
shell service {
!? ^error:
> ${__RELUX_SUITE_ROOT}/task_service.py --port ${TASKS_PORT} --db-port ${DB_PORT} --auth-port ${AUTH_PORT}
<~10s? ^listening on ${TASKS_PORT}$
}
}
effect SeededTasks {
expect DB_PORT, AUTH_PORT, TASKS_PORT
start Tasks as tasks
expose tasks.service as service
shell seeder {
log("login as alice")
let response_filename = http_request(200, url(TASKS_PORT, "/login"), "POST", "{\"login\": \"alice\", \"password\": \"alice_secret\"}")
let token = jq_extract(response_filename, ".token")
log("auth token: ${token}")
log("create a task")
let response_filename = http_request_authorized(200, url(TASKS_PORT, "/tasks"), "POST", token, "{\"title\": \"buy milk\", \"status\": \"todo\"}")
let task_id = jq_extract(response_filename, ".id")
jq_match_query(response_filename, ".title", "^buy milk$")
log("login as bob")
let response_filename = http_request(200, url(TASKS_PORT, "/login"), "POST", "{\"login\": \"bob\", \"password\": \"bob_secret\"}")
let token = jq_extract(response_filename, ".token")
log("auth token: ${token}")
log("create a task")
let response_filename = http_request_authorized(200, url(TASKS_PORT, "/tasks"), "POST", token, "{\"title\": \"buy milk\", \"status\": \"todo\"}")
let task_id = jq_extract(response_filename, ".id")
jq_match_query(response_filename, ".title", "^buy milk$")
}
}
pure fn url(port, path) {
"http://localhost:${port}${path}"
}
SeededTasks follows the same layering pattern we used for auth: it starts Tasks, logs in as two users, and creates a task for each. Tests that need pre-existing tasks use start SeededTasks instead of setting up data themselves.
Each port is allocated once at the test level and flows down through overlays. Effect deduplication still works – two start Db { DB_PORT } with the same evaluated value share one instance.
Updating the tests
Each test now passes ports when starting effects. Here is the updated tasks/smoke.relux:
import api/http
import jq
import service/tasks { url as tasks_url, Tasks }
test "task CRUD" {
"""
Log in, create a task, read it back, update it, and delete it.
"""
let db_port = available_port()
let auth_port = available_port()
let tasks_port = available_port()
start Tasks {
DB_PORT = db_port
AUTH_PORT = auth_port
TASKS_PORT = tasks_port
}
shell client {
log("login as alice")
let response_filename = http_request(200, tasks_url(tasks_port, "/login"), "POST", "{\"login\": \"alice\", \"password\": \"alice_secret\"}")
let token = jq_extract(response_filename, ".token")
log("auth token: ${token}")
log("create a task")
let response_filename = http_request_authorized(200, tasks_url(tasks_port, "/tasks"), "POST", token, "{\"title\": \"buy milk\", \"status\": \"todo\"}")
let task_id = jq_extract(response_filename, ".id")
jq_match_query(response_filename, ".title", "^buy milk$")
log("read it back")
let response_filename = http_request_authorized(200, tasks_url(tasks_port, "/tasks/${task_id}"), token)
jq_match_query(response_filename, ".title", "^buy milk$")
jq_match_query(response_filename, ".status", "^todo$")
log("update the status")
let response_filename = http_request_authorized(200, tasks_url(tasks_port, "/tasks/${task_id}"), "PUT", token, "{\"status\": \"done\"}")
jq_match_query(response_filename, ".status", "^done$")
log("delete it")
let response_filename = http_request_authorized(200, tasks_url(tasks_port, "/tasks/${task_id}"), "DELETE", token)
jq_match_query(response_filename, ".deleted", "^${task_id}$")
}
}
The three available_port() calls at the top allocate unique ports. The overlay blocks on start pass them down. The test body passes tasks_port to tasks_url() so the curl commands target the right port.
The same pattern applies to the db and auth test files. Each test allocates the ports it needs and passes them through overlays.
Error-path tests
The SeededTasks effect pays off in error-path tests. Create tasks/errors.relux:
import api/http
import jq
import service/tasks { url as tasks_url, Tasks, SeededTasks }
# skip if SMOKE
test "unauthorized without token" {
"""
Verify requests without a Bearer token return 401.
"""
let db_port = available_port()
let auth_port = available_port()
let tasks_port = available_port()
start Tasks {
DB_PORT = db_port
AUTH_PORT = auth_port
TASKS_PORT = tasks_port
}
shell client {
http_request(401, tasks_url(tasks_port, "/tasks"), "POST", "{\"title\": \"nope\"}")
}
}
# skip if SMOKE
test "get nonexistent task" {
"""
Verify reading a task that does not exist returns 404.
"""
let db_port = available_port()
let auth_port = available_port()
let tasks_port = available_port()
start SeededTasks {
DB_PORT = db_port
AUTH_PORT = auth_port
TASKS_PORT = tasks_port
}
shell client {
log("login as alice")
let response_filename = http_request(200, tasks_url(tasks_port, "/login"), "POST", "{\"login\": \"alice\", \"password\": \"alice_secret\"}")
let token = jq_extract(response_filename, ".token")
log("get a task that does not exist")
let response_filename = http_request_authorized(404, tasks_url(tasks_port, "/tasks/999"), token)
jq_match_query(response_filename, ".error", "^task 999 not found$")
}
}
Notice the different effect choices: the unauthorized test only starts Tasks – no seeded data, just a running service to reject the request. The nonexistent task test starts SeededTasks because it logs in as alice, who must exist.
Running in parallel
Enable parallel execution in Relux.toml:
[run]
jobs = 4
Or pass it on the command line:
relux run -j 4
Each test gets its own set of ports. Four tests running simultaneously means four separate service stacks, each on different ports, with no collisions. The effect graph is resolved per-test, and deduplication operates within a single test’s dependency tree – not across tests.
CI readiness
A few flags make the suite CI-friendly:
Timeout multiplier. Tolerance timeouts (~) scale with the -m flag. CI machines are often slower, so double the timeouts:
relux run -m 2.0
Assertion timeouts (@) are never scaled – they test hard time bounds that should hold on any machine.
Fail-fast vs. all. For local development, stop at the first failure:
relux run --strategy fail-fast
For CI, run everything to get the full picture:
relux run --strategy all
Flaky markers. If a test is inherently timing-sensitive, mark it so relux retries before reporting failure:
# flaky
test "sometimes slow" {
"""
Verify the service responds under load.
"""
...
}
CI-only tests. Some tests only make sense in CI:
# run if CI
test "full integration" {
"""
Run the full integration suite against the staging environment.
"""
...
}
What we built
The suite started in chapter 0 as an empty project. Over five chapters it grew into a parallel integration test suite for three interconnected services:
- Chapter 0 – project setup and first empty test
- Chapter 1 – testing the database with inline curl commands
- Chapter 2 – extracting reusable HTTP and jq libraries
- Chapter 3 – effects for declarative infrastructure setup
- Chapter 4 – shared dependencies and effect deduplication
- Chapter 5 – dynamic ports, overlays, and parallel execution
The final architecture separates concerns cleanly:
project/
├── Relux.toml
├── db_service.py
├── auth_service.py
├── task_service.py
└── relux/
├── tests/
│ ├── db/
│ │ ├── smoke.relux
│ │ └── errors.relux
│ ├── auth/
│ │ ├── smoke.relux
│ │ └── errors.relux
│ └── tasks/
│ ├── smoke.relux
│ └── errors.relux
└── lib/
├── jq.relux
├── api/
│ └── http.relux
└── service/
├── db.relux
├── auth.relux
└── tasks.relux
Test files say what to test. Library files say how to talk to services. Effect modules say how to start them. Environment overlays make everything parallel-safe.
The complete working example project is available at project/.