PolarExpress.Test provides process-scoped HTTP stubs via NimbleOwnership, so
your tests can run with async: true without interference.
Setup
Start the test stub server in your test/test_helper.exs:
PolarExpress.Test.start()
ExUnit.start()Stubbing Requests
Use PolarExpress.Test.stub/1 to define how the HTTP layer responds. The stub
function receives a map with :method, :url, :headers, and :body,
and returns a {status, headers, body} tuple:
defmodule MyApp.BillingTest do
use ExUnit.Case, async: true
test "creates a customer" do
PolarExpress.Test.stub(fn %{method: :post, url: url} ->
assert url =~ "/v1/customers"
{200, [], ~s({"id": "cus_123", "email": "jane@example.com"})}
end)
client = PolarExpress.client("pk_test_123")
{:ok, customer} = PolarExpress.Services.CustomersService.create_customer(client, %{
email: "jane@example.com"
})
assert customer.id == "cus_123"
end
endAsserting on Request Parameters
The stub receives the full request, so you can assert on the body, headers, or URL parameters:
test "sends correct params" do
PolarExpress.Test.stub(fn %{method: :post, body: body} ->
params = JSON.decode!(body)
assert params["email"] == "jane@example.com"
{200, [], ~s({"id": "cus_123", "email": "jane@example.com"})}
end)
client = PolarExpress.client("pk_test_123")
{:ok, _} = PolarExpress.Services.CustomersService.create_customer(client, %{email: "jane@example.com"})
endSimulating Errors
Return non-200 status codes to test error handling:
test "handles validation error" do
PolarExpress.Test.stub(fn _ ->
{422, [],
~s({"error": {"type": "validation_error", "message": "Invalid email address."}})}
end)
client = PolarExpress.client("pk_test_123")
{:error, err} = PolarExpress.Services.CustomersService.create_customer(client, %{email: "invalid"})
assert err.type == :validation_error
assert err.message =~ "Invalid"
endProcess Isolation
Stubs are scoped to the test process that defines them. This means:
async: trueworks — concurrent tests don't interfere with each other- No shared state — each test sets up its own stubs independently
- Automatic cleanup — stubs are removed when the test process exits
Under the hood, PolarExpress.Test uses NimbleOwnership to associate stubs
with the calling process. If your test spawns child processes that make
Polar API calls, you can allow them to share the parent's stubs:
test "works in spawned processes" do
PolarExpress.Test.stub(fn _ ->
{200, [], ~s({"id": "cus_123", "object": "customer"})}
end)
# Allow the Task process to use this test's stubs
task = Task.async(fn ->
client = PolarExpress.client("pk_test_123")
PolarExpress.Services.CustomersService.get_customer(client, "cus_123")
end)
assert {:ok, customer} = Task.await(task)
assert customer.id == "cus_123"
endTips
- Keep stubs minimal. Only include the fields your test actually checks. The deserializer handles missing fields gracefully.
- Use
async: true. The ownership model is designed for it. - Don't stub the webhook. Use
PolarExpress.Webhook.construct_event/4directly in webhook tests — it's a pure function that doesn't make HTTP calls.