This guide covers how to test application code that uses LatticeStripe. LatticeStripe is designed
to be testable: the Transport behaviour is mockable with Mox,
webhook helpers are included in the library itself, and
stripe-mock provides a real HTTP server for integration
tests validated against Stripe's OpenAPI spec.
For Stripe's official testing documentation (test card numbers, bank accounts, etc.), see Stripe Testing docs.
Mocking with Mox
LatticeStripe uses a Transport behaviour for all HTTP calls. In your tests, you can replace
the real Finch transport with a Mox mock — no HTTP calls, no external dependencies, full control
over responses.
Step 1: Define the mock in your test support
# In test/support/mocks.ex (or anywhere compiled by elixirc_paths(:test))
Mox.defmock(MyApp.MockTransport, for: LatticeStripe.Transport)Make sure test/support/ is compiled in your mix.exs:
# mix.exs
def project do
[
# ...
elixirc_paths: elixirc_paths(Mix.env())
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]Step 2: Configure the mock as the default in test env
# config/test.exs
config :my_app, :stripe_transport, MyApp.MockTransportOr build the client with the mock transport directly in each test (recommended for explicitness):
client = LatticeStripe.Client.new!(
api_key: "sk_test_mock",
finch: MyApp.Finch,
transport: MyApp.MockTransport
)Step 3: Write tests with expect/3
defmodule MyApp.PaymentsTest do
use ExUnit.Case, async: true
import Mox
setup :verify_on_exit!
test "creates a payment intent successfully" do
MyApp.MockTransport
|> expect(:request, fn request ->
# Assert the request shape
assert request.method == :post
assert request.url =~ "/v1/payment_intents"
# Return a mock Stripe response
{:ok, %{
status: 200,
headers: [{"request-id", "req_test123"}],
body: Jason.encode!(%{
"id" => "pi_test123",
"object" => "payment_intent",
"amount" => 2000,
"currency" => "usd",
"status" => "requires_payment_method",
"livemode" => false
})
}}
end)
client = LatticeStripe.Client.new!(
api_key: "sk_test_mock",
finch: MyApp.Finch,
transport: MyApp.MockTransport
)
assert {:ok, %LatticeStripe.PaymentIntent{amount: 2000, currency: "usd"}} =
LatticeStripe.PaymentIntent.create(client, %{
"amount" => 2000,
"currency" => "usd"
})
end
test "handles card declined error" do
MyApp.MockTransport
|> expect(:request, fn _request ->
{:ok, %{
status: 402,
headers: [{"request-id", "req_declined"}],
body: Jason.encode!(%{
"error" => %{
"type" => "card_error",
"code" => "card_declined",
"decline_code" => "insufficient_funds",
"message" => "Your card has insufficient funds.",
"doc_url" => "https://docs.stripe.com/error-codes/card-declined"
}
})
}}
end)
client = LatticeStripe.Client.new!(
api_key: "sk_test_mock",
finch: MyApp.Finch,
transport: MyApp.MockTransport
)
assert {:error, %LatticeStripe.Error{
type: :card_error,
decline_code: "insufficient_funds",
request_id: "req_declined"
}} = LatticeStripe.PaymentIntent.create(client, %{
"amount" => 2000,
"currency" => "usd"
})
end
endMocking Multiple Calls in Sequence
Use expect/3 multiple times — each call consumes the next expectation in order:
test "retries on rate limit then succeeds" do
rate_limit_body = Jason.encode!(%{
"error" => %{
"type" => "rate_limit_error",
"message" => "Too many requests"
}
})
success_body = Jason.encode!(%{
"id" => "cus_test123",
"object" => "customer",
"email" => "user@example.com"
})
MyApp.MockTransport
|> expect(:request, fn _req ->
{:ok, %{status: 429, headers: [], body: rate_limit_body}}
end)
|> expect(:request, fn _req ->
{:ok, %{status: 200, headers: [{"request-id", "req_ok"}], body: success_body}}
end)
# Configure client with max_retries: 1 to test exactly one retry
client = LatticeStripe.Client.new!(
api_key: "sk_test_mock",
finch: MyApp.Finch,
transport: MyApp.MockTransport,
max_retries: 1
)
assert {:ok, %LatticeStripe.Customer{email: "user@example.com"}} =
LatticeStripe.Customer.create(client, %{"email" => "user@example.com"})
endMocking Connection Errors
test "returns connection_error on network failure" do
MyApp.MockTransport
|> expect(:request, fn _req ->
{:error, %Mint.TransportError{reason: :econnrefused}}
end)
client = LatticeStripe.Client.new!(
api_key: "sk_test_mock",
finch: MyApp.Finch,
transport: MyApp.MockTransport,
max_retries: 0 # don't retry in this test
)
assert {:error, %LatticeStripe.Error{type: :connection_error}} =
LatticeStripe.Customer.create(client, %{"email" => "user@example.com"})
endTesting Webhook Handlers
LatticeStripe ships LatticeStripe.Testing — a module included in the library itself (not just in
test support) that generates realistic signed webhook payloads. You don't need to understand Stripe's
HMAC signing scheme to test webhook handling.
Testing Event Handler Logic
For testing your webhook business logic without any HTTP layer:
defmodule MyApp.WebhookHandlerTest do
use ExUnit.Case, async: true
alias LatticeStripe.Testing
test "handles payment_intent.succeeded" do
event = Testing.generate_webhook_event("payment_intent.succeeded", %{
"id" => "pi_test123",
"amount" => 2000,
"currency" => "usd",
"status" => "succeeded",
"metadata" => %{"order_id" => "order_456"}
})
assert {:ok, :processed} = MyApp.WebhookHandler.handle(event)
end
test "ignores unknown event types gracefully" do
event = Testing.generate_webhook_event("customer.subscription.created", %{
"id" => "sub_test789"
})
assert {:ok, :ignored} = MyApp.WebhookHandler.handle(event)
end
endTesting Webhook Plug Endpoint
For testing the full HTTP path — signature verification through to your handler:
defmodule MyApp.WebhookPlugTest do
use ExUnit.Case, async: true
use Plug.Test
alias LatticeStripe.Testing
@webhook_secret "whsec_test_supersecret"
test "accepts valid signed webhook" do
{payload, sig_header} = LatticeStripe.Testing.generate_webhook_payload(
"payment_intent.succeeded",
%{"id" => "pi_test123", "amount" => 2000},
secret: @webhook_secret
)
conn =
conn(:post, "/webhooks/stripe", payload)
|> put_req_header("stripe-signature", sig_header)
|> put_req_header("content-type", "application/json")
conn = MyApp.Router.call(conn, [])
assert conn.status == 200
end
test "rejects webhook with invalid signature" do
{payload, _valid_sig} = LatticeStripe.Testing.generate_webhook_payload(
"payment_intent.succeeded",
%{"id" => "pi_test123"},
secret: @webhook_secret
)
conn =
conn(:post, "/webhooks/stripe", payload)
|> put_req_header("stripe-signature", "t=12345,v1=invalidsignature")
|> put_req_header("content-type", "application/json")
conn = MyApp.Router.call(conn, [])
assert conn.status == 400
end
endThe generate_webhook_payload/3 function returns a {raw_json_string, stripe_signature_header}
tuple. The signature is computed using the same HMAC algorithm Stripe uses, so Webhook.construct_event/4
will accept it.
Using stripe-mock
For integration tests that verify real request/response shapes against Stripe's actual API spec, use stripe-mock. It's an official Stripe server powered by Stripe's OpenAPI spec — if stripe-mock accepts your request, the real Stripe API will too.
Starting stripe-mock
# Run via Docker (recommended for CI)
docker run -d -p 12111:12111 -p 12112:12112 stripe/stripe-mock:latest
# Or via Homebrew on macOS
brew install stripe/stripe-mock/stripe-mock
stripe-mock &
Integration Test Client
Point a client at stripe-mock:
defmodule MyApp.IntegrationTest do
use ExUnit.Case
# Guard: skip if stripe-mock isn't running
setup_all do
case :gen_tcp.connect(~c"localhost", 12111, [], 1_000) do
{:ok, socket} ->
:gen_tcp.close(socket)
:ok
{:error, _} ->
raise "stripe-mock is not running. Start it with:\n " <>
"docker run -p 12111:12111 -p 12112:12112 stripe/stripe-mock:latest"
end
end
defp stripe_mock_client do
LatticeStripe.Client.new!(
api_key: "sk_test_123",
finch: MyApp.Finch,
base_url: "http://localhost:12111"
)
end
test "creates a customer via stripe-mock" do
client = stripe_mock_client()
assert {:ok, %LatticeStripe.Customer{} = customer} =
LatticeStripe.Customer.create(client, %{
"email" => "integration@example.com",
"name" => "Integration Test User"
})
assert customer.email == "integration@example.com"
assert is_binary(customer.id)
assert String.starts_with?(customer.id, "cus_")
end
test "lists customers via stripe-mock" do
client = stripe_mock_client()
assert {:ok, %LatticeStripe.List{}} =
LatticeStripe.Customer.list(client)
end
endstripe-mock in CI (GitHub Actions)
# .github/workflows/ci.yml
services:
stripe-mock:
image: stripe/stripe-mock:latest
ports:
- 12111:12111
- 12112:12112Then your integration tests connect to http://localhost:12111 in CI automatically.
Test Helper Patterns
Shared Client Factory
Avoid repeating client setup in every test by extracting a helper:
# test/support/stripe_helpers.ex
defmodule MyApp.StripeHelpers do
def mock_client do
LatticeStripe.Client.new!(
api_key: "sk_test_mock",
finch: MyApp.Finch,
transport: MyApp.MockTransport
)
end
def stripe_mock_client do
LatticeStripe.Client.new!(
api_key: "sk_test_123",
finch: MyApp.Finch,
base_url: "http://localhost:12111"
)
end
endAsync Test Compatibility
Mox is safe for async: true tests when using verify_on_exit! in setup:
defmodule MyApp.PaymentsTest do
use ExUnit.Case, async: true # safe with Mox
import Mox
setup :verify_on_exit!
# ...
endUnder the hood, Mox stores expectations in the test process dictionary, so concurrent tests don't share expectations.
Disabling Telemetry in Tests
By default, telemetry events are emitted even with a mock transport. To disable them:
client = LatticeStripe.Client.new!(
api_key: "sk_test_mock",
finch: MyApp.Finch,
transport: MyApp.MockTransport,
telemetry_enabled: false
)Common Pitfalls
Mock response bodies must be valid JSON strings
The Transport callback receives and must return raw HTTP data. The response body must be a JSON string, not an Elixir map:
# Wrong: body is a map — LatticeStripe will try to JSON-decode a map and fail
{:ok, %{status: 200, headers: [], body: %{"id" => "cus_123"}}}
# Correct: body is a JSON string
{:ok, %{status: 200, headers: [{"request-id", "req_test"}], body: Jason.encode!(%{"id" => "cus_123", "object" => "customer"})}}Include the object field in mock response bodies
LatticeStripe uses the "object" field in Stripe responses for certain validations. Always include
it:
%{
"id" => "pi_test123",
"object" => "payment_intent", # required
"amount" => 2000,
"currency" => "usd",
"status" => "requires_payment_method"
}Use verify_on_exit! to catch unused expectations
Without it, a test that expects 2 calls but only makes 1 will silently pass:
setup :verify_on_exit! # catches: "expected 2 calls, got 1"Don't use ExVCR or cassette recording
Stripe's API evolves frequently. Cassettes become stale and hide real behavior. Use Mox for unit tests (control the response) and stripe-mock for integration tests (validates against real spec).
stripe-mock validates against Stripe's OpenAPI spec
If stripe-mock rejects a request with a 400 or 422, it means your request shape doesn't match Stripe's API contract — that's a real bug. stripe-mock is more strict than just passing tests.
Test keys must use sk_test_ prefix
The real Stripe API rejects test key formatting issues. With stripe-mock, any sk_test_ value works.
Never use real API keys in tests.