Chapter 19
Testing Your Server
Rust has testing built into the language and toolchain. No test framework to install, no test runner to configure - cargo test does it all.
Unit Tests with #[test] and #[cfg(test)]
Unit tests live alongside the code they test, inside a #[cfg(test)] module. This module is only compiled when running cargo test - it's stripped from release builds.
use std::collections::HashMap;
use crate::error::ServerError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Method { Get, Post, Put, Delete }
impl Method {
pub fn parse(s: &str) -> Result<Method, ServerError> {
match s {
"GET" => Ok(Method::Get),
"POST" => Ok(Method::Post),
"PUT" => Ok(Method::Put),
"DELETE" => Ok(Method::Delete),
other => Err(ServerError::ParseError(
format!("unsupported method: {}", other)
)),
}
}
}
pub struct Request {
pub method: Method,
pub path: String,
pub headers: HashMap<String, String>,
pub body: Option<String>,
}
impl Request {
pub fn parse(raw: &str) -> Result<Request, ServerError> {
// ... parsing logic ...
todo!()
}
}
// ---- Tests live right here, in the same file ----
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_get_method() {
let method = Method::parse("GET").unwrap();
assert_eq!(method, Method::Get);
}
#[test]
fn parse_unknown_method_is_error() {
let result = Method::parse("PATCH");
assert!(result.is_err());
}
#[test]
fn parse_simple_request() {
let raw = "GET /index.html HTTP/1.1\r\nHost: localhost\r\n\r\n";
let req = Request::parse(raw).unwrap();
assert_eq!(req.method, Method::Get);
assert_eq!(req.path, "/index.html");
assert_eq!(req.headers.get("host"), Some(&"localhost".to_string()));
}
#[test]
fn parse_empty_request_is_error() {
let result = Request::parse("");
assert!(result.is_err());
}
}Run them:
# Run all tests
cargo test
# Run tests in a specific module
cargo test request::tests
# Run a single test by name
cargo test parse_get_method
# Show output from passing tests (normally hidden)
cargo test -- --nocapture
# Run tests with a pattern
cargo test parse_Assertion Macros
// Equality
assert_eq!(actual, expected); // panics with a diff if not equal
assert_ne!(actual, unexpected); // panics if equal
// Boolean
assert!(condition); // panics if false
assert!(result.is_ok());
assert!(path.starts_with("/"));
// Custom message
assert_eq!(status, 200, "expected 200 OK, got {}", status);
// Check that something panics
#[test]
#[should_panic(expected = "empty request")]
fn empty_request_panics() {
Request::parse("").unwrap(); // unwrap panics on Err
}
// Check the error variant
#[test]
fn bad_method_returns_parse_error() {
let err = Method::parse("INVALID").unwrap_err();
match err {
ServerError::ParseError(msg) => {
assert!(msg.contains("unsupported method"));
}
other => panic!("expected ParseError, got {:?}", other),
}
}#[cfg(test)] means the module is conditionally compiled - it doesn't exist in your release binary. use super::* imports everything from the parent module, including private functions. Unit tests can test private code - that's by design.Integration Tests in the tests/ Directory
Integration tests live in a tests/ directory at the project root. Each file is compiled as a separate crate that can only access your public API - just like an external user of your library.
webserver/
├── src/
│ ├── lib.rs
│ ├── main.rs
│ ├── request.rs
│ └── ...
└── tests/
├── request_parsing.rs ← each file is a separate test crate
├── routing.rs
└── api.rsFor integration tests to work, your server logic must be in src/lib.rs (not just main.rs). The integration tests import from the library crate:
use webserver::request::{Method, Request};
#[test]
fn parse_request_with_body() {
let raw = "POST /api/todos HTTP/1.1\r\n\
Content-Type: application/json\r\n\
Content-Length: 25\r\n\
\r\n\
{\"title\":\"Write tests\"}";
let req = Request::parse(raw).unwrap();
assert_eq!(req.method, Method::Post);
assert_eq!(req.path, "/api/todos");
assert_eq!(
req.headers.get("content-type"),
Some(&"application/json".to_string())
);
assert_eq!(
req.body.as_deref(),
Some("{\"title\":\"Write tests\"}")
);
}
#[test]
fn parse_request_preserves_query_string() {
let raw = "GET /search?q=rust&page=2 HTTP/1.1\r\n\r\n";
let req = Request::parse(raw).unwrap();
assert_eq!(req.path, "/search?q=rust&page=2");
}use webserver::handler::build_router;
use webserver::request::{Method, Request};
use std::collections::HashMap;
fn make_request(method: Method, path: &str) -> Request {
Request {
method,
path: path.to_string(),
headers: HashMap::new(),
body: None,
}
}
#[test]
fn root_returns_200() {
let router = build_router();
let req = make_request(Method::Get, "/");
let resp = router.route(&req).unwrap();
assert_eq!(resp.status, 200);
}
#[test]
fn unknown_path_returns_404() {
let router = build_router();
let req = make_request(Method::Get, "/nonexistent");
let err = router.route(&req).unwrap_err();
let resp = err.to_response(false);
assert_eq!(resp.status, 404);
}cargo test --test routing to run a single test file.Testing HTTP Handlers in Isolation
The key insight: handlers are just functions that take a &Request and return a Result<Response, ServerError>. You don't need a running server to test them - construct a Request, call the handler, and check the Response:
use webserver::request::{Method, Request};
use webserver::store::{Store, CreateTodo};
use std::collections::HashMap;
use std::sync::Arc;
fn api_request(method: Method, path: &str, body: Option<&str>) -> Request {
let mut headers = HashMap::new();
if body.is_some() {
headers.insert(
"content-type".to_string(),
"application/json".to_string(),
);
}
Request {
method,
path: path.to_string(),
headers,
body: body.map(|s| s.to_string()),
}
}
#[test]
fn create_and_list_todos() {
let store = Arc::new(Store::new());
// Create a todo
let created = store.create(CreateTodo {
title: "Write tests".to_string(),
});
assert_eq!(created.id, 1);
assert_eq!(created.title, "Write tests");
assert!(!created.completed);
// List todos
let todos = store.list();
assert_eq!(todos.len(), 1);
assert_eq!(todos[0].title, "Write tests");
}
#[test]
fn update_todo_partial() {
let store = Store::new();
store.create(CreateTodo { title: "Original".into() });
// Update only the completed field
let updated = store.update(1, webserver::store::UpdateTodo {
title: None,
completed: Some(true),
}).unwrap();
assert_eq!(updated.title, "Original"); // unchanged
assert!(updated.completed); // updated
}
#[test]
fn delete_nonexistent_returns_false() {
let store = Store::new();
assert!(!store.delete(999));
}Testing the store directly is fast and deterministic - no network, no async, no timing issues. Each test gets a fresh Store so tests don't interfere with each other.
Testing JSON Serialization
use webserver::store::Todo;
#[test]
fn todo_serializes_correctly() {
let todo = Todo {
id: 1,
title: "Test".to_string(),
completed: false,
};
let json = serde_json::to_string(&todo).unwrap();
assert!(json.contains(r#""id":1"#));
assert!(json.contains(r#""title":"Test""#));
assert!(json.contains(r#""completed":false"#));
}
#[test]
fn todo_deserializes_with_defaults() {
// 'completed' is missing - should default to false
let json = r#"{"id":1,"title":"Test"}"#;
let todo: Todo = serde_json::from_str(json).unwrap();
assert!(!todo.completed);
}
#[test]
fn invalid_json_returns_descriptive_error() {
let json = r#"{"wrong":"fields"}"#;
let result: Result<Todo, _> = serde_json::from_str(json);
let err = result.unwrap_err().to_string();
assert!(err.contains("missing field"), "error was: {}", err);
}End-to-End Tests Against a Running Server
For full confidence, test the entire stack - network, parsing, routing, and serialization. Spin up the server in the test, send real HTTP requests, and check the responses:
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
/// Start the server on a random available port and return the address.
async fn start_server() -> String {
let router = Arc::new(webserver::handler::build_router());
// Bind to port 0 - the OS assigns a random available port
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
loop {
if let Ok((stream, _)) = listener.accept().await {
let router = Arc::clone(&router);
tokio::spawn(async move {
webserver::handle_connection(stream, router).await;
});
}
}
});
addr
}
/// Send a raw HTTP request and return the raw response.
async fn http_request(addr: &str, request: &str) -> String {
let mut stream = TcpStream::connect(addr).await.unwrap();
stream.write_all(request.as_bytes()).await.unwrap();
stream.flush().await.unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).await.unwrap();
response
}
#[tokio::test]
async fn get_root_returns_200() {
let addr = start_server().await;
let resp = http_request(
&addr,
"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n",
).await;
assert!(resp.starts_with("HTTP/1.1 200"));
assert!(resp.contains("Content-Type: text/html"));
}
#[tokio::test]
async fn get_unknown_path_returns_404() {
let addr = start_server().await;
let resp = http_request(
&addr,
"GET /nope HTTP/1.1\r\nHost: localhost\r\n\r\n",
).await;
assert!(resp.starts_with("HTTP/1.1 404"));
}
#[tokio::test]
async fn post_todo_and_get_it_back() {
let addr = start_server().await;
// Create
let create_resp = http_request(
&addr,
"POST /api/todos HTTP/1.1\r\n\
Content-Type: application/json\r\n\
Content-Length: 23\r\n\
\r\n\
{\"title\":\"From test\"}",
).await;
assert!(create_resp.starts_with("HTTP/1.1 201"));
assert!(create_resp.contains("From test"));
// List
let list_resp = http_request(
&addr,
"GET /api/todos HTTP/1.1\r\nHost: localhost\r\n\r\n",
).await;
assert!(list_resp.contains("From test"));
}
#[tokio::test]
async fn malformed_request_returns_400() {
let addr = start_server().await;
let resp = http_request(&addr, "GARBAGE\r\n\r\n").await;
assert!(resp.starts_with("HTTP/1.1 400"));
}#[tokio::test] is the async version of #[test]. It sets up a Tokio runtime for the test function. Binding to port 0 lets the OS pick an available port, so tests can run in parallel without port conflicts.Helper Functions
As your test suite grows, extract helpers to reduce duplication:
/// Parse just the status code from a raw HTTP response.
fn status_code(response: &str) -> u16 {
response
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|code| code.parse().ok())
.unwrap_or(0)
}
/// Extract the body from a raw HTTP response.
fn response_body(response: &str) -> &str {
response.split("\r\n\r\n").nth(1).unwrap_or("")
}
/// Parse the body as JSON.
fn json_body<T: serde::de::DeserializeOwned>(response: &str) -> T {
serde_json::from_str(response_body(response)).unwrap()
}
#[tokio::test]
async fn todo_api_lifecycle() {
let addr = start_server().await;
let resp = http_request(&addr, "GET /api/todos HTTP/1.1\r\n\r\n").await;
assert_eq!(status_code(&resp), 200);
let todos: Vec<serde_json::Value> = json_body(&resp);
assert!(todos.is_empty());
}Property-Based Testing
Traditional tests check specific inputs. Property-based testing generates hundreds of random inputs and checks that certain properties always hold. It finds edge cases you wouldn't think to test manually.
The proptest crate is the most popular choice:
cargo add --dev proptest#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
// Generate random valid HTTP request lines
fn valid_method() -> impl Strategy<Value = &'static str> {
prop_oneof![
Just("GET"),
Just("POST"),
Just("PUT"),
Just("DELETE"),
]
}
fn valid_path() -> impl Strategy<Value = String> {
"[/][a-zA-Z0-9/_.-]{0,100}".prop_map(|s| {
if s.is_empty() { "/".to_string() } else { s }
})
}
proptest! {
/// Any valid request line should parse successfully.
#[test]
fn parses_valid_requests(
method in valid_method(),
path in valid_path(),
) {
let raw = format!("{} {} HTTP/1.1\r\n\r\n", method, path);
let result = Request::parse(&raw);
prop_assert!(result.is_ok(), "Failed to parse: {}", raw);
let req = result.unwrap();
prop_assert_eq!(req.path, path);
}
/// Random bytes should never cause a panic - they should return Err.
#[test]
fn random_input_never_panics(input in ".*") {
let _ = Request::parse(&input);
// We don't care if it's Ok or Err - just that it doesn't panic
}
/// Parsing then serializing a method should round-trip.
#[test]
fn method_roundtrip(method in valid_method()) {
let parsed = Method::parse(method).unwrap();
let serialized = parsed.to_string();
prop_assert_eq!(method, serialized.as_str());
}
}
}The proptest! macro generates random inputs from your Strategy definitions and runs the test body hundreds of times. If it finds a failing input, it shrinks it to the smallest reproducing case.
Properties to test for a webserver:
- Parse never panics - any input returns
OkorErr, never crashes. - Round-trip - serialize then deserialize produces the original value.
- Response is always valid HTTP - status line, Content-Length, and blank line are always present.
- Path normalization is idempotent - normalizing twice gives the same result as normalizing once.
Test Organization Tips
Where to put tests
- Unit tests (
#[cfg(test)] mod tests) - same file, test private functions, fast - Integration tests (
tests/*.rs) - test public API, each file is a separate crate - Doc tests (
/// ``` ... ```) - in doc comments, verified bycargo test
Useful cargo test flags
cargo test # run everything
cargo test --lib # unit tests only
cargo test --test e2e # one integration test file
cargo test -- --ignored # run #[ignore] tests
cargo test -- --test-threads=1 # no parallelism (useful for port conflicts)
cargo test --no-fail-fast # don't stop on first failureIgnoring slow tests
#[test]
#[ignore] // skipped by default, run with: cargo test -- --ignored
fn slow_load_test() {
// Send 10,000 requests and check for errors
}Exercise
- Add unit tests for
Response::to_bytes. Verify it produces valid HTTP: starts with"HTTP/1.1 ", containsContent-Length, and has a blank line before the body. - Write an integration test that creates a todo via POST, updates it via PUT, and deletes it via DELETE. Verify each step with GET.
- Add a
proptestthat generates random JSON objects and verifies thatRequest::json_bodyeither parses successfully or returns a descriptive error - never panics. - Write a doc test for
Method::parse. Put a code example in the///doc comment and verify it runs withcargo test --doc. - Add an
#[ignore]load test: send 1,000 concurrent requests to the server usingtokio::spawnand verify every response is 200. Measure elapsed time.
What's Next
Your server now has a proper test suite - unit tests for individual functions, integration tests for the public API, end-to-end tests hitting real HTTP, and property-based tests generating random inputs. cargo test runs them all.
In the next chapter, we add logging, configuration, and CLI arguments - structured logging with tracing, config from files and environment variables, and proper command-line argument parsing.