name: effect-http-testing description: Testing Effect HttpApi services end-to-end. Use when writing tests that involve Effect's HttpApi, HttpApiBuilder, HttpClient, HttpServer, or when testing any HTTP service/plugin built with @effect/platform. Covers proper layer composition, test server setup, HttpClient injection, and common pitfalls.
Effect HTTP Testing
Core Pattern
Define an API with HttpApi, implement handlers with HttpApiBuilder.group, serve it with HttpApiBuilder.serve(), and use NodeHttpServer.layerTest to get an in-process test server + HttpClient pointed at it.
import { expect, layer } from "@effect/vitest";
import { Effect, Layer, Schema } from "effect";
import {
HttpApi,
HttpApiBuilder,
HttpApiEndpoint,
HttpApiGroup,
HttpClient,
OpenApi,
} from "@effect/platform";
import { NodeHttpServer } from "@effect/platform-node";
// 1. Define the API
class Item extends Schema.Class<Item>("Item")({
id: Schema.Number,
name: Schema.String,
}) {}
const ItemsGroup = HttpApiGroup.make("items")
.add(HttpApiEndpoint.get("listItems", "/items").addSuccess(Schema.Array(Item)))
.add(
HttpApiEndpoint.get("getItem", "/items/:itemId")
.setPath(Schema.Struct({ itemId: Schema.NumberFromString }))
.addSuccess(Item),
);
const MyApi = HttpApi.make("myApi").add(ItemsGroup);
// 2. Implement handlers
const ItemsLive = HttpApiBuilder.group(MyApi, "items", (handlers) =>
handlers
.handle("listItems", () => Effect.succeed([{ id: 1, name: "Widget" }]))
.handle("getItem", (req) => Effect.succeed({ id: req.path.itemId, name: "Widget" })),
);
// 3. Build test layer
const ApiLive = HttpApiBuilder.api(MyApi).pipe(Layer.provide(ItemsLive));
const TestLayer = HttpApiBuilder.serve().pipe(
Layer.provide(ApiLive),
Layer.provideMerge(NodeHttpServer.layerTest),
);
// 4. Use layer() to share across tests
layer(TestLayer)("My API", (it) => {
it.effect("works", () =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
// client is already pointed at the test server
const response = yield* client.get("/items");
// ...
}),
);
});
Critical Rules
Layer composition order matters
HttpApiBuilder.serve() consumes HttpApi.Api. The API layer must be provided to it, not the other way around:
// CORRECT
HttpApiBuilder.serve().pipe(Layer.provide(ApiLive), Layer.provideMerge(NodeHttpServer.layerTest));
// WRONG — "Service not found: HttpApi.Api"
ApiLive.pipe(Layer.provide(HttpApiBuilder.serve()), Layer.provideMerge(NodeHttpServer.layerTest));
layerTestClient prepends the server URL
NodeHttpServer.layerTest (and HttpServer.layerTestClient) produce an HttpClient that automatically prepends the test server's http://127.0.0.1:<port> to every request URL.
- Use paths only (
/items,/items/2) in requests — not full URLs - If your code builds full URLs (e.g.
http://localhost/items), the client will producehttp://127.0.0.1:PORThttp://localhost/items— an invalid URL - When injecting the test client into code that normally uses a
baseUrl, passbaseUrl: ""or skip the base URL entirely
Path parameters need setPath()
Effect's HttpApiEndpoint with :param syntax does NOT automatically populate req.path. You must call .setPath() with a schema:
// WRONG — req.path is undefined
HttpApiEndpoint.get("getItem", "/items/:itemId").addSuccess(Item);
// CORRECT — req.path.itemId is typed and populated
HttpApiEndpoint.get("getItem", "/items/:itemId")
.setPath(Schema.Struct({ itemId: Schema.NumberFromString }))
.addSuccess(Item);
OpenApi.fromApi generates the spec
Use OpenApi.fromApi(api) to generate an OpenAPI spec from an HttpApi definition. The generated spec:
- Uses
"Api"as the default title (not the api id) - Converts
:paramto{param}in paths - Does NOT list path parameters in the
parametersarray — they're implicit in the path template - Uses
group.endpointformat for operationIds (e.g.items.listItems)
Grab the test HttpClient from context
Inside layer() tests, the HttpClient is available in the Effect context:
layer(TestLayer)("tests", (it) => {
it.effect("test", () =>
Effect.gen(function* () {
const httpClient = yield* HttpClient.HttpClient;
// Use it directly or wrap in a Layer for injection
const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
}),
);
});
Use HttpClient for HTTP calls, not fetch
Production code should use HttpClient from @effect/platform, not raw fetch:
import { HttpClient, HttpClientRequest } from "@effect/platform";
// Build request
let request = HttpClientRequest.get("/items");
request = HttpClientRequest.setHeader(request, "accept", "application/json");
request = HttpClientRequest.setUrlParam(request, "limit", "10");
// Execute — requires HttpClient in context
const response = yield * client.execute(request);
// Read body
const data = yield * response.json; // Effect<unknown>
const text = yield * response.text; // Effect<string>
This makes testing clean — swap in a test client layer, no monkey-patching needed.
Response headers are Record<string, string>
Effect's HttpClientResponse.headers is a plain Record<string, string>, not a Web Headers object. Don't call .forEach() or .get() on it:
// WRONG
response.headers.forEach((v, k) => ...)
response.headers.get("content-type")
// CORRECT
const ct = response.headers["content-type"]
const copy = { ...response.headers }
HttpClientRequest.make takes uppercase methods
// The method parameter to make() must be uppercase
HttpClientRequest.make("GET")("/items");
// Or use the convenience methods
HttpClientRequest.get("/items");
HttpClientRequest.post("/items");
Prepending base URLs to a client
Use HttpClient.mapRequest with HttpClientRequest.prependUrl:
const clientWithBase = Layer.effect(
HttpClient.HttpClient,
Effect.map(
HttpClient.HttpClient,
HttpClient.mapRequest(HttpClientRequest.prependUrl("https://api.example.com")),
),
).pipe(Layer.provide(baseClientLayer));
Error assertions
Use Effect.flip to turn errors into values for assertion:
const error = yield * Effect.flip(someFailingEffect);
expect(error._tag).toBe("MyError");
Dependencies
@effect/platform— HttpApi, HttpClient, HttpServer, OpenApi@effect/platform-node— NodeHttpServer.layerTest (for Node/vitest)@effect/vitest—layer(),it.effect(),expect