Skip to content

Testing Handlers

supertest/jest vs tower::ServiceExt::oneshot

Section titled “supertest/jest vs tower::ServiceExt::oneshot”

In Node.js you use supertest to send HTTP requests against your Express/Nest app in-process without starting a real server. Axum’s equivalent is tower::ServiceExt::oneshot — it drives the router as a Tower service, sending a single Request and awaiting the Response, all in-process with no socket.

TypeScript
// Jest + supertest (NestJS)
import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { AppModule } from '../src/app.module';
describe('GET /users', () => {
let app;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
await app.init();
});
it('returns 200 with users array', async () => {
const res = await request(app.getHttpServer()).get('/users');
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
afterAll(() => app.close());
});
Rust
use axum::{
body::Body,
http::{Request, StatusCode},
routing::get,
Router,
};
use tower::ServiceExt; // provides .oneshot()
use serde_json::Value;
fn build_app() -> Router {
Router::new().route("/users", get(list_users))
}
#[tokio::test]
async fn test_list_users_returns_200() {
let app = build_app();
let response = app
.oneshot(
Request::builder()
.uri("/users")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert!(json.is_array());
}
use axum::{
body::Body,
http::{header, Method, Request, StatusCode},
};
use serde_json::json;
use tower::ServiceExt;
#[tokio::test]
async fn test_create_user_returns_201() {
let app = build_app_with_state(test_state().await);
let body = json!({ "name": "Alice", "email": "[email protected]" });
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/users")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let user: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(user["name"], "Alice");
}
#[tokio::test]
async fn test_get_nonexistent_user_returns_404() {
let app = build_app();
let response = app
.oneshot(
Request::builder()
.uri("/users/00000000-0000-0000-0000-000000000000")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

Test helper: build the app with mock state

Section titled “Test helper: build the app with mock state”

For tests that need a database, inject a test pool pointing at a test database:

async fn test_state() -> Arc<AppState> {
let pool = PgPool::connect(
&std::env::var("TEST_DATABASE_URL")
.unwrap_or_else(|_| "postgres://localhost/myapp_test".to_string()),
)
.await
.unwrap();
sqlx::migrate!().run(&pool).await.unwrap();
Arc::new(AppState { db: pool })
}
fn build_app_with_state(state: Arc<AppState>) -> Router {
Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/:id", get(get_user).delete(delete_user))
.with_state(state)
}

Run locally#[tokio::test] tests require the Tokio runtime. Run cargo test in your project directory. For database tests, set TEST_DATABASE_URL and run cargo test -- --test-threads=1 to avoid concurrent migration conflicts.

What does `tower::ServiceExt::oneshot` do?
Which attribute marks an async Axum handler test?
Where should `tower` (for `ServiceExt`) be declared in `Cargo.toml`?