Use memorypayment when a test needs coherent local payment state. Use
paymenttest.Fake when it needs an exact provider reply, including a lost
response or missing relationship. Neither moves money, runs a buyer UI, or
schedules renewals.
The fragments belong inside your application tests with t *testing.T and
ctx := t.Context(). Import arkoren.dev/core/service/payment plus the simulator
or fake subpackage used below. They demonstrate the provider boundary; inject
the resulting payments client into your real application helpers to test
authorization, durable recording, and fulfillment policy.
Simulating Payments
Start a fresh simulator per test. It is bundled with core and requires no account:
import "arkoren.dev/core/service/payment/memorypayment"
simulator := memorypayment.New()
payments := payment.New(simulator)
Create an ordinary checkout with an explicit amount and key, as in
Hosted Checkout. With that checkout
already created, supply buyer approval and merchant completion separately:
if err := simulator.ApproveCheckout(ctx, checkout.ID); err != nil {
t.Fatal(err)
}
checkout, err = payments.CompleteCheckout(ctx,
payment.NewCompleteCheckoutRequest(checkout.ID, "book-184-complete"))
if err != nil {
t.Fatal(err)
}
if checkout.PaymentStatus != payment.StatusPending {
t.Fatal("completion must leave collection pending")
}
Completion creates a pending payment, not financial success. Resolve it explicitly before reading through the facade:
if err := simulator.ResolvePayment(ctx, checkout.PaymentID, payment.StatusSucceeded); err != nil {
t.Fatal(err)
}
observed, err := payments.GetPayment(ctx, checkout.PaymentID)
if err != nil {
t.Fatal(err)
}
if observed.Status != payment.StatusSucceeded {
t.Fatalf("payment status = %q", observed.Status)
}
The read reports the state you supplied; it proves no external settlement.
Repeating completion cannot create another payment. Advancing time changes
neither pending settlement nor recurring collection. ResolveSubscription
supplies agreement lifecycle separately and creates no invoice/payment.
Scripting Outcomes
For a policy test that must see a pending payment, script that exact reply.
With a checked amount from payment.NewMoney("EUR", 2500):
import "arkoren.dev/core/service/payment/paymenttest"
fake := paymenttest.New()
fake.ReplyGetPayment(payment.Payment{
ID: "payment-test",
Amount: amount,
Status: payment.StatusPending,
}, nil)
payments := payment.New(fake)
Call your application’s payment-checking operation with that client and assert
that it does not fulfill. Scripts are independent FIFO queues per operation:
a completion reply never creates a payment-read reply or a ledger entry.
A valid unexpected call returns paymenttest.ErrUnexpectedCall, not invented
success.
Attempted() and Accepted() return copied typed histories. Inspect
Attempted().ReadPayments for reads or use mutation assertion helpers with
testing.TB and request predicates. Acceptance models what the test declares
accepted, not successful collection.
FailNext(err) overrides the next valid operation without consuming its script;
FailWith(err) persists until cleared. One-shot failure takes precedence.
Invalid input consumes neither failure. Reset() clears scripts and history.
These controls are not a financial ledger or provider replay simulator.
Testing Uncertainty
To model accepted creation with a lost reply, queue an observed checkout ID with
ErrOutcomeUnknown, then separately queue a later retrieval:
fake.ReplyCreateCheckout(payment.Checkout{
ID: "checkout-test",
}, payment.ErrOutcomeUnknown)
fake.ReplyGetCheckout(payment.Checkout{
ID: "checkout-test",
Status: payment.CheckoutStatusOpen,
}, nil)
Invoke your real creation/reconciliation helpers and assert they save the
original request/key, merchant/environment, and returned ID. Accepted() records
the modeled effect even with the uncertain error. For accepted cancellation
whose reply was lost, use ReplyCanceledSubscription(payment.ErrOutcomeUnknown);
ordinary ReplyCancelSubscription(err) does not declare acceptance with an error.
Also test creation with no returned ID, followed by a known rejection on a later same-key call:
fake.ReplyCreateCheckout(payment.Checkout{}, payment.ErrOutcomeUnknown)
fake.ReplyCreateCheckout(payment.Checkout{}, payment.ErrUnauthorized)
After your application consumes these replies, assert that durable uncertainty
remains, the original request/key stays unchanged, and neither fresh-key dispatch
nor fulfillment is authorized. The later error does not establish the earlier
provider effect. A missing fake Accepted() entry reflects the script, not
proof that a real provider did nothing.
With no resource ID, portable lookup by key/Reference is unavailable. Test that your recovery policy respects provider replay scope/retention or requires investigation instead of manufacturing an ID or another charge.
Testing Handoffs
For Mobile Payments, use a fictitious protocol to isolate backend handling from a native SDK:
fake.ReplyPreparePayment(payment.Preparation{
CheckoutID: "checkout-test",
Action: payment.NewClientOrder("example.native", "client-order-test"),
}, nil)
Script ReplyGetCheckout with an approved checkout, expected amount, and
NeedsCompletion: true; then script ReplyCompleteCheckout with a financial
PaymentID. Queue pending and succeeded ReplyGetPayment replies independently.
Call the application’s actual helpers and assert that approval is checked,
completion uses its saved key, and only the matching succeeded payment permits
one fulfillment commit.
For refund progress, submit a pending reply independently of the later read.
With saved paymentID and explicit refundAmount:
fake.ReplyRefundPayment(payment.Refund{
ID: "refund-test",
PaymentID: paymentID,
Amount: refundAmount,
Status: payment.RefundStatusPending,
}, nil)
fake.ReplyGetRefund(payment.Refund{
ID: "refund-test",
PaymentID: paymentID,
Amount: refundAmount,
Status: payment.RefundStatusSucceeded,
}, nil)
Assert that your application reports the first outcome as pending and only the second as confirmed refund success. Also test mismatched/missing references, unapproved checkout, protected/no-store JSON, owner-only resume, secret-log exclusions, recording crashes, and concurrent requests. Fake success does not prove native approval, persistence, or HTTP security.
Checking Conformance
If you implement a driver for your application, use paymenttest.Run with a
fresh Harness per scenario. Its complete Driver and bounded fixture hooks
supply approval, settlement, and subscription activation without waiting for
a real buyer. The factory owns cleanup through t.
Full journeys need PlanReference and ActivateSubscription. Harness.Amount
selects a positive supported one-off amount; zero selects 1200 USD, and refunds
use the observed payment currency. If completion exposes no PaymentID, provide
ResolvePaymentID for one bounded fixture observation. The suite neither polls
nor invents the relationship.
| Option | Declared limitation |
|---|---|
WithoutApproval() |
Omits lifecycle journeys, retaining validation and standalone unsupported subscription/refund-lookup checks. Completion/refund-creation checks are omitted with their journey; it does not declare approval unsupported. |
WithoutCompletion() |
Requires ErrUnsupported and an unchanged approved checkout that does not need completion. |
WithoutRefunds() |
Requires unsupported refund creation without changing the original payment. |
WithoutRefundLookup() |
Requires unsupported refund reads; creation still runs unless separately disabled. |
WithoutSubscriptions() |
Requires unsupported enrollment, lookup, and cancellation; no plan/activation hook is needed. |
WithoutPreparation() |
Requires unsupported valid preparation with no handoff; preparation checks remain independent of hosted approval. |
With factory func(*testing.T) paymenttest.Harness supplying fresh controlled
Mollie fixtures, supported EUR amounts, and approval/settlement hooks, declare
only the operations actually unsupported:
paymenttest.Run(t, factory,
paymenttest.WithoutPreparation(),
paymenttest.WithoutCompletion(),
paymenttest.WithoutSubscriptions(),
)
This still exercises one-off payments and refunds. Do not add WithoutApproval to hide absent hooks: doing so omits the journey rather than proving it.
Sandbox Boundaries
Wire fixtures prove serialization, mapping, and attempt counts—not live settlement. The existing opt-in provider checks also have narrower scopes than a buyer journey:
| Provider | Opt-in coverage | Not established |
|---|---|---|
| Stripe | Intent preparation/read and synthetic test-card confirmation with refund/read. | Native devices, PaymentSheet, wallets, 3DS, or signed delivery. |
| PayPal | Unapproved hosted sandbox Order creation/read. | Native preparation, SDK approval, capture, subscription collections, or signed delivery. |
| Mollie | Read-only profile authentication and optional existing test payment read. | Hosted creation/approval, refunds, or callbacks. |
These describe coverage, not tests run for this guide. Never substitute production credentials or infer provider retention from a local fixture. With a dedicated test merchant and your own opt-in configuration, separately exercise hosted buyer approval, explicit reads/completion, partial refund progress, and supported subscription approval/cancellation. Stripe/PayPal notification verification needs real signed deliveries, not just synthetic JSON; Mollie needs the unsigned-hint route plus authoritative API read.
Test native UI separately with the provider’s test-mode SDK/device setup, including cancellation and additional authentication. PayPal SDK approval still needs explicit server capture. Client UI success must never bypass backend ownership and collection checks.
For controls, histories, and harness options, see the paymenttest API and memorypayment API.