How to Test Cart and Promo Code Scenarios
Short answer
Cart and coupon tests fail when teams reuse one shared promo across CI workers, ignore stacking rules, or assert success from toast text alone. Reliable automation seeds run-scoped coupons, exercises eligibility and stack/non-stack paths in the UI, and probes cart line items and discount rows on the server.
Part of Testing Guides by industry.
Who this is for
Ecommerce startups changing pricing, bundles, and promotions weekly—with or without dedicated QA. Headless cart APIs, session-based carts, and multi-step checkout promo fields all benefit from the same Arrange/Act/Assert split.
Why testing cart and promos matters
Promo bugs leak margin silently:
- Revenue loss — two "non-stackable" codes both apply; employee discount stacks with public sale; free-shipping promo applies after threshold removed.
- Support load — "invalid code" for valid campaign because usage limit exhausted by automated tests; cart shows discount removed on refresh.
- Race conditions — double-click Apply creates duplicate discount rows; concurrent tab updates overwrite cart.
- CI blindness — shared
HOLIDAY20works locally, fails in worker 4; tests never cover category-restricted SKUs.
The cart UI can show a discount label while discount rows and totals on the server disagree. Probe authoritative cart state—not DOM text alone.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Valid single code | Percent vs fixed | Wrong total math | Probe line items + discount amount |
| Expired code | Past expires_at | Shared code still in wiki | Seed expired per run |
| Usage limit | max_uses: 1 | Parallel exhaustion | Unique code per worker |
| Non-stackable pair | Two public codes | Both apply—revenue leak | Probe single discount row |
| Stackable pair | Campaign allows stack | Second rejected wrongly | Probe two rows + total |
| Category restriction | SKU outside category | Code applies anyway | Seed ineligible SKU |
| Minimum spend | Below threshold | UI shows applied | Probe rejected + reason |
| First-order only | Returning customer | Should reject | Seed customer with prior order |
| Auto-apply vs manual | URL param promo | Untested path | Separate scenario |
| Cart merge | Login merges guest cart | Duplicate discounts | Probe after merge event |
| Race: double apply | Two rapid clicks | Duplicate rows | Probe idempotent apply |
| Race: concurrent tabs | Tab A + B edit qty | Lost discount | Serialize or probe version |
| Inventory + promo | BOGO + low stock | Partial fulfillment | Probe hold + promo flags |
| Currency | Multi-currency cart | Wrong discount currency | Probe settlement currency |
| Remove / replace code | Swap codes mid-session | Stale total in UI | Probe after each mutation |
Run-scoped coupons: seed pattern
// POST /api/test/seed-promo
// Body: {
// runId,
// coupons: [
// { code, type: 'percent', value: 10, stackable: true, campaignId: 'SUMMER' },
// { code, type: 'fixed', value: 500, stackable: false, campaignId: 'VIP' },
// ],
// cart: { sku, qty }
// }
const runId = `${test.info().parallelIndex}-${Date.now()}`;
const { cartId, codes } = await request.post('/api/test/seed-promo', {
data: {
runId,
coupons: [
{ code: `STACK-A-${runId}`, stackable: true, campaignId: 'A' },
{ code: `NO-STACK-B-${runId}`, stackable: false, campaignId: 'B' },
],
cart: { sku: 'SKU-TEST-001', qty: 1 },
},
}).then(r => r.json());
await page.goto(`/cart/${cartId}`);
Never share one hard-coded coupon across workers. Include campaign_id, stackable, and expires_at in seed payloads so probes can assert the correct rule fired.
Stacking rules matrix
Document stacking in markdown scenarios, then mirror in seeds:
| Code A | Code B | Expected probe |
|---|---|---|
| Stackable 10% | Stackable $5 | Two discount rows; combined total |
| Stackable 10% | Exclusive 20% | Second rejected OR first replaced—match product rules |
| Exclusive A | Exclusive B | One row only |
| Auto free ship | Manual 10% | Both if policy allows; else probe rejection reason |
await page.getByLabel('Promo code').fill(codes[0]);
await page.getByRole('button', { name: 'Apply' }).click();
await page.getByLabel('Promo code').fill(codes[1]);
await page.getByRole('button', { name: 'Apply' }).click();
const cart = await request.get(`/api/test/probe-cart?runId=${runId}`).then(r => r.json());
expect(cart.discountRows).toHaveLength(1);
expect(cart.discountRows[0].campaignId).toBe('A');
expect(cart.totalCents).toBe(expectedSingleDiscountTotal);
UI error for the second code is optional—probe is authoritative.
Race conditions and concurrency
Cart mutations are concurrency-sensitive:
- Double-click Apply — assert probe shows one discount application; optional idempotency key on apply endpoint.
- Qty change during apply — Act: change qty while promo request in flight; probe final total matches business rules.
- Parallel specs — never share
cartIdacross tests; seed fresh cart per spec.
Use expect.poll when apply is async:
await expect.poll(async () => {
const c = await request.get(`/api/test/probe-cart?runId=${runId}`).then(r => r.json());
return c.discountRows.length;
}).toBe(1);
Requirement slices to cover
coupon_outcome— applied, rejected_expired, rejected_limit, rejected_stack, rejected_ineligiblecampaign_id— internal promo campaign identifierstackable— true/false on applied discount rowscart_source— guest, logged_in, merged
When prod shows high volume of rejected_stack but tests only cover happy single-code applies, evolve specs for stacking edge cases.
CI checklist
- Unique promo codes per worker (
PROMO-${runId}) - Stacking positive and negative specs per campaign policy
- Probe cart after every apply/remove—not discount label text
- Race spec: double apply or concurrent qty change
- Category/min-spend rejection probes with seeded SKUs
- Teardown or TTL on seeded coupons to avoid staging clutter
- Link SmartTests to markdown with
// @Scenario:
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| One global promo code | Parallel CI collisions | Seed per-run codes |
| Assert on discount label | Copy changes break tests | Probe cart line items |
| Skip stacking rules | Revenue leaks in prod | Scenario matrix |
| Ignore campaign metadata | Wrong discount applied | Seed with campaign_id |
| Guest-only tests | Merge/login path untested | cart_source dimension |
| No race specs | Double-apply bugs | Double-click + poll probe |
| UI setup for catalog | Slow, brittle | API cart seed |
Example scenario
Situation: A shopper applies two promo codes where only one may stack.
Expected outcome: Second code is rejected; cart total reflects a single discount.
Why UI-only automation breaks: Toast says 'applied' while backend applies both discounts—revenue leak undetected.
- Arrange: Seed cart with SKU + two coupons via API; mark second as non-stackable for this campaign.
- Act: Apply first code, then second in cart UI.
- Assert: Probe returns one discount row and expected total; optional UI error for second code.
TestChimp workflow: Compare `coupon_applied` metadata (`stackable`, `campaign_id`, `coupon_outcome`) in prod vs test runs.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Connect scenarios to your QA workflow
Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).
Related scenarios
- Checkout flows — payment probes, parallel CI
- Tax & regional pricing — promos with tax interaction
- Inventory & stock — BOGO and low-stock promos
- Stripe payments — pay after discounted cart
- Flaky E2E fixes — shared world-state
- Form validation — promo field errors
External references
Frequently asked questions
How do parallel CI runs avoid coupon collisions?
Never share one hard-coded coupon—seed per-run codes via API Arrange with runId tied to parallelIndex. Assert cart totals through probes so UI restyling does not break validation.
How do I test stacking rules authoritatively?
Seed two coupons with explicit stackable flags and campaign IDs, apply both in UI, probe discount row count and total. Do not rely on toast copy for rejection.
What race conditions should cart tests cover?
Double-click Apply, qty change during apply, and guest-cart merge on login—poll probe until cart version stabilizes.
Should I walk the full catalog UI to build a cart?
No for promo specs—seed cart line items via API and focus Act on apply/remove flows. Catalog browsing belongs in separate product discovery specs.
How does TrueCoverage guide promo coverage?
Compare campaign_id and coupon_outcome slices prod vs test. When rejected_stack or rejected_ineligible dominate prod but tests only cover applied, run /testchimp evolve.
Expired vs usage-limit coupons—same test?
Separate scenarios with distinct probes: expired checks expires_at; limit checks max_uses after seeding prior redemption for same code pattern.
Can marketing rotate copy without breaking tests?
Yes if Assert uses probes for totals and discount rows; keep UI asserts optional or regex-based. Hybrid ai.verify only for volatile promo banners—not totals.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.