# modo::testing
Test helpers for building and exercising modo applications in-process.
Requires the `test-helpers` feature.
```toml
[dev-dependencies]
modo = { package = "modo-rs", version = "0.8", features = ["test-helpers"] }
```
## Key types
| `TestApp` | Assembled test application; send requests via HTTP-method helpers |
| `TestAppBuilder` | Builder for `TestApp`; register services, routes, and layers |
| `TestDb` | In-memory SQLite database with chainable `exec` / `migrate` setup |
| `TestPool` | In-memory `DatabasePool` (default database and shards both `:memory:`) |
| `TestRequestBuilder` | Fluent builder for a single in-process HTTP request |
| `TestResponse` | Captured response with status, header, and body accessors |
| `TestSession` | Session infrastructure: creates the `authenticated_sessions` table, signs cookies, and builds `CookieSessionLayer`. Exposes `SCHEMA_SQL` and `INDEXES_SQL` constants. |
## Usage
### Basic handler test
```rust,ignore
use axum::routing::get;
use modo::testing::TestApp;
async fn hello() -> &'static str { "hello" }
#[tokio::test]
async fn test_hello() {
let app = TestApp::builder()
.route("/", get(hello))
.build();
let res = app.get("/").send().await;
assert_eq!(res.status(), 200);
assert_eq!(res.text(), "hello");
}
```
### Services and middleware
Register services with `.service()` and add middleware with `.layer()`:
```rust,ignore
use axum::routing::get;
use modo::testing::TestApp;
async fn greet(modo::service::Service(name): modo::service::Service<String>) -> String {
format!("hello {}", *name)
}
#[tokio::test]
async fn test_service() {
let app = TestApp::builder()
.service("world".to_string())
.layer(modo::middleware::request_id())
.route("/greet", get(greet))
.build();
let res = app.get("/greet").send().await;
assert_eq!(res.status(), 200);
assert_eq!(res.text(), "hello world");
}
```
### JSON request and response
Use `.json()` on the request builder and `.json::<T>()` on the response:
```rust,ignore
use axum::{routing::post, Json};
use modo::testing::TestApp;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct Greeting { name: String }
async fn greet(Json(body): Json<Greeting>) -> Json<Greeting> {
Json(Greeting { name: format!("hello {}", body.name) })
}
#[tokio::test]
async fn test_json() {
let app = TestApp::builder()
.route("/greet", post(greet))
.build();
let res = app.post("/greet").json(&Greeting { name: "world".into() }).send().await;
assert_eq!(res.status(), 200);
let out: Greeting = res.json();
assert_eq!(out.name, "hello world");
}
```
### From an existing router
Wrap a fully-assembled `Router` with `TestApp::from_router()` when you do
not need the builder's service-registry integration:
```rust,ignore
use axum::{Router, routing::get};
use modo::testing::TestApp;
let res = app.get("/").send().await;
assert_eq!(res.status(), 200);
```
### Database
```rust,ignore
use modo::testing::TestDb;
use modo::db::{ConnExt, Database};
#[tokio::test]
async fn test_db() {
let db = TestDb::new()
.await
.exec("CREATE TABLE items (id TEXT PRIMARY KEY, name TEXT NOT NULL)")
.await;
let database: Database = db.db();
database
.conn()
.execute_raw(
"INSERT INTO items (id, name) VALUES ('1', 'Alice')",
(),
)
.await
.unwrap();
}
```
### Database with migrations
Use `.migrate()` to run a directory of `.sql` migration files:
```rust,ignore
use modo::testing::TestDb;
#[tokio::test]
async fn test_with_migrations() {
let db = TestDb::new()
.await
.migrate("tests/fixtures/migrations")
.await;
let database = db.db();
// tables from migration files are now available
}
```
### In-memory database pool
`TestPool` exposes a `DatabasePool` whose default database and all shard
databases are `:memory:` — useful when exercising multi-database wiring
without touching the filesystem:
```rust,ignore
use modo::testing::TestPool;
#[tokio::test]
async fn test_pool() {
let pool = TestPool::new()
.await
.exec(None, "CREATE TABLE items (id TEXT PRIMARY KEY, name TEXT NOT NULL)")
.await;
let db = pool.conn(None).await.unwrap();
// use `db` as any `Database` handle
let _ = pool.pool(); // clone out the underlying `DatabasePool` for wiring
}
```
### Sessions
```rust,ignore
use axum::routing::get;
use modo::auth::session::CookieSession;
use modo::testing::{TestApp, TestDb, TestSession};
async fn whoami(session: CookieSession) -> String {
session.user_id().unwrap_or_else(|| "anonymous".to_string())
}
#[tokio::test]
async fn test_session() {
let db = TestDb::new().await;
let session = TestSession::new(&db).await;
let app = TestApp::builder()
.route("/me", get(whoami))
.layer(session.layer())
.build();
let cookie = session.authenticate("user-1").await;
let res = app.get("/me").header("cookie", &cookie).send().await;
assert_eq!(res.text(), "user-1");
}
```
### Sessions with custom data
Use `authenticate_with()` to attach arbitrary JSON data to the session:
```rust,ignore
use modo::testing::{TestDb, TestSession};
let db = TestDb::new().await;
let session = TestSession::new(&db).await;
let cookie = session
.authenticate_with("user-1", serde_json::json!({ "role": "admin" }))
.await;
```
### Session schema constants
`TestSession::SCHEMA_SQL` and `TestSession::INDEXES_SQL` are public constants
containing the DDL for the `authenticated_sessions` table and its indexes.
They are useful when you need to set up the schema independently of
`TestSession::new`:
```rust,ignore
use modo::testing::{TestDb, TestSession};
// Apply schema manually instead of using TestSession::new
let db = TestDb::new().await;
db.db().conn().execute_raw(TestSession::SCHEMA_SQL, ()).await.unwrap();
for sql in TestSession::INDEXES_SQL {
db.db().conn().execute_raw(sql, ()).await.unwrap();
}
```
### Sessions with custom config
Use `TestSession::with_config()` to supply explicit `CookieSessionsConfig` and
`CookieConfig`:
```rust,ignore
use modo::cookie::CookieConfig;
use modo::auth::session::CookieSessionsConfig;
use modo::testing::{TestDb, TestSession};
let db = TestDb::new().await;
let cookie_config = CookieConfig {
secret: "a".repeat(64),
secure: false,
http_only: true,
same_site: "lax".to_string(),
};
let session = TestSession::with_config(&db, CookieSessionsConfig::default(), cookie_config).await;
```
## Feature flag
Guard integration test files with:
```rust
#![cfg(feature = "test-helpers")]
```