ExUnit ergonomics for Stripe Test Clocks.
This module provides a use-macro that users opt into from their own
ExUnit.CaseTemplate. It wraps LatticeStripe.TestHelpers.TestClock
with automatic per-test cleanup (via a lightweight GenServer Owner),
automatic customer-to-clock linkage (closing the silent-correctness
footgun of forgetting test_clock: on Customer.create/2), and a
human-friendly advance/2 that takes time units.
Usage
defmodule MyApp.StripeCase do
use ExUnit.CaseTemplate
using do
quote do
use LatticeStripe.Testing.TestClock, client: MyApp.StripeClient
end
end
end
defmodule MyApp.BillingTest do
use MyApp.StripeCase, async: true
setup :with_test_clock
test "sub renews after 30 days", %{test_clock: clock} do
customer = create_customer(clock, email: "a@b.c")
{:ok, sub} = MyApp.Billing.subscribe(customer, "price_monthly")
advance(clock, days: 30)
assert {:ok, %{status: "active"}} = MyApp.Billing.get_subscription(sub.id)
end
endClient contract
The module passed as :client must be either:
- A
%LatticeStripe.Client{}struct directly, or - A module atom that exposes a public
stripe_client/0function returning a%LatticeStripe.Client{}.
Every helper also accepts a per-call :client option (a %Client{} struct)
that wins over the compile-time binding. This supports multi-account tests.
Cleanup strategy
Every clock created via test_clock/1 is registered with a per-test
Owner GenServer (internal, not part of the public API). On test exit
(including crash / assertion failure), the Owner deletes each
registered clock via LatticeStripe.TestHelpers.TestClock.delete/3.
Stripe's delete cascades to attached Customers and Subscriptions.
For SIGKILL / BEAM crash / CI timeout scenarios that bypass on_exit,
the mix lattice_stripe.test_clock.cleanup task backstops by deleting
test clocks older than a configurable threshold.
Metadata marker (A-13g caveat)
Stripe's Test Clock API does not support metadata on create
(verified via OpenAPI spec and stripe-mock on 2026-04-11). This means
the Mix task cleanup backstop uses age-based filtering only and
cannot distinguish LatticeStripe-managed clocks from user-created ones.
The primary cleanup path (Owner + on_exit) is unaffected.
If Stripe adds metadata support in the future, this module will be updated to tag clocks with a marker for precise Mix task filtering.
Supported advance units (v1)
advance/2 accepts: :seconds, :minutes, :hours, :days, or :to
(absolute DateTime). Passing :months or :years raises
ArgumentError -- Elixir 1.15 (the project minimum) has no calendar
shift helper, and month-length arithmetic is fiddly. For month/year
advancement, use:
advance(clock, to: DateTime.utc_now() |> DateTime.add(86_400 * 30, :second))Customer-to-clock linkage (D-13h)
create_customer/2,3 auto-injects test_clock: clock.id into the
customer creation params. This closes the silent-correctness footgun
where forgetting test_clock: means the customer runs on real time
and clock advances have no effect. Users who bypass this wrapper and
call LatticeStripe.Customer.create/2 directly are responsible for
injecting the test_clock param themselves.
Summary
Functions
Advances a test clock by a given unit, waiting until :ready.
Creates a customer attached to the given test clock.
Waits for a test clock to reach :ready at its current frozen_time.
Useful after out-of-band clock ops.
Creates a test clock and registers it for automatic cleanup.
ExUnit setup callback. Creates a test clock and injects it into the
context as :test_clock.
Functions
Advances a test clock by a given unit, waiting until :ready.
Supported units (v1)
[seconds: N][minutes: N][hours: N][days: N][to: %DateTime{}]-- absolute target
[months: N] and [years: N] raise ArgumentError -- Elixir 1.15 has
no calendar shift helper. Use [to: DateTime] with hand-computed month
math if needed.
Options
:client-- per-call client override (%LatticeStripe.Client{}). When omitted, falls back to the process-bound client from theusemacro.
Example
advance(clock, days: 30)
advance(clock, days: 30, client: my_client)
Creates a customer attached to the given test clock.
Wraps LatticeStripe.Customer.create/2 and auto-injects
test_clock: clock.id into the params. This closes the D-13h
silent-correctness footgun of forgetting test_clock: and having
the customer run on real time.
Example
customer = create_customer(clock, email: "a@b.c")
Waits for a test clock to reach :ready at its current frozen_time.
Useful after out-of-band clock ops.
Creates a test clock and registers it for automatic cleanup.
Options
:frozen_time-- unix timestamp integer (default: current system time):name-- human-readable name (default:"lattice_stripe_test"):client-- per-call client override (%LatticeStripe.Client{})
Example
clock = test_clock(frozen_time: ~U[2026-01-01 00:00:00Z] |> DateTime.to_unix())
ExUnit setup callback. Creates a test clock and injects it into the
context as :test_clock.
Usage
setup :with_test_clock
test "sub renews", %{test_clock: clock} do
...
end