actix-web Unit Testing and MockApp Patterns

actix-web Unit Testing and MockApp Patterns

actix-web ships with a first-class testing module — actix_web::test — that lets you spin up a full application stack in memory without binding a socket, send requests through it, and inspect responses. This approach catches the kinds of bugs that unit tests miss: misconfigured routes, middleware that intercepts too eagerly, extractors that reject valid payloads, and state that leaks between handlers.

This post walks through the full actix-web testing toolkit: init_service, TestRequest, call_service, mocking application state with web::Data, testing middleware and extractors, and writing tests that survive refactors.

The actix-web Test Model

actix-web's test module wraps your App in a lightweight service that processes requests in the same Tokio runtime your tests run in. The flow looks like this:

  1. Build your App exactly as you would in main(), but using test-specific state
  2. Call test::init_service(app) to get a ServiceRequest-capable service
  3. Build requests with TestRequest
  4. Pass them to test::call_service() and inspect the response

No ports, no TCP stacks, no sleep() calls waiting for servers to start.

Project Setup

[dependencies]
actix-web = "4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

[dev-dependencies]
actix-web = { version = "4", features = ["macros"] }

The macros feature enables #[actix_web::test], the async test macro that handles Tokio runtime setup automatically.

A Realistic Application to Test

Let's build a small task management API with application state, middleware, and several handler patterns:

// src/lib.rs
use actix_web::{web, App, HttpResponse, HttpServer, middleware};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};

#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Task {
    pub id: u32,
    pub title: String,
    pub done: bool,
}

#[derive(Deserialize)]
pub struct CreateTask {
    pub title: String,
}

#[derive(Deserialize)]
pub struct UpdateTask {
    pub done: bool,
}

#[derive(Clone)]
pub struct TaskStore {
    pub tasks: Arc<Mutex<Vec<Task>>>,
}

impl TaskStore {
    pub fn new() -> Self {
        Self {
            tasks: Arc::new(Mutex::new(Vec::new())),
        }
    }

    pub fn with_tasks(tasks: Vec<Task>) -> Self {
        Self {
            tasks: Arc::new(Mutex::new(tasks)),
        }
    }
}

pub async fn list_tasks(store: web::Data<TaskStore>) -> HttpResponse {
    let tasks = store.tasks.lock().unwrap();
    HttpResponse::Ok().json(tasks.clone())
}

pub async fn get_task(
    store: web::Data<TaskStore>,
    path: web::Path<u32>,
) -> HttpResponse {
    let id = path.into_inner();
    let tasks = store.tasks.lock().unwrap();
    match tasks.iter().find(|t| t.id == id) {
        Some(task) => HttpResponse::Ok().json(task),
        None => HttpResponse::NotFound().json(serde_json::json!({
            "error": "task not found",
            "id": id
        })),
    }
}

pub async fn create_task(
    store: web::Data<TaskStore>,
    body: web::Json<CreateTask>,
) -> HttpResponse {
    let mut tasks = store.tasks.lock().unwrap();
    let id = tasks.len() as u32 + 1;
    let task = Task { id, title: body.title.clone(), done: false };
    tasks.push(task.clone());
    HttpResponse::Created().json(task)
}

pub async fn update_task(
    store: web::Data<TaskStore>,
    path: web::Path<u32>,
    body: web::Json<UpdateTask>,
) -> HttpResponse {
    let id = path.into_inner();
    let mut tasks = store.tasks.lock().unwrap();
    match tasks.iter_mut().find(|t| t.id == id) {
        Some(task) => {
            task.done = body.done;
            HttpResponse::Ok().json(task.clone())
        }
        None => HttpResponse::NotFound().finish(),
    }
}

pub fn configure_app(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/tasks")
            .route("", web::get().to(list_tasks))
            .route("", web::post().to(create_task))
            .route("/{id}", web::get().to(get_task))
            .route("/{id}", web::put().to(update_task)),
    );
}

Basic Handler Tests with init_service and call_service

#[cfg(test)]
mod tests {
    use super::*;
    use actix_web::{test, App};

    fn test_store() -> TaskStore {
        TaskStore::with_tasks(vec![
            Task { id: 1, title: "Write tests".into(), done: false },
            Task { id: 2, title: "Deploy".into(), done: true },
        ])
    }

    #[actix_web::test]
    async fn test_list_tasks_returns_all() {
        let app = test::init_service(
            App::new()
                .app_data(web::Data::new(test_store()))
                .configure(configure_app),
        )
        .await;

        let req = test::TestRequest::get().uri("/tasks").to_request();
        let resp = test::call_service(&app, req).await;

        assert_eq!(resp.status(), 200);

        let body: Vec<Task> = test::read_body_json(resp).await;
        assert_eq!(body.len(), 2);
        assert_eq!(body[0].title, "Write tests");
    }

    #[actix_web::test]
    async fn test_get_task_exists() {
        let app = test::init_service(
            App::new()
                .app_data(web::Data::new(test_store()))
                .configure(configure_app),
        )
        .await;

        let req = test::TestRequest::get().uri("/tasks/1").to_request();
        let resp = test::call_service(&app, req).await;

        assert_eq!(resp.status(), 200);

        let task: Task = test::read_body_json(resp).await;
        assert_eq!(task.id, 1);
        assert_eq!(task.title, "Write tests");
        assert!(!task.done);
    }

    #[actix_web::test]
    async fn test_get_task_not_found_returns_json_error() {
        let app = test::init_service(
            App::new()
                .app_data(web::Data::new(test_store()))
                .configure(configure_app),
        )
        .await;

        let req = test::TestRequest::get().uri("/tasks/999").to_request();
        let resp = test::call_service(&app, req).await;

        assert_eq!(resp.status(), 404);

        let body: serde_json::Value = test::read_body_json(resp).await;
        assert_eq!(body["error"], "task not found");
        assert_eq!(body["id"], 999);
    }
}

Testing JSON Request Bodies

Handlers that accept web::Json<T> need requests with a body and the correct Content-Type header. TestRequest has a .set_json() helper that handles both:

#[actix_web::test]
async fn test_create_task_returns_created() {
    let store = TaskStore::new();
    let app = test::init_service(
        App::new()
            .app_data(web::Data::new(store))
            .configure(configure_app),
    )
    .await;

    let req = test::TestRequest::post()
        .uri("/tasks")
        .set_json(serde_json::json!({ "title": "New task" }))
        .to_request();

    let resp = test::call_service(&app, req).await;

    assert_eq!(resp.status(), 201);

    let task: Task = test::read_body_json(resp).await;
    assert_eq!(task.title, "New task");
    assert!(!task.done);
    assert!(task.id > 0);
}

#[actix_web::test]
async fn test_create_task_missing_field_returns_400() {
    let store = TaskStore::new();
    let app = test::init_service(
        App::new()
            .app_data(web::Data::new(store))
            .configure(configure_app),
    )
    .await;

    // Missing required "title" field
    let req = test::TestRequest::post()
        .uri("/tasks")
        .set_json(serde_json::json!({ "unexpected": "field" }))
        .to_request();

    let resp = test::call_service(&app, req).await;

    // actix-web returns 400 for JSON deserialization failures
    assert_eq!(resp.status(), 400);
}

#[actix_web::test]
async fn test_update_task_marks_done() {
    let app = test::init_service(
        App::new()
            .app_data(web::Data::new(test_store()))
            .configure(configure_app),
    )
    .await;

    let req = test::TestRequest::put()
        .uri("/tasks/1")
        .set_json(serde_json::json!({ "done": true }))
        .to_request();

    let resp = test::call_service(&app, req).await;

    assert_eq!(resp.status(), 200);

    let task: Task = test::read_body_json(resp).await;
    assert!(task.done);
    assert_eq!(task.id, 1);
}

Mocking Application State with web::Data

The key to testable actix-web apps is injecting state through web::Data<T> rather than accessing globals. In tests, you supply a pre-populated or mock implementation:

// Define a trait for the data layer
#[async_trait::async_trait]
pub trait TaskRepository: Send + Sync {
    async fn find_all(&self) -> Vec<Task>;
    async fn find_by_id(&self, id: u32) -> Option<Task>;
    async fn create(&self, title: String) -> Task;
}

// Real implementation backed by the in-memory store
pub struct InMemoryRepository {
    store: Arc<Mutex<Vec<Task>>>,
}

#[async_trait::async_trait]
impl TaskRepository for InMemoryRepository {
    async fn find_all(&self) -> Vec<Task> {
        self.store.lock().unwrap().clone()
    }

    async fn find_by_id(&self, id: u32) -> Option<Task> {
        self.store.lock().unwrap().iter().find(|t| t.id == id).cloned()
    }

    async fn create(&self, title: String) -> Task {
        let mut tasks = self.store.lock().unwrap();
        let id = tasks.len() as u32 + 1;
        let task = Task { id, title, done: false };
        tasks.push(task.clone());
        task
    }
}

// Test double — returns predictable data, doesn't touch state
pub struct MockRepository {
    pub preset_tasks: Vec<Task>,
}

#[async_trait::async_trait]
impl TaskRepository for MockRepository {
    async fn find_all(&self) -> Vec<Task> {
        self.preset_tasks.clone()
    }

    async fn find_by_id(&self, id: u32) -> Option<Task> {
        self.preset_tasks.iter().find(|t| t.id == id).cloned()
    }

    async fn create(&self, title: String) -> Task {
        Task { id: 99, title, done: false }
    }
}
#[actix_web::test]
async fn test_list_with_mock_repository() {
    let mock = MockRepository {
        preset_tasks: vec![
            Task { id: 10, title: "Mocked task".into(), done: false },
        ],
    };

    let app = test::init_service(
        App::new()
            .app_data(web::Data::new(Arc::new(mock) as Arc<dyn TaskRepository>))
            .configure(configure_app_with_repo),
    )
    .await;

    let req = test::TestRequest::get().uri("/tasks").to_request();
    let resp = test::call_service(&app, req).await;

    assert_eq!(resp.status(), 200);
    let tasks: Vec<Task> = test::read_body_json(resp).await;
    assert_eq!(tasks[0].id, 10);
}

Testing Middleware with wrap_fn

actix-web middleware is applied with .wrap() or .wrap_fn(). Tests exercise middleware exactly as production does, since init_service includes the full middleware stack:

use actix_web::{dev::ServiceRequest, dev::ServiceResponse, Error};
use actix_web::middleware::Next;

async fn require_api_key(
    req: ServiceRequest,
    next: Next<actix_web::body::BoxBody>,
) -> Result<ServiceResponse<actix_web::body::BoxBody>, Error> {
    let key = req.headers().get("X-Api-Key")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    if key == "secret-key" {
        next.call(req).await
    } else {
        Ok(req.error_response(actix_web::error::ErrorUnauthorized("invalid key")))
    }
}
#[actix_web::test]
async fn test_middleware_rejects_missing_api_key() {
    let app = test::init_service(
        App::new()
            .app_data(web::Data::new(test_store()))
            .configure(configure_app)
            .wrap(actix_web::middleware::from_fn(require_api_key)),
    )
    .await;

    let req = test::TestRequest::get().uri("/tasks").to_request();
    let resp = test::call_service(&app, req).await;

    assert_eq!(resp.status(), 401);
}

#[actix_web::test]
async fn test_middleware_allows_valid_api_key() {
    let app = test::init_service(
        App::new()
            .app_data(web::Data::new(test_store()))
            .configure(configure_app)
            .wrap(actix_web::middleware::from_fn(require_api_key)),
    )
    .await;

    let req = test::TestRequest::get()
        .uri("/tasks")
        .insert_header(("X-Api-Key", "secret-key"))
        .to_request();

    let resp = test::call_service(&app, req).await;

    assert_eq!(resp.status(), 200);
}

Testing Form Data Extractors

For endpoints that accept application/x-www-form-urlencoded data:

#[actix_web::test]
async fn test_form_endpoint() {
    let app = test::init_service(
        App::new()
            .route("/form-task", web::post().to(create_task_from_form)),
    )
    .await;

    let req = test::TestRequest::post()
        .uri("/form-task")
        .insert_header(("Content-Type", "application/x-www-form-urlencoded"))
        .set_payload("title=From+Form")
        .to_request();

    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), 201);
}

Custom Error Handler Testing

actix-web lets you customize error responses with .app_data() configuration. Test that your error shape matches expectations:

#[actix_web::test]
async fn test_custom_json_error_format() {
    let json_cfg = web::JsonConfig::default()
        .error_handler(|err, _req| {
            let response = HttpResponse::BadRequest().json(serde_json::json!({
                "error": "validation_failed",
                "detail": err.to_string()
            }));
            actix_web::error::InternalError::from_response(err, response).into()
        });

    let app = test::init_service(
        App::new()
            .app_data(json_cfg)
            .app_data(web::Data::new(TaskStore::new()))
            .configure(configure_app),
    )
    .await;

    let req = test::TestRequest::post()
        .uri("/tasks")
        .insert_header(("Content-Type", "application/json"))
        .set_payload("not-valid-json")
        .to_request();

    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), 400);

    let body: serde_json::Value = test::read_body_json(resp).await;
    assert_eq!(body["error"], "validation_failed");
    assert!(body["detail"].as_str().is_some());
}

Structuring Tests for Maintainability

The configure_app pattern — a function that takes &mut web::ServiceConfig — is essential for testability. It separates route registration from app initialization, so tests can compose exactly the subset of routes they need.

For larger applications, create a tests/ directory with a common.rs helper:

// tests/common.rs
use actix_web::{test, web, App};
use myapp::{configure_app, TaskStore, Task};

pub async fn build_test_app(tasks: Vec<Task>) -> impl actix_web::dev::Service<
    actix_web::dev::ServiceRequest,
    Response = actix_web::dev::ServiceResponse,
    Error = actix_web::Error,
> {
    test::init_service(
        App::new()
            .app_data(web::Data::new(TaskStore::with_tasks(tasks)))
            .configure(configure_app),
    )
    .await
}

Then each test file stays clean:

// tests/tasks.rs
mod common;

#[actix_web::test]
async fn test_empty_store_returns_empty_list() {
    let app = common::build_test_app(vec![]).await;
    let req = test::TestRequest::get().uri("/tasks").to_request();
    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), 200);
    let body: Vec<serde_json::Value> = test::read_body_json(resp).await;
    assert!(body.is_empty());
}

Continuous Testing with HelpMeTest

actix-web's test utilities make it easy to write comprehensive local tests. The harder problem is keeping them green over time — as your application grows, as dependencies change, and as your team merges concurrent changes.

HelpMeTest provides continuous test monitoring for Rust applications. Connect your test suite and get automatic tracking of which tests are passing, which are flaky, and alerts when regressions appear — without setting up your own test result infrastructure. For actix-web APIs in production, that means catching a broken middleware layer or a changed error response format before a customer does.

The patterns above — init_service, TestRequest, trait-based state mocking, and the configure_app composition pattern — give you a test suite that's both thorough and fast. Pair them with continuous execution and you have real confidence in your actix-web application's behavior.

Read more