Skip to main content

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 HOLIDAY20 works 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

ScenarioEdge caseWhy tests breakApproach
Valid single codePercent vs fixedWrong total mathProbe line items + discount amount
Expired codePast expires_atShared code still in wikiSeed expired per run
Usage limitmax_uses: 1Parallel exhaustionUnique code per worker
Non-stackable pairTwo public codesBoth apply—revenue leakProbe single discount row
Stackable pairCampaign allows stackSecond rejected wronglyProbe two rows + total
Category restrictionSKU outside categoryCode applies anywaySeed ineligible SKU
Minimum spendBelow thresholdUI shows appliedProbe rejected + reason
First-order onlyReturning customerShould rejectSeed customer with prior order
Auto-apply vs manualURL param promoUntested pathSeparate scenario
Cart mergeLogin merges guest cartDuplicate discountsProbe after merge event
Race: double applyTwo rapid clicksDuplicate rowsProbe idempotent apply
Race: concurrent tabsTab A + B edit qtyLost discountSerialize or probe version
Inventory + promoBOGO + low stockPartial fulfillmentProbe hold + promo flags
CurrencyMulti-currency cartWrong discount currencyProbe settlement currency
Remove / replace codeSwap codes mid-sessionStale total in UIProbe 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 ACode BExpected probe
Stackable 10%Stackable $5Two discount rows; combined total
Stackable 10%Exclusive 20%Second rejected OR first replaced—match product rules
Exclusive AExclusive BOne row only
Auto free shipManual 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:

  1. Double-click Apply — assert probe shows one discount application; optional idempotency key on apply endpoint.
  2. Qty change during apply — Act: change qty while promo request in flight; probe final total matches business rules.
  3. Parallel specs — never share cartId across 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_ineligible
  • campaign_id — internal promo campaign identifier
  • stackable — true/false on applied discount rows
  • cart_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

  1. Unique promo codes per worker (PROMO-${runId})
  2. Stacking positive and negative specs per campaign policy
  3. Probe cart after every apply/remove—not discount label text
  4. Race spec: double apply or concurrent qty change
  5. Category/min-spend rejection probes with seeded SKUs
  6. Teardown or TTL on seeded coupons to avoid staging clutter
  7. Link SmartTests to markdown with // @Scenario:

Anti-patterns

Anti-patternWhy it failsBetter approach
One global promo codeParallel CI collisionsSeed per-run codes
Assert on discount labelCopy changes break testsProbe cart line items
Skip stacking rulesRevenue leaks in prodScenario matrix
Ignore campaign metadataWrong discount appliedSeed with campaign_id
Guest-only testsMerge/login path untestedcart_source dimension
No race specsDouble-apply bugsDouble-click + poll probe
UI setup for catalogSlow, brittleAPI 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.

  1. Arrange: Seed cart with SKU + two coupons via API; mark second as non-stackable for this campaign.
  2. Act: Apply first code, then second in cart UI.
  3. 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).

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.

Start free on TestChimp · Book a demo