This guide shows you how to test your Bazaar implementation.
Testing Handlers
Basic Handler Test
defmodule MyApp.UCPHandlerTest do
use ExUnit.Case, async: true
alias MyApp.UCPHandler
describe "capabilities/0" do
test "returns expected capabilities" do
assert UCPHandler.capabilities() == [:checkout, :orders]
end
end
describe "business_profile/0" do
test "returns store profile" do
profile = UCPHandler.business_profile()
assert profile["name"] == "My Store"
assert is_binary(profile["description"])
end
end
describe "create_checkout/2" do
test "creates checkout with valid params" do
params = valid_checkout_params()
assert {:ok, checkout} = UCPHandler.create_checkout(params, nil)
assert checkout["currency"] == "USD"
assert checkout["status"] == "incomplete"
assert is_list(checkout["totals"])
assert is_list(checkout["links"])
end
test "returns error for invalid params" do
params = %{"currency" => "INVALID"}
assert {:error, _} = UCPHandler.create_checkout(params, nil)
end
end
describe "get_checkout/2" do
test "returns checkout when found" do
# Setup: create a checkout first
{:ok, created} = UCPHandler.create_checkout(valid_checkout_params(), nil)
assert {:ok, checkout} = UCPHandler.get_checkout(created["id"], nil)
assert checkout["id"] == created["id"]
end
test "returns not_found for missing checkout" do
assert {:error, :not_found} = UCPHandler.get_checkout("nonexistent", nil)
end
end
defp valid_checkout_params do
%{
"currency" => "USD",
"line_items" => [
%{"item" => %{"id" => "TEST-1"}, "quantity" => 1}
],
"payment" => %{}
}
end
endTesting with Database
defmodule MyApp.UCPHandlerTest do
use MyApp.DataCase, async: true
alias MyApp.UCPHandler
alias MyApp.Repo
setup do
# Clean up before each test
:ok
end
describe "create_checkout/2" do
test "persists checkout to database" do
params = valid_checkout_params()
{:ok, checkout} = UCPHandler.create_checkout(params, nil)
# Verify it's in the database
assert Repo.get!(MyApp.Checkout, checkout["id"])
end
end
describe "cancel_checkout/2" do
test "updates status to canceled" do
{:ok, checkout} = UCPHandler.create_checkout(valid_checkout_params(), nil)
{:ok, cancelled} = UCPHandler.cancel_checkout(checkout["id"], nil)
assert cancelled["status"] == "canceled"
# Verify in database
db_checkout = Repo.get!(MyApp.Checkout, checkout["id"])
assert db_checkout.status == :canceled
end
end
defp valid_checkout_params do
%{
"currency" => "USD",
"line_items" => [
%{"item" => %{"id" => "TEST-1"}, "quantity" => 1}
],
"payment" => %{}
}
end
endTesting Schemas
CheckoutSession Tests
defmodule Bazaar.Schemas.CheckoutSessionTest do
use ExUnit.Case, async: true
alias Bazaar.Schemas.CheckoutSession
describe "new/1" do
test "creates valid changeset with required fields" do
params = %{
"currency" => "USD",
"line_items" => [
%{"item" => %{"id" => "ABC"}, "quantity" => 1}
],
"payment" => %{}
}
changeset = CheckoutSession.new(params)
assert changeset.valid?
end
test "requires currency" do
params = %{
"line_items" => [
%{"item" => %{"id" => "ABC"}, "quantity" => 1}
],
"payment" => %{}
}
changeset = CheckoutSession.new(params)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).currency
end
test "requires line_items" do
params = %{
"currency" => "USD",
"payment" => %{}
}
changeset = CheckoutSession.new(params)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).line_items
end
test "requires payment" do
params = %{
"currency" => "USD",
"line_items" => [%{"item" => %{"id" => "ABC"}, "quantity" => 1}]
}
changeset = CheckoutSession.new(params)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).payment
end
test "validates currency format" do
params = %{
"currency" => "INVALID",
"line_items" => [%{"item" => %{"id" => "ABC"}, "quantity" => 1}],
"payment" => %{}
}
changeset = CheckoutSession.new(params)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).currency
end
test "requires at least one line item" do
params = %{
"currency" => "USD",
"line_items" => [],
"payment" => %{}
}
changeset = CheckoutSession.new(params)
refute changeset.valid?
end
test "validates line item quantity >= 1" do
params = %{
"currency" => "USD",
"line_items" => [
%{"item" => %{"id" => "ABC"}, "quantity" => 0}
],
"payment" => %{}
}
changeset = CheckoutSession.new(params)
refute changeset.valid?
end
end
# Helper to extract errors
defp errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
end
endOrder Tests
defmodule Bazaar.Schemas.OrderTest do
use ExUnit.Case, async: true
alias Bazaar.Order
describe "from_checkout/3" do
test "creates order from checkout data" do
checkout = %{
"id" => "checkout_123",
"currency" => "USD",
"line_items" => [
%{
"item" => %{"id" => "ABC", "title" => "Widget", "price" => 1999},
"quantity" => 1,
"totals" => [%{"type" => "subtotal", "amount" => 1999}]
}
],
"totals" => [
%{"type" => "subtotal", "amount" => 1999},
%{"type" => "total", "amount" => 1999}
]
}
order = Order.from_checkout(checkout, "order_456", "https://shop.example/orders/456")
assert {:ok, _} = Bazaar.Validator.validate_order(order)
assert order["id"] == "order_456"
assert order["checkout_id"] == "checkout_123"
assert [%{"quantity" => %{"total" => 1, "fulfilled" => 0}}] = order["line_items"]
end
end
describe "new/1" do
test "requires id, checkout_id, permalink_url, line_items, totals" do
changeset = Order.new(%{})
refute changeset.valid?
errors = errors_on(changeset)
assert "can't be blank" in errors.id
assert "can't be blank" in errors.checkout_id
assert "can't be blank" in errors.permalink_url
assert "can't be blank" in errors.line_items
assert "can't be blank" in errors.totals
end
end
defp errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
end
endTesting Against Official UCP Schemas
Use Bazaar.Validator to ensure your responses match the official UCP spec:
defmodule MyApp.UCPConformanceTest do
use ExUnit.Case, async: true
alias MyApp.UCPHandler
describe "checkout response conformance" do
test "checkout response matches UCP spec" do
params = valid_checkout_params()
{:ok, checkout} = UCPHandler.create_checkout(params, nil)
# Validate against official UCP JSON Schema
assert {:ok, _} = Bazaar.Validator.validate_checkout(checkout)
end
test "checkout with all fields matches UCP spec" do
checkout = %{
"ucp" => %{
"version" => "2026-01-11",
"capabilities" => [
%{"name" => "dev.ucp.shopping.checkout", "version" => "2026-01-11"}
]
},
"id" => "checkout_123",
"status" => "incomplete",
"currency" => "USD",
"line_items" => [
%{
"id" => "li_1",
"item" => %{"id" => "PROD-1", "title" => "Widget", "price" => 1999},
"quantity" => 2,
"totals" => [%{"type" => "subtotal", "amount" => 3998}]
}
],
"totals" => [
%{"type" => "subtotal", "amount" => 3998},
%{"type" => "total", "amount" => 3998}
],
"links" => [
%{"type" => "privacy_policy", "url" => "https://example.com/privacy"},
%{"type" => "terms_of_service", "url" => "https://example.com/terms"}
],
"payment" => %{"handlers" => []}
}
assert {:ok, _} = Bazaar.Validator.validate_checkout(checkout)
end
end
describe "order response conformance" do
test "order response matches UCP spec" do
order = %{
"ucp" => %{
"version" => "2026-01-11",
"capabilities" => [
%{"name" => "dev.ucp.shopping.order", "version" => "2026-01-11"}
]
},
"id" => "order_123",
"checkout_id" => "checkout_456",
"permalink_url" => "https://shop.example/orders/123",
"line_items" => [
%{
"id" => "li_1",
"item" => %{"id" => "PROD-1", "title" => "Widget", "price" => 1999},
"quantity" => %{"total" => 2, "fulfilled" => 0},
"totals" => [%{"type" => "subtotal", "amount" => 3998}],
"status" => "processing"
}
],
"fulfillment" => %{
"expectations" => [],
"events" => []
},
"totals" => [
%{"type" => "total", "amount" => 3998}
]
}
assert {:ok, _} = Bazaar.Validator.validate_order(order)
end
end
defp valid_checkout_params do
%{
"currency" => "USD",
"line_items" => [
%{"item" => %{"id" => "TEST-1"}, "quantity" => 1}
],
"payment" => %{}
}
end
endTesting Routes
Using Phoenix.ConnTest
defmodule MyAppWeb.UCPRoutesTest do
use MyAppWeb.ConnCase, async: true
describe "GET /.well-known/ucp" do
test "returns discovery profile", %{conn: conn} do
conn = get(conn, "/.well-known/ucp")
assert json_response(conn, 200)
assert json_response(conn, 200)["name"] == "My Store"
assert is_list(json_response(conn, 200)["capabilities"])
end
end
describe "POST /checkout-sessions" do
test "creates checkout with valid params", %{conn: conn} do
params = %{
"currency" => "USD",
"line_items" => [
%{"item" => %{"id" => "TEST-1"}, "quantity" => 1}
],
"payment" => %{}
}
conn = post(conn, "/checkout-sessions", params)
assert %{"id" => _id, "status" => "incomplete"} = json_response(conn, 201)
end
test "returns 422 for invalid params", %{conn: conn} do
params = %{"currency" => "INVALID"}
conn = post(conn, "/checkout-sessions", params)
assert %{"error" => "validation_error"} = json_response(conn, 422)
end
end
describe "GET /checkout-sessions/:id" do
test "returns checkout when found", %{conn: conn} do
# Create a checkout first
create_conn = post(conn, "/checkout-sessions", valid_checkout_params())
%{"id" => id} = json_response(create_conn, 201)
# Fetch it
conn = get(conn, "/checkout-sessions/#{id}")
assert json_response(conn, 200)["id"] == id
end
test "returns 404 when not found", %{conn: conn} do
conn = get(conn, "/checkout-sessions/nonexistent")
assert json_response(conn, 404)["error"] == "not_found"
end
end
describe "DELETE /checkout-sessions/:id" do
test "cancels checkout", %{conn: conn} do
# Create a checkout first
create_conn = post(conn, "/checkout-sessions", valid_checkout_params())
%{"id" => id} = json_response(create_conn, 201)
# Cancel it
conn = delete(conn, "/checkout-sessions/#{id}")
assert json_response(conn, 200)["status"] == "canceled"
end
end
defp valid_checkout_params do
%{
"currency" => "USD",
"line_items" => [
%{"item" => %{"id" => "TEST-1"}, "quantity" => 1}
],
"payment" => %{}
}
end
endTesting Plugs
UCPHeaders Plug
defmodule Bazaar.Plugs.UCPHeadersTest do
use ExUnit.Case, async: true
import Plug.Test
import Plug.Conn
alias Bazaar.Plugs.UCPHeaders
test "extracts UCP-Agent header" do
conn =
conn(:post, "/")
|> put_req_header("ucp-agent", "test-agent/1.0")
|> UCPHeaders.call([])
assert conn.assigns[:ucp_agent] == "test-agent/1.0"
end
test "generates request ID when not provided" do
conn =
conn(:post, "/")
|> UCPHeaders.call([])
assert conn.assigns[:ucp_request_id]
assert String.starts_with?(conn.assigns[:ucp_request_id], "req_")
end
test "uses provided request ID" do
conn =
conn(:post, "/")
|> put_req_header("ucp-request-id", "custom_id")
|> UCPHeaders.call([])
assert conn.assigns[:ucp_request_id] == "custom_id"
end
endValidateRequest Plug
defmodule Bazaar.Plugs.ValidateRequestTest do
use ExUnit.Case, async: true
import Plug.Test
import Plug.Conn
alias Bazaar.Plugs.ValidateRequest
setup do
{:ok, opts: ValidateRequest.init([])}
end
test "validates create_checkout action", %{opts: opts} do
conn =
conn(:post, "/checkout-sessions")
|> put_private(:phoenix_action, :create_checkout)
|> Map.put(:params, %{
"currency" => "USD",
"line_items" => [%{"item" => %{"id" => "ABC"}, "quantity" => 1}],
"payment" => %{}
})
|> ValidateRequest.call(opts)
refute conn.halted
assert conn.assigns[:bazaar_validated]
end
test "returns 422 for invalid request", %{opts: opts} do
conn =
conn(:post, "/checkout-sessions")
|> put_private(:phoenix_action, :create_checkout)
|> Map.put(:params, %{"currency" => "INVALID"})
|> ValidateRequest.call(opts)
assert conn.halted
assert conn.status == 422
end
endIdempotency Plug
defmodule Bazaar.Plugs.IdempotencyTest do
use ExUnit.Case, async: false
import Plug.Test
import Plug.Conn
alias Bazaar.Plugs.Idempotency
alias Bazaar.Plugs.Idempotency.ETSCache
setup do
# Clear cache between tests
if :ets.whereis(:bazaar_idempotency_cache) != :undefined do
:ets.delete_all_objects(:bazaar_idempotency_cache)
end
{:ok, opts: Idempotency.init([])}
end
test "passes through without idempotency key", %{opts: opts} do
conn =
conn(:post, "/checkout-sessions")
|> Idempotency.call(opts)
refute conn.halted
end
test "returns cached response for duplicate request", %{opts: opts} do
cache_key = "POST:/checkout-sessions:test-key"
ETSCache.put(cache_key, %{
status: 201,
headers: [],
body: ~s({"id":"cached"})
}, 3600)
conn =
conn(:post, "/checkout-sessions")
|> put_req_header("idempotency-key", "test-key")
|> Idempotency.call(opts)
assert conn.halted
assert conn.status == 201
assert get_resp_header(conn, "idempotency-replay") == ["true"]
end
endIntegration Tests
Full Flow Test
defmodule MyAppWeb.CheckoutFlowTest do
use MyAppWeb.ConnCase, async: true
test "complete checkout flow", %{conn: conn} do
# 1. Create checkout
create_params = %{
"currency" => "USD",
"line_items" => [
%{"item" => %{"id" => "LAPTOP-1"}, "quantity" => 1}
],
"payment" => %{}
}
conn = post(conn, "/checkout-sessions", create_params)
assert %{"id" => checkout_id, "status" => "incomplete"} = json_response(conn, 201)
# 2. Update with buyer info
update_params = %{
"buyer" => %{
"first_name" => "Test",
"last_name" => "User",
"email" => "test@example.com"
},
"shipping_address" => %{
"street_address" => "123 Main St",
"address_locality" => "NYC",
"address_region" => "NY",
"postal_code" => "10001",
"address_country" => "US"
}
}
conn = patch(conn, "/checkout-sessions/#{checkout_id}", update_params)
assert json_response(conn, 200)["buyer"]["email"] == "test@example.com"
# 3. Get checkout to verify
conn = get(conn, "/checkout-sessions/#{checkout_id}")
checkout = json_response(conn, 200)
assert checkout["id"] == checkout_id
assert checkout["buyer"]["email"] == "test@example.com"
end
endTest Helpers
Create a test helper module:
# test/support/ucp_helpers.ex
defmodule MyApp.UCPHelpers do
def valid_checkout_params(overrides \\ %{}) do
Map.merge(
%{
"currency" => "USD",
"line_items" => [
%{
"item" => %{"id" => "TEST-#{System.unique_integer([:positive])}"},
"quantity" => 1
}
],
"payment" => %{}
},
overrides
)
end
def valid_checkout_response(overrides \\ %{}) do
Map.merge(
%{
"id" => "checkout_#{System.unique_integer([:positive])}",
"status" => "incomplete",
"currency" => "USD",
"line_items" => [
%{
"item" => %{"id" => "PROD-1", "title" => "Widget", "price" => 1999},
"quantity" => 1,
"totals" => [%{"type" => "subtotal", "amount" => 1999}]
}
],
"totals" => [
%{"type" => "subtotal", "amount" => 1999},
%{"type" => "total", "amount" => 1999}
],
"links" => [
%{"type" => "privacy_policy", "url" => "https://example.com/privacy"},
%{"type" => "terms_of_service", "url" => "https://example.com/terms"}
],
"payment" => %{"handlers" => []}
},
overrides
)
end
def valid_order_response(overrides \\ %{}) do
Map.merge(
%{
"id" => "order_#{System.unique_integer([:positive])}",
"checkout_id" => "checkout_123",
"permalink_url" => "https://shop.example/orders/123",
"line_items" => [
%{
"item" => %{"id" => "PROD-1", "title" => "Widget", "price" => 1999},
"quantity" => 1,
"totals" => [%{"type" => "subtotal", "amount" => 1999}]
}
],
"totals" => [%{"type" => "total", "amount" => 1999}],
"fulfillment" => %{"expectations" => [], "events" => []}
},
overrides
)
end
endRunning Tests
# Run all tests
mix test
# Run with coverage
mix test --cover
# Run specific file
mix test test/my_app/ucp_handler_test.exs
# Run specific test
mix test test/my_app/ucp_handler_test.exs:42
Tips
Use async: true
Most Bazaar tests can run in parallel:
use ExUnit.Case, async: trueException: Tests using the idempotency ETS cache should use async: false.
Test Edge Cases
Always test:
- Missing required fields
- Invalid field values
- Not found scenarios
- State transition errors (e.g., cancelling already cancelled)
Test UCP Conformance
Use Bazaar.Validator to ensure your responses match the official UCP spec:
test "response matches UCP spec" do
{:ok, checkout} = MyHandler.create_checkout(params, nil)
assert {:ok, _} = Bazaar.Validator.validate_checkout(checkout)
endMock External Services
For handlers that call external APIs:
# Using Mox
defmock(MyApp.PaymentMock, for: MyApp.PaymentBehaviour)
test "handles payment failure" do
expect(MyApp.PaymentMock, :charge, fn _ ->
{:error, :card_declined}
end)
# ... test code
endNext Steps
- Review Handlers Guide for callback patterns
- Check Schemas Guide for validation details
- See Plugs Guide for middleware testing