Testing
Comprehensive testing guide for Soli MVC applications. Includes unit testing and end-to-end controller testing.
E2E Controller Testing
Rails-like end-to-end testing framework for Soli MVC applications. Test your controllers with real HTTP requests.
The E2E testing framework provides a comprehensive set of helpers for testing your Soli controllers with real HTTP requests. Built on a test server that runs alongside your test suite, it enables you to write integration tests that simulate actual browser requests and verify controller responses, sessions, and view data.
This framework follows conventions inspired by RSpec Rails testing patterns, making it familiar to developers coming from Ruby on Rails backgrounds while providing the safety and expressiveness of Soli's type system.
Basic Test Structure
Every E2E test file follows the same structure using Soli's test DSL. The framework provides functions for grouping tests, setting up test data, making HTTP requests, and asserting expected outcomes.
describe("HomeController", fn() {
test("GET /up returns UP status", fn() {
response = get("/up")
assert_eq(res_status(response), 200)
assert_eq(res_body(response), "UP")
})
})
Running Tests
Execute your E2E tests using the Soli test runner:
soli test tests/builtins/controller_integration_spec.sl
soli test tests/builtins
Request Helpers
Request helpers enable you to make HTTP requests to your controllers from within tests. These functions interact with the test server running on a random available port.
HTTP Method Functions
get(path)
GET request without modifying server state
post(path, data)
POST with body to create resources
put(path, data)
PUT replacement of existing resources
patch(path, data)
PATCH partial updates
delete(path)
DELETE resources
head(path)
HEAD request without body
response = get("/posts")
assert_eq(res_status(response), 200)
posts = res_json(response)
assert_gt(len(posts), 0)
response = post("/posts", {
"title": "New Post",
"content": "Hello World"
})
assert_eq(res_status(response), 201)
Custom Headers
Add custom headers to your requests:
set_header("X-Request-ID", "test-123")
set_header("X-Custom-Header", "custom-value")
response = get("/api/data")
clear_headers()
Authentication Headers
with_token("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
response = get("/api/protected")
clear_authorization()
Cookie Management
set_request_cookie("session_id", "abc123session")
response = get("/dashboard")
clear_cookies()
Response Helpers
Response helpers inspect HTTP responses returned by your controllers.
Status Codes
res_status(response)
Returns HTTP status code as integer
res_ok(response)
Checks for 2xx status codes
res_client_error(response)
Checks for 4xx status codes
res_server_error(response)
Checks for 5xx status codes
res_not_found(response)
Checks for 404 status
res_unauthorized(response)
Checks for 401 status
Response Body
body = res_body(response)
assert_contains(body, "expected text")
response = post("/users", {"name": "John"})
user = res_json(response)
assert_eq(user["name"], "John")
Response Headers
content_type = res_header(response, "Content-Type")
assert_contains(content_type, "application/json")
headers = res_headers(response)
assert_hash_has_key(headers, "Content-Type")
Redirects
assert(res_redirect(response))
location = res_location(response)
assert_eq(location, "/expected/path")
Session Helpers
Session helpers manage authentication state and session data during tests. These functions simulate user login/logout and authentication checks.
Authentication State Management
as_guest()
Clears all authentication state
as_user(user_id)
Simulates logged-in user with given id
as_user(user_id, opts)
User id + session opts (e.g. {"role": "admin"})
as_role(role)
First user with matching role (single-table)
sign_in(name, id?)
Separate-collection auth (e.g. Admin model)
as_admin()
Shortcut for as_user(1)
with_token(token)
Sets a Bearer authorization header
Which helper to use?
- Single-table role-column (one
userscollection, role string field): useas_role("admin")oras_user(id, {"role": "admin"}). - Separate-collection (distinct
User,Admin, ... models): usesign_in("admin", id)/sign_in("admin"). - Just need a logged-in user (no role checks):
as_user(id)is the lightest path. - Anything else (non-conventional session keys): fall back to
with_session({...}).
as_guest()
Clears all authentication state, simulating an unauthenticated user.
before_each(fn()
as_guest()
end)
as_user(user_id)
Simulates a logged-in regular user with the specified ID.
as_user(42)
response = get("/profile")
assert_eq(res_status(response), 200)
as_user(user_id, options)
Writes the user id and an options hash into the server-side session store. Use this when middleware reads more than user_id (e.g. a role field on the single-table users collection). Any keys work — extend as your auth needs grow.
as_user(42, {"role": "admin"})
response = get("/admin/dashboard")
# Any keys work — extend as your auth needs grow:
as_user(42, {"role": "admin", "tenant": "acme"})
as_role(role)
Looks up the first user with role == <value> in the users collection and signs in as that record. The role is also stored in the session so middleware reading req.session["role"] sees it on the next request. Errors if no matching user exists — seed one in before_each, or pass an explicit id with as_user(id, {"role": "admin"}).
as_role("admin")
response = get("/admin/dashboard")
assert_eq(res_status(response), 200)
sign_in(resource_name, id?)
For separate-collection auth (Devise-style: distinct User / Admin models with their own session keys). Writes session.{resource_name}_id. Without an explicit id, looks up the first record of the matching model ("admin" → Admin, "blog_post" → BlogPost).
sign_in("admin", 5) # session.admin_id = 5
sign_in("admin") # session.admin_id = Admin.first.id
sign_in("user", 42) # session.user_id = 42
sign_in("staff", 7) # session.staff_id = 7
Apps with non-conventional session keys (e.g. current_admin_id) should keep using with_session({"current_admin_id": 5}).
as_admin()
Zero-arg convenience equivalent to as_user(1) — logs in as whichever record has id = 1 in your users table. There is no built-in role or permission system: by convention, scaffolded apps seed user_id 1 as the administrator, but your controllers and middleware are still responsible for enforcing admin authorization.
as_admin() # same as as_user(1)
response = get("/admin/dashboard")
assert_eq(res_status(response), 200)
with_token(token)
Sets a Bearer authorization header for subsequent requests.
with_token("your-jwt-token-here")
response = get("/api/protected")
Login and Logout
login(email, password)
Performs a login request and maintains session state via cookies.
login("user@example.com", "secretpassword")
response = get("/dashboard")
assert_eq(res_status(response), 200)
logout()
Destroys the current session.
login("user@example.com", "password")
logout()
response = get("/dashboard")
assert_eq(res_status(response), 302) # Redirect to login
Session Inspection
signed_in()
Returns true if authenticated
signed_out()
Returns true if not authenticated
current_user()
Returns authenticated user data
signed_in?() / signed_out?()
Predicate aliases (Ruby-style)
signed_in()
Returns true when an authenticated user is present (test marker or a non-empty session_id cookie). Also available as signed_in?().
as_guest()
assert_not(signed_in())
as_user(1)
assert(signed_in())
signed_out()
Returns true when no authentication is in effect. Also available as signed_out?().
as_guest()
assert(signed_out())
current_user()
Returns the currently authenticated user as a hash, or null if no user is signed in.
as_user(42)
user = current_user()
assert_eq(user["id"], 42)
Session Creation and Destruction
create_session(user_id)
Creates a session cookie for the specified user and returns the new session id.
session_id = create_session(42)
assert_not_null(session_id)
destroy_session()
Clears the current session and any test-user marker.
create_session(42)
destroy_session()
assert(signed_out())
Custom Session Data
with_session(data)
Writes arbitrary key/value pairs into the server-side session and sets a matching session_id cookie. Subsequent requests in the same test see the data via session_get(...) on the server.
with_session({
"user_id": 42,
"role": "editor"
})
response = get("/dashboard")
assert_eq(res_status(response), 200)
Test-runner only. with_session writes to the live session store and is gated to processes started by soli test (or test-server children spawned by it). Calling it from soli run, the REPL, a job, or a soli serve --dev script raises with_session is a test-only helper; … so an attacker who can inject Soli code into one of those contexts cannot forge an authenticated session.
Assigns Helpers
Assigns helpers inspect data passed to views during template rendering.
assigns()
Returns all assigns as a hash
assign(key)
Retrieves specific assign value
view_path()
Rendered template path, e.g. "posts/index.html" ("" if none)
render_template()
Did the response render a view template? (render_template?() is an alias)
Large locals. View locals ride back from the test server in a response header. When they are very large (≈48 KB+ of serialized JSON), assigns() degrades to a keys-only view — every top-level key is present (so assert_hash_has_key still works) but its value is null.
Query Assertions (N+1 detection)
Every response carries the AQL queries the request executed — recorded from the same query log that powers the dev bar's N+1 badge. Assert against it to catch a loop that should have been batched, or to hold an endpoint to a query budget.
assert_no_n_plus_one(res)
Fails if any AQL template fired 2+ times (a loop that should be batched)
assert_query_count(res, n)
Asserts the request ran exactly n queries
assert_max_queries(res, n)
Asserts the request ran at most n queries
assert_no_ungrouped_reads(res)
Fails if 3+ distinct reads each cost a round-trip outside a grouped block
res["query_count"] / res["n_plus_one"] / res["ungrouped_reads"]
Raw count, the {query, count} N+1 groups, and the {query} coalescing candidates behind the assertions
Two different problems
N+1 detection fingerprints by query template, so it only ever fires on a repeated one. Three unrelated reads are three distinct templates with a count of one each — invisible to assert_no_n_plus_one, yet exactly the shape grouped exists for. The two assertions cover opposite halves:
- Same read, N times →
assert_no_n_plus_one; fix withincludes(...). - N different reads, once each →
assert_no_ungrouped_reads; fix withgrouped(fn() { ... }).
Neither can prove reads are independent: User.find(id) then a query on user._key is genuinely two round-trips, and assert_no_ungrouped_reads will flag it. Use it on actions whose reads really are unrelated.
test("posts index does not N+1", fn() {
response = get("/posts")
assert_eq(res_status(response), 200)
assert_no_n_plus_one(response) # fails if the view loops a per-row query
assert_max_queries(response, 3) # hold the endpoint to a query budget
})
Threshold. A template counts as N+1 at 2 repetitions, matching the dev bar — HABTM/through lookups start at two. assert_no_ungrouped_reads trips at 3 distinct one-off reads. If an endpoint legitimately repeats a query, bound it with assert_max_queries instead of assert_no_n_plus_one. These keys are populated by the --dev test server soli test runs, so they're always present in request specs.
Counts are production counts. The test server runs with --dev so the query log is populated, but grouped(fn() { ... }) does coalesce there — unlike interactive --dev, which keeps the reads separate for a readable query log. Specs therefore exercise the production path, and a grouped action reports one query, not one per read. Without this, the coalescing path would never run during a suite and a query budget would be measured against a shape production never uses.
Fail the whole suite: --fail-on-n1
An assertion only catches an N+1 where you remember to write it. To make the entire suite an N+1 tripwire — without touching a single spec — run:
soli test --fail-on-n1
Every get() / post() / request() that triggers an N+1 fails its test automatically, using the exact same detection and message as assert_no_n_plus_one. Clean and uninstrumented responses are untouched, so it never fails a spec spuriously. It composes with --jobs, --coverage, and the rest — wire it into CI to catch a query regression the moment it lands, even in specs that predate the check.
Complete Example
describe("PostsController", fn() {
before_each(fn() {
as_guest()
})
test("creates post with valid data", fn() {
login("author@example.com", "password123")
response = post("/posts", {
"title": "New Post Title",
"body": "Post content here"
})
assert_eq(res_status(response), 201)
result = res_json(response)
assert_not_null(result["id"])
})
test("rejects unauthenticated request", fn() {
response = post("/posts", {"title": "Test"})
assert_eq(res_status(response), 302)
})
test("shows single post", fn() {
response = get("/posts/1")
assert_eq(res_status(response), 200)
post = res_json(response)
assert_eq(post["title"], "First Post")
})
})
Best Practices
Test Organization
Structure your tests hierarchically using describe() blocks. Group tests by controller, then by action, then by concern.
Before and After Hooks
Use before_each() and after_each() to set up and clean up test state. Always reset authentication state between tests.
Test Isolation
Each test should be independent and not rely on the state created by other tests.
Test DSL
Soli's testing framework provides a BDD-style DSL for organizing and writing tests:
Available Functions
describe(name, def)
Group related tests
context(name, def)
Group tests with conditions
test(name, def)
Define a test case
before_each(def)
Setup before each test
after_each(def)
Teardown after each test
pending()
Skip a test
Assertions
Assertions are builtins of the test runner — available inside any test(...) body, with nothing to import. Each one raises on failure (the runner marks the test failed and prints the file and line) and returns 1 on success, bumping the run's assertion counter. They take values only: there is no message parameter, because the failure already points at the line. When a check needs prose, throw it yourself.
assert(value)
Passes when value is the boolean true. A non-boolean is an error, not a failure, so a typo cannot quietly pass — use assert_not_null for presence.
assert(order["paid"])
assert_eq(a, b)
Passes when the two values are equal. assert_ne(a, b) is the inverse.
assert_eq(response["status"], 200)
assert_ne(user["_key"], other["_key"])
assert_null(value)
Passes when value is null. assert_not_null(value) is the inverse.
assert_null(order["cancelled_at"])
assert_not_null(user["_key"])
assert_gt(a, b) / assert_lt(a, b)
Ordering comparisons: a > b and a < b.
assert_gt(total, 0)
assert_lt(elapsed_ms, 500)
assert_match(string, pattern)
Passes when the regex pattern matches string. Both arguments must be strings.
assert_match(slug, "^[a-z0-9-]+$")
assert_contains(collection, item)
Passes when an array contains item, or when a string contains that substring.
assert_contains(["draft", "open"], order["state"])
assert_contains(response["body"], "Saved")
assert_no_n_plus_one(response)
Asserts the request that produced response triggered no N+1 query (the same AQL template firing 2+ times). Same detection as the dev bar's badge.
response = get("/posts")
assert_no_n_plus_one(response)
assert_query_count(response, n) /
assert_max_queries(response, n)
Asserts the request ran exactly (or at most) n AQL queries — a query budget for the endpoint.
response = get("/dashboard")
assert_query_count(response, 3) # exactly three
assert_max_queries(response, 5) # at most five
Assertion Result
All assertion functions return a result hash:
{
"passed": true,
"message": "test description",
"expected": value_that_was_expected,
"actual": value_that_was_actual
}
Expect Syntax (Alternative)
expect(value).to_equal(expected)
expect(value).to_be(expected)
expect(value).to_not_equal(other)
expect(value).to_be_null()
expect(value).to_not_be_null()
expect(value).to_contain("substring")
Factory Functions
Factory.define(name, data)
Define a factory with default data.
Factory.define("user", {
"name": "Test User",
"email": "test@example.com"
})
Factory.create_with(name, overrides)
Create an instance with custom overrides.
admin = Factory.create_with("user", { "role": "admin" })
Factory.create_list(name, count)
Create multiple instances
Factory.sequence(name)
Get auto-incrementing number
Factory.clear
Clear all factories
Database Testing
Transaction Rollback
Worker databases are dropped when a suite finishes, so a machine running many projects doesn't accumulate one empty *_spec database per worker per app. Set SOLI_TEST_KEEP_DB=1 to keep them and truncate their collections instead: dropping is serialised server-side and the next run has to recreate the schema, which costs little on a small app but grows with the collection count. Wrap per-example writes in with_transaction — it always rolls back, even on success (unlike Model.transaction):
describe("User model", fn() {
test("creates user", fn() {
Factory.define("user", {"email": "tx@test.com", "name": "Test"})
Factory.bind("user", User)
with_transaction(fn() {
user = Factory.insert("user")
assert_eq(User.count(), 1)
assert_eq(user.name, "Test")
})
assert_eq(User.count(), 0)
})
})
Time Travel
Pin datetime_now() for cron, TTL, and expiration specs. Cleared automatically before each test.
freeze_time(1_700_000_000)
travel_to("2024-06-15")
unfreeze_time()
Factory Pattern
Factory.define("user", {
"email": "user#{n}@example.com",
"name": "Test User"
})
Factory.define("post", fn() {
return {"title": "Post #{Factory.sequence("post")}"}
})
user = Factory.create("user")
post = Factory.create_with("post", {"title": "Custom Title"})
users = Factory.create_list("user", 5)
Factory.bind("user", User)
persisted = Factory.insert("user")
Password hashing under test
soli test sets
SOLI_ARGON2_FAST=1 for itself and for the servers it
starts. New password hashes are then made at 4 MiB and one pass instead of the
RFC 9106 default of 19 MiB and two — roughly 2 ms instead of 20.
A suite pays the full price twice per authenticated test: once creating the fixture user, once verifying at login. On one real application — 1 013 logins and as many fixture users — that was 38 of the 106 seconds the run took, and the average login went from 62 ms to 29 ms.
Two things keep this from reaching anything real:
- It changes hashing only.
Crypto.argon2_verifyreads the cost from the stored hash (Argon2 recordsm,tandpin the PHC string it returns), so a password hashed in production keeps its full cost however the variable is set, and one database may hold both kinds. - Only
soli testsets it. It is read once, and only1ortruecount — an emptySOLI_ARGON2_FAST=is off.
Never set it on a machine that stores real passwords: a hash made under it carries its weakness for as long as it is stored.
Mock Database Queries
For integration tests without a real database, use Model.mock_query_result() to intercept queries and return predefined data:
describe("User queries", fn() {
before_each(fn() { User.clear_mocks() })
after_each(fn() { User.clear_mocks() })
test("finds user by id", fn() {
User.mock_query_result(
"FOR doc IN users FILTER doc._key == @key RETURN doc",
[
{
"_key": "123",
"_id": "default:users/123",
"name": "Alice",
"email": "alice@example.com"
}
]
)
user = User.find("123")
assert_eq(user.name, "Alice")
})
test("includes returns correct class for relations", fn() {
# Mock the parent query
Contact.mock_query_result(
"FOR doc IN contacts RETURN doc",
[
{
"_key": "c1",
"_id": "default:contacts/c1",
"name": "Bob",
"organisation_id": "default:organisations/o1"
}
]
)
# Mock the included relation query
Organisation.mock_query_result(
"FOR doc IN organisations FILTER doc._key IN @keys RETURN doc",
[
{
"_key": "o1",
"_id": "default:organisations/o1",
"name": "Acme Corp"
}
]
)
contact = Contact.includes("organisation").first
org = contact.organisation
# Verify the relation has the correct class (not Contact)
assert_eq(org.class_name, "Organisation")
assert_eq(org.name, "Acme Corp")
})
})
Model.mock_query_result(query, results)
Register mock data for an AQL query
Model.clear_mocks()
Remove all registered mocks
Note: Include relations require mocking both the parent and related queries. The _id field (e.g., "default:organisations/o1") determines the correct class for included documents.
Mock HTTP Services
When the code under test calls another service — a payment API, an OpenID provider, a webhook receiver — start the local mock server and script what it answers:
let port = mock_http_server_start()
let base = "http://127.0.0.1:" + port.to_s
# Any method on this path answers 200 with this body. The query string is
# ignored; scripting the same path again replaces the answer.
mock_http_route("/idp/.well-known/openid-configuration", 200, json_stringify({
"issuer": base + "/idp",
"token_endpoint": base + "/idp/token",
"jwks_uri": base + "/idp/keys"
}))
mock_http_route("/idp/token", 400, "{\"error\":\"invalid_grant\"}")
# ... drive the app ...
# What the app SENT is often the thing to prove.
let sent = mock_http_last_body("/idp/token")
assert(sent.includes?("code_verifier="))
- The server is a real socket on
127.0.0.1, so the app’s test server — a separate process — reaches it exactly as it would reach the real service. Point the app atbasethrough whatever configuration it reads. - A path that was never scripted answers
200with{"ok":true}. mock_http_last_body(path)returns the body of the last request on that path (up to 64 KiB), ornilif none arrived.- Routes are shared by the whole spec file. Give each test its own path prefix so one test’s answers never reply in another’s place.
- Test-only: none of the three exists in a served app.
Parallel Execution
Tests run in parallel by default:
soli test # Parallel (default)
soli test --jobs=4 # 4 workers
soli test --jobs=1 # Sequential (debug)
Live Progress
While the suite runs, each worker gets a row and the aggregate bar at the bottom carries the running totals. A row names its spec by its path under the tested directory, and a narrow terminal shortens the folders before the filename:
W0 [████████░░░░░░] ⠇ models/users_spec 2.4s 6
W1 [██████░░░░░░░░] ⠹ controllers/buildings/pages_spec 10.0s 4
[██████████░░░░░░░░░░░░░░░░░░░░] ⠇ 41/158 1 204 tests · 6 018 assertions
The three counters advance at different rates on purpose. 41/158 counts files, and only moves when one finishes. tests counts each test(...) block as it ends, and assertions counts every assertion as it fires — both are live, so a spec file that runs for twenty seconds visibly contributes while it is still running rather than landing all at once at the end. A failing test is called out in red on the bar as soon as it fails, before its file has finished.
The summary repeats the three, and says which unit each line counts:
❌
156 files passed, 2 failed (158 total)
1 204 tests, 2 failed
6 018 assertions
Time: 41.2s
Note: A file that panics outright (rather than failing an assertion) loses its own assertion count, so the summary reports what the per-file tally saw. The test count has no second source — it is counted as each block ends, so the tests a panicking file ran before it died are still there.
Coverage Reporting
Generate coverage reports for your tests:
Coverage: 87.5% (1250/1428 lines) ✓
src/controllers/users.sl ▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░ 94.2%
src/models/user.sl ▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░ 91.1%
src/controllers/posts.sl ▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░ 78.5%
--coverage
Generate coverage report
--coverage=html
HTML report
--coverage=json
JSON for CI
--coverage-min=80
Fail if < 80%