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.
// 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());});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());}Testing a POST endpoint with a JSON body
Section titled “Testing a POST endpoint with a JSON body”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 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");}Testing error paths
Section titled “Testing error paths”#[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. Runcargo testin your project directory. For database tests, setTEST_DATABASE_URLand runcargo test -- --test-threads=1to avoid concurrent migration conflicts.