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
end

Asserting 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"})
end

Simulating 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"
end

Process Isolation

Stubs are scoped to the test process that defines them. This means:

  • async: true works — 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"
end

Tips

  • 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/4 directly in webhook tests — it's a pure function that doesn't make HTTP calls.