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_admin()
Simulates authenticated admin
with_token(token)
Sets a Bearer authorization header
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_admin()
Simulates an authenticated administrator.
as_admin()
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("[email protected]", "secretpassword")
response = get("/dashboard")
assert_eq(res_status(response), 200)
logout()
Destroys the current session.
login("[email protected]", "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()
Returns rendered template path
flash()
Returns flash messages
Complete Example
describe("PostsController", fn() {
before_each(fn() {
as_guest()
})
test("creates post with valid data", fn() {
login("[email protected]", "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
Soli provides assertion functions for writing tests with expressive syntax:
assert_equal(expected, actual, message)
Asserts that two values are equal.
assert_equal(42, result, "should return 42")
assert_equal("hello", str, "string should match")
assert_true(value, message)
Asserts that a value is true.
assert_true(user.is_active, "user should be active");
assert_false(value, message)
Asserts that a value is false.
assert_false(user.is_blocked, "user should not be blocked");
assert_contains(haystack, needle, message)
Asserts that a collection contains a specific value.
assert_contains(users, "admin", "should contain admin user");
assert_nil(value, message)
Asserts that a value is nil (null).
assert_nil(result.error, "should have no error");
assert_not_nil(value, message)
Asserts that a value is not nil.
assert_not_nil(user.id, "user should have an id");
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": "[email protected]"
})
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
Tests are isolated using database transactions:
describe("User model", fn() {
test("creates user", fn() {
with_transaction(fn() {
user = Factory.create("user", hash("name": "Test"))
expect(User.count).to_equal(1)
expect(user.name).to_equal("Test")
})
})
})
Factory Pattern
# Define factories
Factory.define("user", hash(
"email": "[email protected]",
"name": "Test User"
))
Factory.define("post", hash(
"title": "Test Post",
"content": "Content here"
))
# Use factories
user = Factory.create("user")
post = Factory.create("post", hash("title": "Custom Title"))
users = Factory.create_list("user", 5);
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": "[email protected]"
}
]
)
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.
Parallel Execution
Tests run in parallel by default:
soli test # Parallel (default)
soli test --jobs=4 # 4 workers
soli test --jobs=1 # Sequential (debug)
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%