Testing
Shaperail projects use standard Rust testing tools. The framework generates testable code and provides patterns for unit tests, integration tests, and end-to-end tests against a real database.
Running tests
The shaperail test command
The simplest way to run your test suite:
shaperail test
This wraps cargo test and passes through any additional arguments:
# Run a specific test by name
shaperail test -- test_create_user
# Run tests in a specific module
shaperail test -- --test api_integration
# Show output from passing tests
shaperail test -- --nocapture
# Run only tests matching a pattern
shaperail test -- "test_validation"
Using cargo test directly
You can also call cargo test with full control:
# Run all tests in the workspace
cargo test --workspace
# Run tests for a specific crate
cargo test -p my-app
# Run a single test file
cargo test --test api_integration
# Run tests with release optimizations
cargo test --release
Pre-commit checklist
Always run these before committing:
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --check
Unit testing controllers
Controllers are async functions that take &mut Context. To unit test them, construct a Context with the fields your controller reads, call the function, and assert the result.
Testing a before-controller
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use shaperail_runtime::handlers::controller::Context;
fn mock_context(input: serde_json::Value) -> Context {
Context {
input: input.as_object().unwrap().clone(),
data: None,
user: None,
pool: test_pool(), // see "Testing with a real database" below
headers: std::collections::HashMap::new(),
response_headers: vec![],
tenant_id: None,
}
}
#[tokio::test]
async fn test_normalize_name_trims_whitespace() {
let mut ctx = mock_context(json!({
"name": " Alice ",
"role": "member"
}));
let result = normalize_name(&mut ctx).await;
assert!(result.is_ok());
assert_eq!(ctx.input["name"], "Alice");
}
#[tokio::test]
async fn test_normalize_name_does_nothing_when_name_missing() {
let mut ctx = mock_context(json!({
"role": "member"
}));
let result = normalize_name(&mut ctx).await;
assert!(result.is_ok());
assert!(ctx.input.get("name").is_none());
}
}
Testing an after-controller
After-controllers receive ctx.data containing the database result:
#[tokio::test]
async fn test_enrich_response_adds_display_name() {
let mut ctx = mock_context(json!({}));
ctx.data = Some(json!({
"id": "abc-123",
"name": "Alice",
"role": "admin"
}));
let result = enrich_response(&mut ctx).await;
assert!(result.is_ok());
let data = ctx.data.unwrap();
assert_eq!(data["display_name"], "Alice (admin)");
}
Testing controller error paths
Controllers return Err(ShaperailError::...) to halt the request:
use shaperail_core::ShaperailError;
#[tokio::test]
async fn test_set_created_by_rejects_unauthenticated() {
let mut ctx = mock_context(json!({"title": "hello"}));
ctx.user = None;
let result = set_created_by(&mut ctx).await;
assert!(result.is_err());
match result.unwrap_err() {
ShaperailError::Unauthorized => {}
other => panic!("Expected Auth error, got: {:?}", other),
}
}
Testing with an authenticated user
use shaperail_runtime::auth::AuthenticatedUser;
#[tokio::test]
async fn test_admin_only_fields_strips_role_for_non_admin() {
let mut ctx = mock_context(json!({
"name": "Bob",
"role": "admin",
"org_id": "org-1"
}));
ctx.user = Some(AuthenticatedUser {
id: "user-1".into(),
role: "member".into(),
tenant_id: None,
});
let result = admin_only_fields(&mut ctx).await;
assert!(result.is_ok());
assert!(ctx.input.get("role").is_none(), "role should be removed");
assert!(ctx.input.get("org_id").is_none(), "org_id should be removed");
assert_eq!(ctx.input["name"], "Bob", "name should be preserved");
}
Integration testing endpoints
Use actix_web::test to spin up a test server with real handlers and make HTTP requests against it. This is the same pattern used by Shaperail’s own test suite.
Basic setup
use actix_web::{test as actix_test, web, App};
use serde_json::json;
use shaperail_runtime::handlers::crud::AppState;
use shaperail_runtime::handlers::routes::register_resource;
use std::sync::Arc;
/// Build an AppState for testing (no auth, no cache, no jobs).
fn make_test_state(pool: sqlx::PgPool) -> Arc<AppState> {
Arc::new(AppState {
pool,
resources: vec![],
stores: None,
controllers: None,
jwt_config: None,
cache: None,
event_emitter: None,
job_queue: None,
metrics: None,
wasm_runtime: None,
event_bus: tokio::sync::broadcast::channel(16).0,
})
}
Full CRUD test
#[sqlx::test(migrations = "tests/fixtures/migrations")]
async fn test_full_crud_cycle(pool: sqlx::PgPool) {
let resource = test_resource(); // your ResourceDefinition
let state = make_test_state(pool);
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(state.clone()))
.configure(|cfg| register_resource(cfg, &resource, state.clone())),
)
.await;
let org_id = uuid::Uuid::new_v4().to_string();
// CREATE
let req = actix_test::TestRequest::post()
.uri("/v1/users")
.set_json(json!({
"email": "alice@example.com",
"name": "Alice",
"role": "admin",
"org_id": org_id
}))
.to_request();
let resp = actix_test::call_service(&app, req).await;
assert_eq!(resp.status(), 201);
let body: serde_json::Value = actix_test::read_body_json(resp).await;
let id = body["data"]["id"].as_str().expect("id in response");
assert_eq!(body["data"]["name"], "Alice");
// READ
let req = actix_test::TestRequest::get()
.uri(&format!("/v1/users/{id}"))
.to_request();
let resp = actix_test::call_service(&app, req).await;
assert_eq!(resp.status(), 200);
// UPDATE
let req = actix_test::TestRequest::patch()
.uri(&format!("/v1/users/{id}"))
.set_json(json!({"name": "Alice Updated"}))
.to_request();
let resp = actix_test::call_service(&app, req).await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = actix_test::read_body_json(resp).await;
assert_eq!(body["data"]["name"], "Alice Updated");
// DELETE
let req = actix_test::TestRequest::delete()
.uri(&format!("/v1/users/{id}"))
.to_request();
let resp = actix_test::call_service(&app, req).await;
assert_eq!(resp.status(), 204);
// Verify deleted
let req = actix_test::TestRequest::get()
.uri(&format!("/v1/users/{id}"))
.to_request();
let resp = actix_test::call_service(&app, req).await;
assert_eq!(resp.status(), 404);
}
Testing with authentication
Add a JWT config to AppState and include tokens in requests:
use shaperail_runtime::auth::jwt::JwtConfig;
fn make_auth_state(pool: sqlx::PgPool) -> Arc<AppState> {
let jwt = JwtConfig::new(
"test-secret-key-at-least-32-bytes-long!",
3600, // access token TTL
86400, // refresh token TTL
);
Arc::new(AppState {
pool,
jwt_config: Some(Arc::new(jwt)),
// ... other fields same as make_test_state
resources: vec![],
stores: None,
controllers: None,
cache: None,
event_emitter: None,
job_queue: None,
metrics: None,
wasm_runtime: None,
event_bus: tokio::sync::broadcast::channel(16).0,
})
}
#[sqlx::test(migrations = "tests/fixtures/migrations")]
async fn test_auth_rejects_wrong_role(pool: sqlx::PgPool) {
let resource = test_resource(); // endpoint requires auth: [admin]
let state = make_auth_state(pool);
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(state.clone()))
.configure(|cfg| register_resource(cfg, &resource, state.clone())),
)
.await;
// Generate a token with "viewer" role
let jwt = JwtConfig::new("test-secret-key-at-least-32-bytes-long!", 3600, 86400);
let token = jwt
.encode_access("user-1", "viewer")
.expect("generate token");
let req = actix_test::TestRequest::post()
.uri("/v1/users")
.insert_header(("Authorization", format!("Bearer {token}")))
.set_json(json!({
"email": "bob@example.com",
"name": "Bob",
"role": "member",
"org_id": uuid::Uuid::new_v4().to_string()
}))
.to_request();
let resp = actix_test::call_service(&app, req).await;
assert_eq!(resp.status(), 403, "Viewer should not access admin endpoint");
}
Testing validation errors
#[sqlx::test(migrations = "tests/fixtures/migrations")]
async fn test_validation_rejects_missing_required_field(pool: sqlx::PgPool) {
let resource = test_resource();
let state = make_test_state(pool);
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(state.clone()))
.configure(|cfg| register_resource(cfg, &resource, state.clone())),
)
.await;
// Missing required "name" field
let req = actix_test::TestRequest::post()
.uri("/v1/users")
.set_json(json!({
"email": "alice@example.com",
"role": "member",
"org_id": uuid::Uuid::new_v4().to_string()
}))
.to_request();
let resp = actix_test::call_service(&app, req).await;
assert_eq!(resp.status(), 422, "Missing field should return 422");
let body: serde_json::Value = actix_test::read_body_json(resp).await;
let errors = body["errors"].as_array().expect("errors array");
assert!(
errors.iter().any(|e| e["field"] == "name"),
"Error should mention the missing field"
);
}
Testing filters and pagination
#[sqlx::test(migrations = "tests/fixtures/migrations")]
async fn test_list_with_filters(pool: sqlx::PgPool) {
let resource = test_resource();
let state = make_test_state(pool);
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(state.clone()))
.configure(|cfg| register_resource(cfg, &resource, state.clone())),
)
.await;
let org_id = uuid::Uuid::new_v4().to_string();
// Insert users with different roles
for (email, role) in [
("admin@test.com", "admin"),
("member@test.com", "member"),
("viewer@test.com", "viewer"),
] {
let req = actix_test::TestRequest::post()
.uri("/v1/users")
.set_json(json!({
"email": email,
"name": "Test User",
"role": role,
"org_id": org_id
}))
.to_request();
actix_test::call_service(&app, req).await;
}
// Filter by role=admin
let req = actix_test::TestRequest::get()
.uri("/v1/users?filter%5Brole%5D=admin")
.to_request();
let resp = actix_test::call_service(&app, req).await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = actix_test::read_body_json(resp).await;
let data = body["data"].as_array().expect("data array");
assert!(!data.is_empty());
for item in data {
assert_eq!(item["role"], "admin");
}
}
Testing with a real database
Shaperail integration tests run against a real PostgreSQL instance. The #[sqlx::test] macro provides automatic transaction rollback and test isolation.
Docker setup
Start the dev database and Redis:
docker compose up -d
This starts PostgreSQL on port 5433 and Redis on port 6379. Set the environment:
export DATABASE_URL=postgresql://shaperail:shaperail@localhost:5433/shaperail_dev
export REDIS_URL=redis://localhost:6379
Or add these to your .env file (created by shaperail init).
Test database isolation
The #[sqlx::test] macro creates an isolated database for each test function. Each test runs in a transaction that is rolled back when the test completes, so tests never interfere with each other.
#[sqlx::test(migrations = "tests/fixtures/migrations")]
async fn test_something(pool: sqlx::PgPool) {
// `pool` is connected to an isolated DB with migrations applied.
// Anything written here is rolled back after the test.
}
Test migrations
Place test-specific migrations in tests/fixtures/migrations/. These create the tables your tests need without requiring your full app schema:
tests/
fixtures/
migrations/
01_create_test_users.sql
02_create_test_orders.sql
Example migration:
-- tests/fixtures/migrations/01_create_test_users.sql
CREATE TABLE IF NOT EXISTS test_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'member',
org_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
Seed data with shaperail seed
Load fixture YAML files into the database:
shaperail seed
This reads YAML files from the seeds/ directory and inserts them in a transaction. Seed files follow this format:
# seeds/users.yaml
resource: users
records:
- email: alice@example.com
name: Alice Admin
role: admin
org_id: "11111111-1111-1111-1111-111111111111"
- email: bob@example.com
name: Bob Member
role: member
org_id: "11111111-1111-1111-1111-111111111111"
Load a specific seed file:
shaperail seed seeds/users.yaml
For tests, insert data programmatically using the test helpers shown in the integration test examples above.
Test data builder pattern
Use a builder function to create consistent test fixtures:
fn user_payload(email: &str, name: &str, role: &str, org_id: &str) -> serde_json::Value {
serde_json::json!({
"email": email,
"name": name,
"role": role,
"org_id": org_id
})
}
// Usage in tests:
let org_id = uuid::Uuid::new_v4().to_string();
let payload = user_payload("test@example.com", "Test User", "member", &org_id);
Testing background jobs
Background jobs are enqueued to a Redis-backed queue. Test them in two layers: verify that endpoints enqueue the correct jobs, and test job handler functions in isolation.
Checking that jobs are enqueued
After calling an endpoint that declares jobs: [send_welcome_email], verify the job appears in the Redis queue:
use redis::AsyncCommands;
use shaperail_runtime::cache::create_redis_pool;
#[sqlx::test(migrations = "tests/fixtures/migrations")]
async fn test_create_enqueues_welcome_email_job(pool: sqlx::PgPool) {
let redis_pool = create_redis_pool(&redis_url()).expect("redis pool");
let state = make_state_with_jobs(pool, redis_pool.clone());
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(state.clone()))
.configure(|cfg| register_resource(cfg, &resource, state.clone())),
)
.await;
// Create a user (triggers jobs: [send_welcome_email])
let req = actix_test::TestRequest::post()
.uri("/v1/users")
.set_json(json!({
"email": "new@example.com",
"name": "New User",
"role": "member",
"org_id": uuid::Uuid::new_v4().to_string()
}))
.to_request();
let resp = actix_test::call_service(&app, req).await;
assert_eq!(resp.status(), 201);
// Check Redis queue for the enqueued job
let mut conn = redis_pool.get().await.expect("redis connection");
let queue_len: i64 = conn
.llen("shaperail:jobs:queue:normal")
.await
.expect("queue length");
assert!(queue_len > 0, "Job should be enqueued");
// Peek at the job payload
let raw: String = conn
.lindex("shaperail:jobs:queue:normal", 0)
.await
.expect("peek job");
let job: serde_json::Value = serde_json::from_str(&raw).expect("parse job");
assert_eq!(job["name"], "send_welcome_email");
}
Testing job handler functions
Job handlers are regular async functions. Test them the same way you test any async Rust code:
#[tokio::test]
async fn test_send_welcome_email_job_handler() {
let payload = json!({
"email": "alice@example.com",
"name": "Alice"
});
// Call your job handler directly
let result = send_welcome_email(payload).await;
assert!(result.is_ok());
// Assert side effects: email sent, external API called, etc.
}
Testing retry behavior
To test that a job retries on failure, simulate a transient error and verify the job re-enters the queue:
#[tokio::test]
async fn test_job_retries_on_transient_error() {
let payload = json!({"email": "fail@example.com"});
// First call fails
let result = flaky_job_handler(payload.clone()).await;
assert!(result.is_err());
// The job queue worker will re-enqueue automatically.
// In a test, verify retry count via the job metadata in Redis:
let mut conn = redis_pool.get().await.unwrap();
let attempts: i64 = conn
.hget("shaperail:jobs:meta:job-123", "attempts")
.await
.unwrap_or(0);
assert!(attempts <= 3, "Should not exceed max_retries");
}
Testing events and webhooks
Events are emitted after mutations and processed asynchronously. Test them by checking the event log or by verifying that subscriber targets are triggered.
Verifying events are emitted
After a write operation, check the shaperail_event_log table:
#[sqlx::test(migrations = "tests/fixtures/migrations")]
async fn test_create_emits_event(pool: sqlx::PgPool) {
let state = make_state_with_events(pool.clone());
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(state.clone()))
.configure(|cfg| register_resource(cfg, &resource, state.clone())),
)
.await;
let req = actix_test::TestRequest::post()
.uri("/v1/users")
.set_json(json!({
"email": "event@example.com",
"name": "Event User",
"role": "member",
"org_id": uuid::Uuid::new_v4().to_string()
}))
.to_request();
actix_test::call_service(&app, req).await;
// Allow async event processing to complete
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// Check event log
let row = sqlx::query("SELECT event, resource FROM shaperail_event_log ORDER BY timestamp DESC LIMIT 1")
.fetch_one(&pool)
.await
.expect("event logged");
let event: String = row.get("event");
let resource: String = row.get("resource");
assert_eq!(event, "users.created");
assert_eq!(resource, "users");
}
Testing outbound webhook delivery
Use a mock HTTP server (e.g., wiremock) to verify webhook delivery:
use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path, header_exists};
#[tokio::test]
async fn test_webhook_delivery_with_signature() {
// Start a mock server
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/hooks/user-created"))
.and(header_exists("X-Shaperail-Signature"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
// Configure the webhook subscriber to point at the mock server
let webhook_url = format!("{}/hooks/user-created", mock_server.uri());
// Emit an event that triggers the webhook
emit_event("users.created", &json!({
"id": "abc-123",
"email": "alice@example.com"
}), &webhook_url).await;
// wiremock automatically verifies the expected call count on drop
}
Testing inbound webhooks
Send a signed request to an inbound webhook endpoint and verify it produces an internal event:
use hmac::{Hmac, Mac};
use sha2::Sha256;
#[sqlx::test(migrations = "tests/fixtures/migrations")]
async fn test_inbound_stripe_webhook(pool: sqlx::PgPool) {
let state = make_state_with_events(pool.clone());
let secret = "whsec_test_secret";
// Build signed payload
let body = json!({"type": "payment.completed", "data": {"amount": 1000}});
let body_str = serde_json::to_string(&body).unwrap();
let timestamp = chrono::Utc::now().timestamp();
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(format!("{timestamp}.{body_str}").as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
let app = actix_test::init_service(/* ... */).await;
let req = actix_test::TestRequest::post()
.uri("/webhooks/stripe")
.insert_header((
"Stripe-Signature",
format!("t={timestamp},v1={signature}"),
))
.set_json(body)
.to_request();
let resp = actix_test::call_service(&app, req).await;
assert_eq!(resp.status(), 200);
}
Testing with fixtures and seed data
YAML seed files
Place seed files in seeds/ for repeatable test data:
# seeds/organizations.yaml
resource: organizations
records:
- id: "11111111-1111-1111-1111-111111111111"
name: Acme Corp
plan: enterprise
# seeds/users.yaml
resource: users
records:
- email: alice@acme.com
name: Alice Admin
role: admin
org_id: "11111111-1111-1111-1111-111111111111"
- email: bob@acme.com
name: Bob Viewer
role: viewer
org_id: "11111111-1111-1111-1111-111111111111"
Load them before tests:
shaperail seed seeds/organizations.yaml
shaperail seed seeds/users.yaml
Or load the entire directory:
shaperail seed
Programmatic fixtures in Rust
For integration tests, insert data directly through the test app or the pool:
/// Insert a test user and return its ID.
async fn insert_test_user(
app: &impl actix_web::dev::Service<
actix_http::Request,
Response = actix_web::dev::ServiceResponse,
Error = actix_web::Error,
>,
email: &str,
role: &str,
org_id: &str,
) -> String {
let req = actix_test::TestRequest::post()
.uri("/v1/users")
.set_json(json!({
"email": email,
"name": "Test User",
"role": role,
"org_id": org_id
}))
.to_request();
let resp = actix_test::call_service(app, req).await;
let body: serde_json::Value = actix_test::read_body_json(resp).await;
body["data"]["id"].as_str().unwrap().to_string()
}
Fixture files for SQL-level setup
For data that must exist before the test handler is configured, use SQL fixture files alongside your test migrations:
-- tests/fixtures/migrations/02_seed_test_data.sql
INSERT INTO organizations (id, name)
VALUES ('11111111-1111-1111-1111-111111111111', 'Test Org')
ON CONFLICT DO NOTHING;
Integration tests with test_support
shaperail-runtime ships a test-support cargo feature that provides TestServer, spawn_with_listener, and ensure_migrations_run. These let you spin up the full Actix server in-process on an ephemeral port and make real HTTP requests against it — without mocking any layer.
Note: Future versions of
shaperail initwill generate the lib/bin split described here automatically. Until then, the steps below are a one-time edit per project.
Step 1 — split src/main.rs into a library + binary
Add an explicit [lib] target to Cargo.toml alongside the binary, and pull in the dev-dependencies:
[lib]
path = "src/lib.rs"
[[bin]]
name = "my-app"
path = "src/main.rs"
[dev-dependencies]
shaperail-runtime = { workspace = true, features = ["test-support"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
Step 2 — expose build_server from src/lib.rs
Move the existing bootstrap logic (config, pool, registry, route registration) into a public async function that accepts a TcpListener and returns the unawaited actix_web::dev::Server. The function is async because realistic bootstrap code connects a sqlx pool, generates OpenAPI docs, builds resource registries, etc., all of which are async operations:
// src/lib.rs
use std::net::TcpListener;
use actix_web::dev::Server;
pub async fn build_server(listener: TcpListener) -> std::io::Result<Server> {
// ... async config, pool setup, resource registry, middleware ...
let server = actix_web::HttpServer::new(move || {
// ... App::new().route(...) ...
})
.listen(listener)?
.run();
Ok(server)
}
Step 3 — collapse src/main.rs to a thin caller
// src/main.rs
use std::net::TcpListener;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3000);
let listener = TcpListener::bind(("0.0.0.0", port))?;
my_app::build_server(listener)?.await
}
Step 4 — write tests/integration.rs
// tests/integration.rs
use std::net::TcpListener;
#[tokio::test]
async fn health_responds_200() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let server = shaperail_runtime::test_support::spawn_with_listener(
listener,
|l| async move { my_app::build_server(l).await },
)
.await
.unwrap();
let resp = reqwest::get(server.url("/health")).await.unwrap();
assert_eq!(resp.status(), 200);
}
If your build_server is synchronous (no async work in bootstrap), wrap it:
let server = shaperail_runtime::test_support::spawn_with_listener(
listener,
|l| async move { my_app::build_server(l) },
)
.await
.unwrap();
spawn_with_listener binds to port 0 (OS assigns an ephemeral port) and returns a TestServer whose Drop aborts the spawned task. Tests that start multiple server instances will each get a unique port with no conflicts.
Running migrations once per test process
For database-backed integration tests, call ensure_migrations_run before your first query. The helper is gated on a tokio::sync::OnceCell, so parallel tests share a single migration sweep instead of contending on the Postgres advisory lock.
Pass the path to your project’s own migrations/ directory. Use std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations")) for an absolute path that works regardless of where cargo test is invoked from:
use std::path::Path;
use shaperail_runtime::test_support::ensure_migrations_run;
#[tokio::test]
async fn test_create_user(pool: sqlx::PgPool) {
let migrations = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"));
ensure_migrations_run(&pool, migrations).await.expect("migrations");
// ... test body ...
}
Reaching controller helpers from integration tests
The codegen exposes each <resource>_controller module under crate::resources::* via a #[doc(hidden)] pub mod resources aggregator in generated/mod.rs. Library projects publish this with one line in src/lib.rs:
mod generated;
pub use generated::resources;
After this, integration tests in tests/ import controller helpers directly:
// tests/users.rs
use my_app::resources::users_controller::{create_user, NewUser};
#[tokio::test]
async fn create_user_normalizes_email() {
let input = NewUser { email: "Alice@Example.COM".into(), /* ... */ };
// ... call into the helper exactly as the runtime does ...
}
The #[doc(hidden)] attribute keeps the aggregator off the docs.rs surface — it exists for test wiring, not as a public API. Binary-only projects with no tests/ crate do not need to add the pub use line; the aggregator is still emitted but stays unreachable from outside src/lib.rs.
CI/CD testing patterns
Docker-based CI
Run the full test suite without installing Rust locally using the project’s Docker Compose setup:
docker compose up -d
export DATABASE_URL=postgresql://shaperail:shaperail@localhost:5433/shaperail_dev
export REDIS_URL=redis://localhost:6379
cargo test --workspace
GitHub Actions example
name: CI
on:
push:
branches: [main]
pull_request:
env:
DATABASE_URL: postgresql://shaperail:shaperail@localhost:5433/shaperail_dev
REDIS_URL: redis://localhost:6379
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: shaperail_dev
POSTGRES_USER: shaperail
POSTGRES_PASSWORD: shaperail
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U shaperail -d shaperail_dev"
--health-interval 5s
--health-timeout 3s
--health-retries 10
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: $-cargo-$
- name: Check formatting
run: cargo fmt --check
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Run tests
run: cargo test --workspace
- name: Run benchmarks (compile only)
run: cargo bench -p shaperail-runtime --no-run
Parallelizing tests
By default, cargo test runs tests in parallel. The #[sqlx::test] macro handles database isolation per test, so parallel execution is safe.
If you have tests that share external state (such as Redis keys), use a tokio::sync::Mutex to serialize them:
use std::sync::OnceLock;
fn redis_test_lock() -> &'static tokio::sync::Mutex<()> {
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}
#[sqlx::test(migrations = "tests/fixtures/migrations")]
async fn test_cache_behavior(pool: sqlx::PgPool) {
let _guard = redis_test_lock().lock().await;
// This test now has exclusive Redis access
}
Common pitfalls and solutions
1. DATABASE_URL not set
Symptom: Tests fail with “database URL not set” or connection refused.
Solution: Start the dev services and export the URL:
docker compose up -d
export DATABASE_URL=postgresql://shaperail:shaperail@localhost:5433/shaperail_dev
2. Port conflict on 5433
Symptom: docker compose up fails because port 5433 is already in use.
Solution: Stop any other PostgreSQL instances or change the port in docker-compose.yml. Shaperail uses port 5433 (not the default 5432) to avoid conflicts with a local PostgreSQL installation.
3. Tests interfere with each other
Symptom: Tests pass individually but fail when run together.
Solution: Use #[sqlx::test] for database isolation. For Redis-dependent tests, use the mutex lock pattern shown above, and clear relevant cache keys before each test:
async fn clear_resource_cache(pool: &deadpool_redis::Pool, resource: &str) {
let mut conn = pool.get().await.expect("redis connection");
let keys: Vec<String> = redis::cmd("KEYS")
.arg(format!("shaperail:{resource}:*"))
.query_async(&mut conn)
.await
.unwrap_or_default();
if !keys.is_empty() {
let _: usize = redis::AsyncCommands::del(&mut conn, keys)
.await
.expect("clear cache");
}
}
4. Stale test migrations
Symptom: Tests fail after changing a resource schema because the test migration still creates the old table structure.
Solution: Update tests/fixtures/migrations/ to match the current schema. Test migrations are separate from app migrations and must be kept in sync manually.
5. Async test runtime errors
Symptom: #[tokio::test] panics with “cannot start a runtime from within a runtime.”
Solution: Use #[sqlx::test] for tests that need a database pool. It manages the Tokio runtime internally. Do not nest #[tokio::test] inside #[sqlx::test].
6. Controller tests fail without a database
Symptom: Unit tests for controllers that use ctx.pool fail because there is no database connection.
Solution: If your controller queries the database, it needs an integration test with #[sqlx::test]. For controllers that only manipulate ctx.input or ctx.data, you can create a mock context without a real pool – but if the function touches ctx.pool, use a real database.
7. Webhook tests are flaky
Symptom: Webhook delivery tests fail intermittently because the async event processing has not completed.
Solution: Add a short delay after the triggering request to allow background processing to finish:
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
For more reliable assertions, poll the expected state with a timeout instead of a fixed sleep.
8. Test naming conventions
Follow the project naming convention for discoverability:
#[test]
fn test_<thing>_<condition>_<expected_outcome>() { ... }
// Examples:
fn test_field_type_uuid_parses_correctly() { ... }
fn test_list_endpoint_without_auth_returns_401() { ... }
fn test_soft_delete_hides_record_from_list() { ... }
Summary
| Test type | Tool | Database required | Location |
|---|---|---|---|
| Unit tests (controllers, pure logic) | #[tokio::test] | No (unless controller queries DB) | src/ inline #[cfg(test)] modules |
| Integration tests (HTTP endpoints) | #[sqlx::test] + actix_web::test | Yes | tests/ directory |
| Job handler tests | #[tokio::test] | No | src/ or tests/ |
| Event and webhook tests | #[sqlx::test] + mock server | Yes | tests/ directory |
| CLI smoke tests | assert_cmd | No | tests/ directory |
| Benchmarks | Criterion | No | benches/ directory |