Shops::fake()
Shops::fake() swaps in RoundlyConsulting\Shops\Testing\ShopsFake and returns it. It is a spy, not a stub: every operation still runs — carts, orders and stock rows are real and events fire, and a charge still goes through the bound PaymentGateway (the default NullPaymentGateway charges nothing). Each state change is recorded, whether it came through the facade, an injected ShopsManager (the fake is a subtype, so injected managers get it too) or a model method such as $cart->add() or $order->markPaid(). A call that throws is not recorded.
use RoundlyConsulting\Shops\Cart\Cart;
use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Orders\Order;
use RoundlyConsulting\Shops\Products\ProductVariant;
use RoundlyConsulting\Shops\Testing\CartChange;
it('adds to the cart, checks out and charges', function () {
Shops::fake(); // a spy — carts, orders and stock rows are still real
$variant = ProductVariant::factory()->withEurPrice('1990')->withStock(5)->create();
$cart = Cart::factory()->create();
$this->post("/cart/{$cart->id}/items", ['variant' => $variant->id, 'quantity' => 2]);
$this->post("/cart/{$cart->id}/checkout")->assertRedirect();
$order = Order::query()->latest('id')->firstOrFail();
Shops::assertCartChanged($cart, CartChange::Added); // Added, Updated, Removed or Cleared
Shops::assertOrderPlaced($cart); // the stock checkout reserves is part of this
Shops::assertCharged($order); // and so are the transitions a charge makes
Shops::assertNothingTransitioned();
Shops::assertNothingStockAdjusted();
});Only the calls your code makes are recorded: the stock a checkout reserves is part of assertOrderPlaced(), and the transitions a successful charge makes are part of assertCharged(). Assert on the facade or on the returned fake:
use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Inventory\Enums\StockReason;
use RoundlyConsulting\Shops\Orders\Enums\Status;
use RoundlyConsulting\Shops\Orders\Order;
use RoundlyConsulting\Shops\Products\ProductVariant;
it('ships an order and books a delivery', function () {
$fake = Shops::fake(); // the ShopsFake — assert on it or on the facade
$order = Order::factory()->paid()->create();
$variant = ProductVariant::factory()->withStock(0)->create();
$this->post("/admin/orders/{$order->number}/ship");
$this->post("/admin/variants/{$variant->id}/receive", ['quantity' => 10]);
$fake->assertTransitioned($order, Status::Fulfilled); // any order / any status when omitted
$fake->assertStockAdjusted($variant, delta: 10, reason: StockReason::Received);
$fake->assertNothingPlaced();
$fake->assertNothingCharged();
$fake->assertNothingCartChanged();
});Every assertion, with its negative:
| Assertion | Negative | Passes when |
|---|---|---|
assertOrderPlaced(?Cart $cart = null) | assertNothingPlaced() | Any checkout, or one from the cart. |
assertCharged(?Order $order = null) | assertNothingCharged() | Any charge, or of the order — failed charges count too. |
assertTransitioned(?Order $order = null, ?Status $to = null) | assertNothingTransitioned() | Any transition, narrowed by order and/or target status. |
assertStockAdjusted(?ProductVariant $variant = null, ?int $delta = null, ?StockReason $reason = null) | assertNothingStockAdjusted() | Any inventory write, narrowed by variant, exact delta and/or reason. |
assertCartChanged(?Cart $cart = null, ?CartChange $change = null) | assertNothingCartChanged() | Any cart change, narrowed by cart and/or CartChange (Added, Updated, Removed — also update($item, 0) — Cleared). |
Provider fakes compose: shops resolves each action per call, so Coupons::fake() sees the redemption a checkout makes and Credits::fake() the store-credit debit a charge makes.
Model factories
Every shops model has a factory, reachable through Model::factory(). Use them in your tests and seeders:
| Model | Defaults | States |
|---|---|---|
Shops\Shop | Unique company name, no currency | currency(string $currency) |
Shops\TaxRate | Class standard, 2000 bp, no country | default(), reduced(), forCountry(string $iso) |
Products\Product | Unique name, random optional published_at, default variant | withPrice(string $price, string $currency = 'EUR'), withVariant(ProductVariantFactory $variant), published(), unpublished() |
Products\ProductVariant | New product, random SKU, EUR or USD price, tracked, stock 0–100 | withEurPrice(string $price), untracked(), withStock(int $stock) |
Products\ProductOption | New product, Size, Colour or Material | — |
Products\ProductOptionValue | New option, a unique word | — |
Products\Category | Unique name, random optional published_at | published(), unpublished() |
Inventory\StockAdjustment | New variant, +1–50 Received | — |
Cart\Cart | UUID token, EUR | — |
Cart\CartItem | New cart, EUR price, quantity 1–5 | — |
Orders\Order | Status New, a note | withStatus(Status $status), inProgress(), paid(), fulfilled(), canceled(), withDiscount(...) |
Orders\Item | New order, EUR or USD price, quantity 1–10 | withEurPrice(string $price), withUsdPrice(string $price) |
A full checkout without the fake — run it with the published migrations loaded (RefreshDatabase):
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Shops\Cart\Cart;
use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Orders\Events\OrderPlaced;
use RoundlyConsulting\Shops\Products\Product;
use RoundlyConsulting\Shops\Products\ProductVariant;
it('places an order and reserves stock', function () {
Event::fake([OrderPlaced::class]);
$product = Product::factory()
->published()
->withVariant(ProductVariant::factory()->withEurPrice('1990')->withStock(5))
->create();
$variant = $product->variants()->first();
$cart = Cart::factory()->create(); // EUR, guest token
Shops::cart($cart)->add($variant, 2);
$order = Shops::cart($cart)->checkout();
expect($order->price->getSubtotal()->minor())->toBe('3980')
->and($variant->refresh()->availableStock())->toBe(3);
Event::assertDispatched(OrderPlaced::class);
});More states:
use RoundlyConsulting\Shops\Orders\Order;
use RoundlyConsulting\Shops\Shops\Shop;
use RoundlyConsulting\Shops\Shops\TaxRate;
$shop = Shop::factory()->currency('EUR')->create();
TaxRate::factory()->for($shop)->default()->create(); // standard, 2000 bp
TaxRate::factory()->for($shop)->reduced()->forCountry('DE')->create();
$order = Order::factory()->paid()->create(); // status columns only
$order = Order::factory()->withDiscount('500', 'EUR', couponCode: 'WELCOME10')->create();Order factory states set status columns directly — they do not run transitions, reserve stock or fire events. Use the lifecycle methods when the side effects matter. Order numbers are assigned on insert even under Event::fake().
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.