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/