Testing HTTP Applications
Introduction
Testing HTTP applications is fundamentally different from testing regular Scala libraries — you're testing code that handles network I/O, manages stateful connections, and deals with concurrent requests. This guide shows you how to write fast, reliable tests for ZIO HTTP applications without the typical overhead and complexity of HTTP testing.
The key insight is that ZIO HTTP treats routes as pure functions: a route is simply Request => ZIO[R, Response, Response]. This functional model means you can test your HTTP logic the same way you test any other ZIO effect — by providing inputs and asserting on outputs — without needing to start a server, manage ports, or deal with network I/O.
By the end of this guide, you'll understand:
- How to test routes directly without any infrastructure
- How to use
TestClientto mock HTTP client behavior - How to use
TestServerfor integration testing multiple routes - How to test stateful handlers that maintain request context
- How to test WebSocket connections with bidirectional messaging
- How to verify error handling and edge cases
The Problem with Traditional HTTP Testing
Before ZIO HTTP, testing an HTTP service typically involved one of these painful approaches:
Starting a real server for each test: Your test suite starts a Netty server on a random port, makes HTTP requests to it, then shuts it down. This works, but is slow (seconds per test), hard to debug (now you have network I/O to reason about), and makes it difficult to test edge cases (how do you simulate a network timeout? A broken connection?).
Mocking everything at the HTTP library level: You might mock the underlying Client or Server classes, but then you're not really testing your HTTP logic — you're testing that your code calls the mocked object correctly. If the mock doesn't match the real behavior, your tests pass but your code fails in production.
Writing integration tests that depend on external services: Your tests talk to a real database, a real cache, a real message queue. Now your tests are slow, flaky (external services go down), and hard to run locally. You can't easily test error paths like "what if the database is unavailable?"
ZIO HTTP solves this by treating routes as pure, testable functions that you can invoke directly in your tests without any server infrastructure.
Prerequisites and Setup
To follow this guide, add these test dependencies to your build.sbt:
libraryDependencies ++= Seq(
"dev.zio" %% "zio-test" % "2.1.26" % Test,
"dev.zio" %% "zio-test-sbt" % "2.1.26" % Test,
"dev.zio" %% "zio-http-testkit" % "3.8.1" % Test,
)
testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework")
This guide assumes you're familiar with:
- ZIO effects and testing — how to write ZIO code and tests
- ZIO HTTP routes and handlers — how to define HTTP endpoints
- Basic HTTP concepts — requests, responses, status codes, headers
If you're new to ZIO or ZIO HTTP, start with those reference pages before diving into testing patterns.
Three Testing Patterns
ZIO HTTP provides three distinct testing patterns, each suited to different scenarios:
-
Direct Route Testing: Test a handler directly by converting it to routes and calling
runZIO(request). This is the simplest approach and ideal for unit testing individual handlers in isolation. -
TestClient: Mock the HTTP client by defining what responses to return for specific requests. Use when your application makes HTTP calls to external services and you want to mock those dependencies.
-
TestServer: Start a test server that responds to HTTP requests based on your routes. Use for integration testing multiple routes working together, or when testing code that makes HTTP requests and you want to verify the exact requests being made.
Each pattern serves a different testing need — we'll explore them in order of complexity and show when to use each one.
Pattern 1: Direct Route Testing
The simplest and fastest way to test is to invoke a handler directly as a function, without any server infrastructure. In ZIO HTTP, a Handler is a function that takes a Request and returns a ZIO effect that produces a Response. You can convert a handler to a Routes object using .toRoutes, then call runZIO directly in your test to invoke the routing logic.
This approach is ideal when you're testing a single handler in isolation — for example, a handler that parses JSON, validates input, and returns a response. There's no networking, no port binding, no concurrent connections to worry about. Just pure ZIO effects.
When to use this pattern:
- Unit testing individual handlers or middleware
- Testing request parsing and validation logic
- Testing simple routes with no external dependencies
- Fast feedback loop during development
When NOT to use this pattern:
- Your code needs to handle multiple routes with different path patterns
- You need to test that your code makes HTTP calls to other services
- You're testing middleware or request/response transformations that depend on route context
Here's a simple example:
package example.testing
import zio.http._
import zio.test._
/**
* Testing HTTP Applications — Pattern 1: Direct Route Testing
*
* Demonstrates how to test routes directly without any server infrastructure
* by calling runZIO on a route with a request and asserting on the response.
*
* Run with: sbt "zio-http-example-testing/testOnly example.testing.DirectRouteExampleSpec"
*/
object DirectRouteExampleSpec extends ZIOSpecDefault {
def spec = test("handler returns OK status") {
// Create a simple handler that always returns OK
val handler = Handler.ok.toRoutes
// Call the route directly with a request
val request = Request.get(URL(Path.root))
// Assert the response status
for {
response <- handler.runZIO(request)
} yield assertTrue(response.status == Status.Ok)
}
}
(source)
The code examples below are linked directly from the ZIO HTTP test suite and can be run as-is. You can find, study, and execute these tests yourself in the repository.
The key here is handler.toRoutes.runZIO(request) — first, the handler is converted to a Routes object using .toRoutes, then runZIO invokes the route function directly and returns the response as a ZIO effect, which you then assert on.
Behind the scenes: A Routes object is a function that pattern-matches on the request path and method, then invokes the appropriate handler. By calling runZIO directly on the routes, you skip the server entirely and invoke the routing logic as a pure function. The handler must first be converted to routes using .toRoutes before you can call runZIO on it.
Pattern 2: TestClient — Mock HTTP Dependencies
TestClient is a mock HTTP client implementation. Instead of making real HTTP requests to external services, your code makes requests to the TestClient, which you've configured to return specific responses.
What is TestClient useful for?
Imagine your application calls an external service — maybe a payment processor, a weather API, or a recommendation engine. In production, these calls go over the network to real servers. But in tests, you don't want to:
- Make real requests to external APIs (slow, unreliable, might incur costs)
- Depend on those services being up and available
- Test against production data or state
Instead, you use TestClient to provide a mock implementation of the Client interface. Your code calls the mock client with requests, and the mock returns responses you've configured.
How TestClient works:
You provide the TestClient.layer in your test, which gives your code a Client instance. Instead of the real network-based client, it's a test implementation. Then you configure what responses to return for what requests using methods like addRoute, addRequestResponse, and setFallbackHandler.
Simple request/response mappings:
For straightforward cases where a specific request should always get a specific response, use addRequestResponse:
package example.testing
import zio._
import zio.http._
import zio.test._
/**
* Testing HTTP Applications — Pattern 2: TestClient - Mock HTTP Dependencies
*
* Demonstrates how to mock a simple API endpoint using TestClient.addRequestResponse,
* which returns a specific response for an exact request.
*
* Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideTestClientBasicSpec"
*/
object GuideTestClientBasicSpec extends ZIOSpecDefault {
def spec = test("mock a simple API endpoint") {
for {
client <- ZIO.service[Client]
// Configure the mock: when this exact request is made, return this response
_ <- TestClient.addRequestResponse(
Request.get(URL.root / "users" / "1"),
Response.json("""{"id": 1, "name": "Alice", "email": "alice@example.com"}""")
)
// Your code calls the client with the request
response <- client(Request.get(URL.root / "users" / "1"))
body <- response.body.asString
} yield assertTrue(body.contains("Alice"))
}.provide(TestClient.layer, Scope.default)
}
(source)
The mock checks that the incoming request matches exactly — same method, same URL, same headers. If your code makes a different request, it throws an error. This strictness is actually a feature: it forces your test to be specific about what requests should be made.
Flexible route handlers:
Sometimes you need more flexibility. Maybe you want to handle a range of requests (e.g., GET /users/{id} for any ID), or perform some computation before returning the response. Use addRoute:
package example.testing
import zio._
import zio.http._
import zio.test._
/**
* Testing HTTP Applications — Pattern 2: TestClient with Dynamic Handler
*
* Demonstrates how to use TestClient.addRoute with a flexible handler that
* processes path parameters dynamically instead of exact request matching.
*
* Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideTestClientFlexibleSpec"
*/
object GuideTestClientFlexibleSpec extends ZIOSpecDefault {
def spec = test("mock with dynamic handler") {
for {
client <- ZIO.service[Client]
// Handler receives the path parameter and can use it
_ <- TestClient.addRoute {
Method.GET / "users" / int("id") -> handler { (id: Int, _: Request) =>
ZIO.succeed(Response.json(s"""{"id": $id, "name": "User $id"}"""))
}
}
// Now any GET /users/{id} request will be handled by this handler
response1 <- client(Request.get(URL.root / "users" / "1"))
body1 <- response1.body.asString
response2 <- client(Request.get(URL.root / "users" / "99"))
body2 <- response2.body.asString
} yield assertTrue(
body1.contains("User 1"),
body2.contains("User 99")
)
}.provide(TestClient.layer, Scope.default)
}
(source)
This is more flexible — the handler function lets you compute the response dynamically based on the request content.
Catching unexpected requests with fallback handlers:
A powerful testing technique is to track what requests your code makes. Use setFallbackHandler to see any requests that don't match your configured routes:
package example.testing
import zio._
import zio.http._
import zio.test._
/**
* Testing HTTP Applications — Pattern 2: TestClient with Fallback Handler
*
* Demonstrates how to use TestClient.setFallbackHandler to track unexpected requests
* and verify that your code only makes expected HTTP calls.
*
* Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideFallbackHandlerSpec"
*/
object GuideFallbackHandlerSpec extends ZIOSpecDefault {
def spec = test("verify expected requests are made") {
for {
client <- ZIO.service[Client]
// Track unexpected requests
unexpectedCalls <- Ref.make[List[Request]](Nil)
_ <- TestClient.setFallbackHandler { req =>
unexpectedCalls.update(_ :+ req).as(Response.notFound)
}
// Configure expected route
_ <- TestClient.addRoute {
Method.GET / "expected" -> handler(Response.ok)
}
// Your code makes some requests
_ <- client(Request.get(URL.root / "expected"))
_ <- client(Request.get(URL.root / "unexpected"))
// Verify what was called
unexpectedRequests <- unexpectedCalls.get
} yield assertTrue(
unexpectedRequests.length == 1,
unexpectedRequests.head.url.path.encode == "/unexpected"
)
}.provide(TestClient.layer, Scope.default)
}
(source)
This pattern is especially useful when testing code that makes HTTP calls to multiple services. You can verify that:
- The expected calls are made with the right parameters
- No unexpected calls are made to unrelated services
- The code handles specific error responses correctly
TestClient is an inverted dependency model: instead of mocking the Client interface itself (which is brittle), you configure TestClient routes just like you would configure real server routes. Your code gets a Client that works exactly like the real one, but backed by your test configuration. This makes tests more realistic and resistant to changes in the Client implementation.
Pattern 3: TestServer — Integration Testing
Use TestServer when you need to test your HTTP application as a server responding to requests. Unlike TestClient which mocks the outbound client, TestServer is the inbound side — it receives HTTP requests and returns responses.
What is TestServer useful for?
TestServer is ideal when:
- You're testing multiple routes working together
- Your routes depend on each other (e.g., creating a resource returns an ID you use in subsequent requests)
- You're testing middleware that applies to all routes
- You want integration tests that exercise the full request/response cycle without network I/O
TestServer binds to a localhost port and uses loopback network I/O, making it fast and deterministic without the latency of remote network calls. You make HTTP requests using the standard Client interface, which creates a realistic testing scenario without incurring actual network overhead.
Basic server setup:
The simplest TestServer test configures one or more routes, then uses a Client to make HTTP requests to the server:
package example.testing
import zio._
import zio.http._
import zio.test._
/**
* Testing HTTP Applications — Pattern 3: TestServer - Integration Testing
*
* Demonstrates how to use TestServer to test routes by making HTTP requests
* to an in-memory test server using a standard Client.
*
* Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideTestServerBasicSpec"
*/
object GuideTestServerBasicSpec extends ZIOSpecDefault {
def spec = test("server responds to requests") {
for {
client <- ZIO.service[Client]
// Get the port the test server is bound to
port <- ZIO.serviceWithZIO[Server](_.port)
// Configure a route
_ <- TestServer.addRoute {
Method.GET / "hello" -> Handler.text("Hello, World!")
}
// Make a request to localhost:port
response <- client(Request.get(URL.root.port(port) / "hello"))
body <- response.body.asString
} yield assertTrue(body == "Hello, World!")
}.provide(TestServer.default, Client.default, Scope.default)
}
(source)
The key difference from direct route testing: here we're actually making HTTP requests to the server via the Client, just like production code would. The TestServer receives those requests, matches them against configured routes, and returns responses.
Testing multiple routes together:
TestServer shines when testing how multiple routes work together:
package example.testing
import zio._
import zio.http._
import zio.test._
/**
* Testing HTTP Applications — Pattern 3: TestServer with Multiple Routes
*
* Demonstrates how to use TestServer to test multiple routes working together
* with route precedence (specific routes before fallback routes).
*
* Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideMultiRouteIntegrationSpec"
*/
object GuideMultiRouteIntegrationSpec extends ZIOSpecDefault {
def spec = test("test interactions between routes") {
for {
client <- ZIO.service[Client]
port <- ZIO.serviceWithZIO[Server](_.port)
// Configure both a specific route and a fallback
_ <- TestServer.addRoutes {
Routes(
// Specific route: GET /items/{id}
Method.GET / "items" / int("id") -> handler { (id: Int, _: Request) =>
ZIO.succeed(Response.json(s"""{"type": "specific", "id": $id}"""))
},
// Fallback: any other GET request
Method.GET / trailing -> handler { (_: Request) =>
ZIO.succeed(Response.json("""{"type": "fallback"}"""))
},
)
}
// Request the specific route
specificResp <- client(Request.get(URL.root.port(port) / "items" / "42"))
specificBody <- specificResp.body.asString
// Request a path that matches the fallback
fallbackResp <- client(Request.get(URL.root.port(port) / "other"))
fallbackBody <- fallbackResp.body.asString
} yield assertTrue(
specificBody.contains("specific"),
fallbackBody.contains("fallback"),
)
}.provide(TestServer.default, Client.default, Scope.default)
}
(source)
Notice that routes are evaluated in order — the specific GET /users/{id} route is checked first, and if it doesn't match, the fallback GET /... route is checked. This mirrors production behavior and lets you test route precedence.
Key differences from TestClient:
- TestClient mocks the outbound
Client— use when your code makes HTTP calls to external services - TestServer mocks the inbound
Server— use when testing code that receives HTTP requests - TestServer is more like production — you make real HTTP requests that go through the full routing logic
- TestServer is heavier — it binds to a port and runs routing, so it's slower than direct route testing
Testing Stateful Handlers
Many real-world handlers maintain state across requests. For example:
- An authentication handler tracks login attempts to prevent brute force attacks
- A rate limiting middleware tracks how many requests each user makes
- A caching handler caches values to avoid recomputing them
- A checkout handler maintains a shopping cart across multiple requests
When testing such handlers, you need to verify that state is correctly maintained as requests arrive.
How to test state:
Use a Ref (ZIO's mutable reference) to hold state that your handler can modify. The key is to make multiple requests and verify that the state changes correctly based on each request:
package example.testing
import zio._
import zio.http._
import zio.test._
/**
* Testing HTTP Applications — Testing Stateful Handlers
*
* Demonstrates how to test handlers that maintain state across multiple requests
* using Ref to track and verify state changes.
*
* Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideStatefulHandlerSpec"
*/
object GuideStatefulHandlerSpec extends ZIOSpecDefault {
def spec = test("handler state persists across requests") {
for {
client <- ZIO.service[Client]
port <- ZIO.serviceWithZIO[Server](_.port)
// Create a Ref to hold a counter
counter <- Ref.make(0)
// Configure a route that increments the counter
_ <- TestServer.addRoute {
Method.GET / "increment" -> handler { (_: Request) =>
counter.updateAndGet(_ + 1).map(n => Response.text(s"Count: $n"))
}
}
// Make first request
resp1 <- client(Request.get(URL.root.port(port) / "increment"))
body1 <- resp1.body.asString
// Make second request
resp2 <- client(Request.get(URL.root.port(port) / "increment"))
body2 <- resp2.body.asString
// Make third request
resp3 <- client(Request.get(URL.root.port(port) / "increment"))
body3 <- resp3.body.asString
} yield assertTrue(
body1 == "Count: 1",
body2 == "Count: 2",
body3 == "Count: 3",
)
}.provide(TestServer.default, Client.default, Scope.default)
}
(source)
Why is this important?
Stateful handler testing reveals bugs that wouldn't be caught by testing a single request:
- State might not be properly reset between requests
- State updates might race (in concurrent scenarios)
- State might leak between different users or sessions
- Recovery logic might not work when state is corrupted
By making multiple requests in sequence and verifying the state changes at each step, you ensure your stateful logic is correct.
Use Ref for shared mutable state that handlers need to access. The test creates the Ref once, then handlers update it across multiple requests. This models how real applications maintain state — think of it as an in-memory database that all handlers have access to.
Testing WebSocket Connections
WebSockets are fundamentally different from HTTP: instead of request/response pairs, WebSocket connections are long-lived, bidirectional channels where either side can send messages at any time.
How WebSocket testing works:
Testing WebSockets is tricky because you need to test two sides of a conversation simultaneously:
- The server handler — receives messages from clients and sends responses
- The client handler — sends messages to the server and receives responses
ZIO HTTP provides TestChannel to make this work. A TestChannel is an in-memory, bidirectional message queue. You configure both a server handler and a client handler, then they communicate through the test channel — no actual network involved.
Understanding the pairing model:
When you call client.socket(socketApp), the client sends a WebSocket upgrade request. TestClient intercepts this and:
- Creates a
TestChannelwith two ends: one for the server, one for the client - Runs the server handler on one end
- Runs the client app on the other end
- Messages sent by the client appear in the server's input queue
- Messages sent by the server appear in the client's input queue
A simple echo server example:
To create a WebSocket handler that echoes messages back to clients:
package example.testing
import zio._
import zio.http._
import zio.http.ChannelEvent.Read
import zio.test._
/**
* Testing HTTP Applications — Testing WebSocket Connections
*
* Demonstrates how to test WebSocket bidirectional communication using TestChannel
* with a simple echo server and client pattern.
*
* Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideWebSocketEchoSpec"
*/
object GuideWebSocketEchoSpec extends ZIOSpecDefault {
def spec = test("echo server echoes messages") {
// The server receives messages and echoes them back
val echoServer: WebSocketApp[Any] = Handler.webSocket { channel =>
channel.receiveAll {
case Read(WebSocketFrame.Text(message)) =>
// When we receive text, send it back with "Echo: " prefix
channel.send(Read(WebSocketFrame.text(s"Echo: $message")))
case _ => ZIO.unit
}
}
for {
// Use a Promise to coordinate between the forked client fiber and this test
receivedFrame <- Promise.make[Throwable, WebSocketFrame]
// The client sends a message and expects to receive the echo
testClient: WebSocketApp[Any] = Handler.webSocket { channel =>
for {
// Skip handshake complete event
_ <- channel.receive
// Send a message
_ <- channel.send(Read(WebSocketFrame.text("Hello, Server!")))
// Wait to receive the response
response <- channel.receive
// Signal the received frame to the outer test
_ <- response match {
case Read(frame) => receivedFrame.succeed(frame)
case _ => receivedFrame.fail(new Exception("Expected ChannelEvent.Read"))
}
_ <- channel.shutdown
} yield ()
}
// Install the server handler in TestClient
_ <- TestClient.installSocketApp(echoServer)
// The client calls socket() with its handler
_ <- ZIO.serviceWithZIO[Client](_.socket(testClient))
// Wait for the frame and verify it matches the expected echo
frame <- receivedFrame.await
} yield assertTrue(
frame match {
case WebSocketFrame.Text(msg) => msg == "Echo: Hello, Server!"
case _ => false
}
)
}.provide(TestClient.layer, Scope.default)
}
(source)
Key concepts:
Handler.webSocket { channel => ... }— Creates a WebSocket handler that operates on aWebSocketChannel. The handler runs as a ZIO effect and can send and receive messages.channel.receive— Waits for the next message from the other sidechannel.receiveAll { case ... => ... }— Pattern matches on incoming messages and responds to each onechannel.send(...)— Sends a message to the other sideRead(WebSocketFrame.Text(...))— Wraps a text message in aChannelEvent.Readso it can be sent/received
The handshake:
When you upgrade to WebSocket, both sides automatically receive a HandshakeComplete event as the first message. This signals that the upgrade succeeded and bidirectional communication can begin. You must consume this event before processing application messages — as shown in the example above where we skip it with _ <- channel.receive before entering the echo loop.
WebSocket handlers run concurrently. Both the server and client handlers are running at the same time, each waiting to receive or send messages. Be careful about deadlocks — for example, if both sides wait to receive without sending first, they'll hang indefinitely.
Testing Error Scenarios and Edge Cases
A critical part of testing is verifying that your application handles errors correctly. This includes:
- HTTP errors — returning the right status codes (401, 404, 500, etc.)
- Input validation — rejecting invalid requests with helpful error messages
- Fault tolerance — handling timeouts, network errors, and other failures
- State recovery — cleaning up properly when things go wrong
Why error testing matters:
It's easy to test the "happy path" where everything works correctly. But in production, things fail — users send invalid data, external services time out, databases go down. Your code needs to handle these cases gracefully. Testing error scenarios ensures:
- Users get appropriate error messages (not a confusing 500 error)
- Error state doesn't leak to subsequent requests
- Security isn't compromised when validating input
- Failures are logged properly for debugging
Testing HTTP status codes:
Use direct route testing or TestServer to verify that your handlers return the correct status codes:
package example.testing
import zio._
import zio.http._
import zio.test._
/**
* Testing HTTP Applications — Testing Error Scenarios
*
* Demonstrates how to test HTTP error responses including status codes
* like 401 (Unauthorized) and 400 (Bad Request).
*
* Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideErrorHandlingSpec"
*/
object GuideErrorHandlingSpec extends ZIOSpecDefault {
def spec = suite("error responses")(
test("unauthorized when missing auth header") {
for {
client <- ZIO.service[Client]
port <- ZIO.serviceWithZIO[Server](_.port)
// Configure a protected endpoint
_ <- TestServer.addRoute {
Method.GET / "protected" -> handler { (req: Request) =>
if (req.headers.contains("Authorization"))
ZIO.succeed(Response.ok)
else
ZIO.succeed(
Response.status(Status.Unauthorized)
.addHeader("WWW-Authenticate", "Bearer realm=\"api\"")
)
}
}
response <- client(Request.get(URL.root.port(port) / "protected"))
} yield assertTrue(response.status == Status.Unauthorized)
},
test("bad request when input is invalid") {
for {
client <- ZIO.service[Client]
port <- ZIO.serviceWithZIO[Server](_.port)
_ <- TestServer.addRoute {
Method.POST / "users" -> handler { (_: Request) =>
ZIO.succeed(
Response.status(Status.BadRequest)
.addHeader("X-Error", "Missing required field")
)
}
}
response <- client(Request.post(URL.root.port(port) / "users", Body.empty))
} yield assertTrue(response.status == Status.BadRequest)
},
).provide(TestServer.default, Client.default, Scope.default)
}
(source)
Testing error messages:
Beyond just the status code, verify that error messages are clear and helpful:
package example.testing
import zio._
import zio.http._
import zio.test._
/**
* Testing HTTP Applications — Testing Error Messages
*
* Demonstrates how to test that error responses include helpful messages
* by examining response headers and status codes together.
*
* Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideValidationErrorSpec"
*/
object GuideValidationErrorSpec extends ZIOSpecDefault {
def spec = test("error response includes helpful message") {
for {
client <- ZIO.service[Client]
port <- ZIO.serviceWithZIO[Server](_.port)
_ <- TestServer.addRoute {
Method.POST / "login" -> handler { (req: Request) =>
req.body.asString
.map { body =>
if (body.isEmpty)
Response.status(Status.BadRequest)
.addHeader("X-Error", "Username and password required")
else
Response.ok
}
.catchAll { _ =>
ZIO.succeed(Response.status(Status.BadRequest))
}
}
}
response <- client(Request.post(URL.root.port(port) / "login", Body.empty))
errorMsg = response.headers.get("X-Error")
} yield assertTrue(
response.status == Status.BadRequest,
errorMsg.exists(_.contains("required"))
)
}.provide(TestServer.default, Client.default, Scope.default)
}
(source)
Error testing is where the investment in testing really pays off. A single unhandled error path in production can impact thousands of users. By testing error scenarios systematically, you gain confidence that your application degrades gracefully under failure.
Choosing the Right Testing Approach
Now that you understand the three patterns, how do you decide which to use?
Use direct route testing when:
- You're unit testing a single handler or middleware in isolation
- You want the fastest possible feedback loop during development
- You don't need to test multiple routes or external dependencies
- Example: Testing a JSON parser, validation logic, or simple request transformation
Use TestClient when:
- Your code makes HTTP calls to external services (databases, APIs, message queues, etc.)
- You want to mock those dependencies and test failure scenarios
- You need to verify that your code makes the right HTTP calls with the right parameters
- Example: Testing a client that calls a payment processor, weather API, or recommendation service
Use TestServer when:
- You're testing multiple routes working together
- Your routes depend on each other (e.g., create → read → update → delete flows)
- You want to test middleware, authentication, or request routing logic
- You're doing integration testing of larger features
- Example: Testing a complete user management API with create, read, update, delete endpoints
In practice, use all three:
A well-tested application uses a mixture:
- Unit tests with direct route testing for individual handlers
- Mock tests with TestClient to verify integration with external services
- Integration tests with TestServer to test feature workflows
This pyramid approach gives you fast feedback from unit tests, confidence that external integrations work from mock tests, and assurance that features work end-to-end from integration tests.
Best Practices for HTTP Testing
1. Test both success and failure paths
For every feature, write tests for:
- The happy path (everything works)
- Common failure cases (invalid input, missing data, etc.)
- Rare but serious failures (database unavailable, timeout, etc.)
2. Test at the right level
Don't test everything with TestServer. Use direct route testing for unit tests, reserve TestServer for features that truly need it. This keeps your test suite fast.
3. Use meaningful test names
Instead of test("works"), write test("returns 401 when authorization header is missing"). A good test name documents what you're testing.
4. Test behavior, not implementation
Test that the handler returns the right status code and response body. Don't test internal implementation details like "this variable was set correctly".
5. Verify the whole request/response cycle
Don't just check the status code. Verify:
- Status code is correct
- Response headers are correct
- Response body has the expected content
- Side effects happened (e.g., data was persisted)
This gives you confidence that the whole feature works, not just parts of it.
Going Deeper
Once you're comfortable with the basics, consider these advanced testing techniques:
Testing Middleware
Middleware runs on every request. Test it by wrapping your routes with middleware and verifying that it transforms requests/responses correctly. For example, test that an authentication middleware rejects requests without credentials, or that a logging middleware doesn't interfere with response bodies.
Testing Concurrent Behavior
ZIO HTTP handlers can run concurrently. Use TestServer with multiple concurrent requests to test:
- Race conditions in stateful handlers
- Connection pooling behavior
- Concurrent request limits
Integration Testing with Real Resources
For end-to-end testing, combine TestServer with real resources:
- Real database connections (but use transactions to roll back)
- Real message queues (but use test instances)
- Real caches (but reset state between tests)
This gives you confidence that the whole system works together.
Performance Testing
Use TestClient to simulate load:
- Make thousands of requests and measure latency
- Test that handlers scale well under load
- Verify that resource cleanup happens correctly
Error Recovery Testing
Test that your handlers recover correctly from failures:
- Simulate database timeouts with slow test routes
- Test that connection pools recover after failures
- Verify that retries happen with correct backoff
Summary
ZIO HTTP's functional model makes testing straightforward:
- Direct route testing for fast unit tests
- TestClient for mocking external dependencies
- TestServer for integration testing features
- All three can run without network I/O, making tests fast and reliable
The key insight is that routes are just functions — you can test them like any other ZIO effect, which makes HTTP testing simple and deterministic.
For more details, see:
- ZIO Test documentation — how to write ZIO tests
- ZIO HTTP Handler reference — handler patterns and APIs
- ZIO HTTP Route reference — route definition and matching