Testing
This page is about testing your application. It opens with how the package tests itself, because that is the evidence behind everything below — but those are the package's own commands, run in a checkout of the package. Nothing here is something you run in your app.
How the package proves itself
Not something to copy into your project. It is here so you can see what "tested" means before you decide how much of it you need to repeat:
composer test # unit + feature + cross-engine (Postgres + MySQL)
composer qa # style, static analysis, type + line coverage
The Unit and Feature suites run on fast in-memory SQLite; two further suites re-run against real PostgreSQL and MySQL 8.4 so engine-specific behavior is proven on the databases Laravel Cloud runs. That matters to you indirectly: it is why a query this package issues behaves the same on your deployment as it does here.
Two more suites, outside the default run, drive the providers' own test environments: tests/LiveStripe
against Stripe's test mode and tests/LiveMollie against Mollie's test API. A faked client accepts whatever
it is handed, so these suites are what shows that a provider takes the requests this package sends.
The Stripe suite also lets time pass, on Stripe's test clocks: a trial that turns into a paid period, the renewal at the end of that period, a renewal the card refuses, a coupon that runs out, and two subscriptions of one customer. The state Stripe reaches goes through the webhook receiver as the event Stripe wrote, signed with the endpoint secret, and a forged signature is refused.
The Mollie suite starts a subscription with the first payment on Mollie's checkout, registers a direct debit mandate given on the application's form, lets the engine draw a due cycle as a recurring debit over that mandate, reads the payment back through the webhook, has a refund of an unpaid payment refused, and opens a sale on a virtual point-of-sale terminal. Mollie's test mode keeps every SEPA direct debit pending and completes a first payment only on its hosted checkout. A settled cycle, the invoice it raises, a refund of a paid payment and a chargeback are therefore proven by the feature suite, against the shapes of Mollie's SDK.
Testing your own billing logic
You do not need to reach Stripe to test the code you build on this package. Two seams make it straightforward.
Assert on the neutral domain events. Every webhook the package processes is translated into a
provider-neutral event (SubscriptionStateChanged, AddonPurchased, PaymentFailed, InvoiceFinalized,
MandateRevoked, …) and dispatched through Laravel's own dispatcher, so Event::fake() and
Event::assertDispatched() work exactly as they do for your app's events:
use Illuminate\Support\Facades\Event;
use Pushery\Billing\Events\SubscriptionStateChanged;
Event::fake([SubscriptionStateChanged::class]);
// … deliver a webhook, or run a reconcile …
Event::assertDispatched(SubscriptionStateChanged::class,
fn (SubscriptionStateChanged $e) => $e->tierKey === 'pro');
Fake the outbound actions. The provider-mutating seams are contracts, so bind a fake to keep a test off the network and assert on what your code asked for:
use Pushery\Billing\Contracts\SubscriptionActions;
$actions = Mockery::spy(SubscriptionActions::class);
$this->app->instance(SubscriptionActions::class, $actions);
// … exercise the code that swaps a plan …
$actions->shouldHaveReceived('swap')->with($user, 'pro', true);
The same applies to Checkout (open a subscription) and OneTimeCharge (buy an add-on). No card data, no
live call, no webhook secret needed.
Or use the recording fake. For the common case, Billing::fake() binds a recording fake to the seams that
charge a buyer (Checkout, StartsSubscriptions, SubscriptionActions and OneTimeCharge) and to the receiving
side (MerchantOnboarding and the CanReceiveMoney gate) at once, and gives you ready-made assertions — the same
shape as Bus::fake():
use Pushery\Billing\Facades\Billing;
Billing::fake();
// … exercise the code that subscribes the user and buys an add-on …
Billing::assertSubscribeStarted($user, 'pro');
Billing::assertSwapped($user, 'premium');
Billing::assertNothingCharged();
An assertion without Billing::fake() before it throws a LogicException. Nothing would have recorded what
the code under test did, so assertNothingCharged() would pass over a real charge.
To assert the coupon, the declaration reference, the contract type or your own key too, use the value-carrying methods. They are separate methods rather than optional parameters on the two above, because null has to mean something definite: it asserts that no coupon (or no reference) was passed, which is the case worth proving if you validate a code on your own order form before checkout.
Billing::assertSubscribeStartedWithCoupon($user, 'pro', 'WELCOME10');
Billing::assertSubscribeStartedWithCoupon($user, 'pro', null); // and no coupon reached the seam
Billing::assertSubscribeStartedWithDeclaration($user, 'pro', 'DECL-42');
Billing::assertPurchasedWithDeclaration($user, 'extra-seat', 'DECL-42');
Billing::assertSubscribeStartedWithType($user, 'pro', 'sponsorship');
Billing::assertSubscribeStartedWithCallerReference($user, 'pro', 'ORDER-1001');
This matters if you show a discount before the buyer commits. The package deliberately does not fail a checkout on an unknown coupon code — the provider decides what a code is worth, and refusing here would turn a typo into a lost sale. On your own order form that is the wrong default, because a discount displayed and never granted is a problem you own. Assert that the code you validated is the one that reached the seam.
Tips and the receiving side are recorded by the same fake. assertTipped() holds the three things a tip carries together: the owner, the amount the buyer chose and the supply it was paid on. The receive gate answers no until you call Billing::fake()->allowMerchantsToReceive(), because a fake that allowed by default would let a test pass over a path production refuses, and assertReceiveGateDenied() proves the gate was asked and said no.
use Pushery\Billing\Enums\TaxArchetype;
use Pushery\Billing\ValueObjects\Money;
Billing::assertTipped($fan, Money::of(500, 'EUR'), TaxArchetype::Subscription);
Billing::assertNothingTipped();
Billing::assertOnboardingStarted($creator);
Billing::assertNothingOnboarded();
Billing::assertReceiveGateDenied($creator);
To prove a subscription was left alone, the three lifecycle assertions have a negative side. Each takes the same optional merchant scope as its positive twin, and a failure lists every action the owner did see:
Billing::assertNotCanceled($user);
Billing::assertNotCanceledNow($user, MerchantScope::forMerchant($creator));
Billing::assertNotResumed($user);
A cancellation to a date is asserted with its moment, compared as a moment rather than as text, so one held in another time zone still matches. Without a moment, any cancellation to a date passes, and a failure names the moments the owner was canceled to:
Billing::assertCanceledAt($user, $endsAt);
Billing::assertCanceledAt($user, merchant: MerchantScope::forMerchant($creator));
Billing::assertNotCanceledAt($user);
Every lifecycle assertion and assertSwapped() also take the contract type, last. A null type leaves it out of the question, as a null merchant does. Subscription::TYPE_DEFAULT names the default contract, which is what a call without a type reached:
use Pushery\Billing\Models\Subscription;
Billing::assertCanceled($user, type: 'sponsorship');
Billing::assertNotCanceled($user, type: Subscription::TYPE_DEFAULT);
Billing::assertSwapped($user, 'gold', 'sponsorship');
assertOnboardingStarted() takes the refresh and the return address, both optional. They answer different questions: the return address is where the merchant lands, and the refresh address is reached only when the hosted link has expired, so a page there instead of the route that mints a fresh link fails silently. Both fakes that bind MerchantOnboarding answer it the same way:
Billing::assertOnboardingStarted($creator, route('onboarding.refresh'), route('onboarding.done'));
Billing::assertOnboardingStarted($creator, refreshUrl: route('onboarding.refresh'));