September 17, 2026

Testing WooCommerce Coupons with Playwright

woocommerce playwright testing automation

The Coupon Applied. The Price Was Still Wrong.

A customer orders a lamp and pays for a longer cable. They also receive a wholesale discount or enter a coupon. The store needs to retain the extra cable charge and calculate the discount on the correct amount.

When we first implemented cable customization, we checked it with wholesale discounts. We missed the coupon scenario in that first round of testing. The problem surfaced during subsequent testing: a price recalculation could return to the product’s base price and drop the cable surcharge.

The customer could still add the product to the cart. The coupon could still be accepted. But the resulting amount was wrong. For a store owner, that means a paid customization can become work the business supplies without collecting the intended charge.

The cause involved hook priorities: the order in which WooCommerce runs the pieces of code that adjust a price. One calculation could overwrite the result of another. Fixing that order addressed the immediate problem. We also expanded our Playwright tests to check the interaction, so a future change to those calculations can be caught before deployment.

First the Customer Experience, Then the Amounts

We start by testing the experience of extending the cable: can the customer open the customization controls, choose the length, understand the extra charge, and add the configured product to the cart? Does the cart retain the chosen customization?

Then we check the amounts. Does the extra cable still cost what it should after a wholesale discount? What happens when a coupon is applied? Does the resulting item total include the customization and the correct discount?

Both checks matter. The customer needs to be able to configure the product, and the store needs to charge the right amount for it. A successful click or a green “coupon applied” message only answers part of that question.

The automated pricing regression described below focuses on that second layer. It prepares a personalized and a plain cart line, applies the coupon through the cart interface, and compares their amounts. It supplies the cable length to the product form directly; that setup does not replace a separate check of the customization controls themselves.

For the manager reviewing an update, the useful result is concrete: the paid customization remains in the price, the discount is correct, and a failed check identifies a scenario that needs attention before release.

The rest of this article explains how we made that check repeatable: prepare controlled test data through a protected endpoint, exercise the cart, verify the amounts, and clean up afterwards. It follows Your First Playwright Test for WooCommerce.

Give the Test Its Own Coupon

A hardcoded campaign code carries dependencies you may not notice until the test fails:

  • its expiry date;
  • its usage limit;
  • a minimum spend;
  • restrictions on products or customer email addresses;
  • whether sale items qualify.

Finding an “active” coupon only answers part of the problem. It may still be invalid for the customer and cart you are testing.

There are two useful scenarios here. To verify a particular campaign, deliberately test that campaign’s code and restrictions. To verify that your cart calculates a percentage discount correctly, create a coupon with controlled properties.

For the regression in this article, we create a 10% coupon. Each test-suite execution gets a UUID in its code:

import { randomUUID } from 'node:crypto';

const COUPON_CODE = `e2e-cable-${randomUUID()}`;
const COUPON_PERCENT = 10;

That prevents separate executions from updating or deleting the same coupon. It does not isolate other shared data, such as a logged-in customer’s persistent cart. We will come back to that.

A Small API for Test Data

Our child theme has a test-fixtures/v1 REST namespace. A fixture is the data and setup a test needs to run.

The relevant endpoints are:

EndpointPurpose
GET /productFind a published product matching requested features
GET /shop-configRead currency, cart URL and relevant shop settings
POST /userPrepare the test customer
POST /couponCreate or update an API-owned coupon
DELETE /couponRemove an API-owned coupon

For cable pricing, we request a product with require=cable_length. A product that supports a cable colour alone cannot exercise a length surcharge. This small distinction matters more than a conveniently hardcoded product ID.

The coupon endpoint accepts a code, an amount and a discount type:

POST /wp-json/test-fixtures/v1/coupon
X-Test-Token: <secret from the test runner>
Content-Type: application/json

{
  "code": "e2e-cable-<uuid>",
  "amount": 10,
  "discount_type": "percent"
}

It returns the saved coupon’s ID, normalized code, amount and type. We use the returned code in the browser.

Our setup removes expiry, usage-limit, minimum-spend and email restrictions from these test coupons. It also sets exclude_sale_items to false: our B2B pricing makes products count as on sale, so excluding sale items would prevent the very interaction we want to exercise.

These are the rules for this particular scenario. Tests of expiry, minimum spend or restricted products should create fixtures with those conditions explicitly.

Authentication and Ownership Are Separate Checks

Every request must pass a permission_callback that compares the X-Test-Token header with a server-side constant using hash_equals. The secret lives in the runner’s environment and WordPress configuration. It does not belong in a browser URL or an article example.

The endpoint can run in any configured environment. Its protection is authentication plus checks on which coupons it can mutate.

A prefix is a label. Ownership comes from server-written metadata.

When the endpoint creates a coupon, it stores:

$coupon->update_meta_data(
    '_test_fixtures_owner',
    'test-fixtures/v1'
);

The marker is internal; it is not accepted from the request body. If a coupon with the requested code already exists, POST checks ownership before changing any of its properties:

// Excerpt from the authenticated POST handler.
$coupon = $existing_id
    ? new WC_Coupon($existing_id)
    : new WC_Coupon();

if (
    $existing_id &&
    $coupon->get_meta('_test_fixtures_owner', true) !== 'test-fixtures/v1'
) {
    return new WP_Error(
        'coupon_not_owned',
        'This coupon was not created by the fixtures API.',
        ['status' => 403]
    );
}

DELETE performs the same metadata check before calling $coupon->delete(true).

Checking both operations closes an important hole: without the POST check, a request could overwrite an ordinary coupon, mark it as test-owned, and then delete it.

Older test coupons without the marker also receive a 403. A familiar-looking code is insufficient evidence to adopt them automatically.

This marker identifies coupons created by our fixtures API. It does not distinguish individual token holders or runs: someone authorized to use the API can manage its marked coupons. We use unique codes to avoid accidental collisions between executions.

Prepare and Clean Up in the Test Lifecycle

The following is an excerpt from our suite. createTestCoupon and deleteTestCoupon are project helpers around the two authenticated REST requests, not built-in Playwright functions.

let coupon: TestCoupon;

test.beforeAll(async ({ request }) => {
  coupon = await createTestCoupon(request, {
    code: COUPON_CODE,
    amount: COUPON_PERCENT,
    discount_type: 'percent',
  });
});

test.afterAll(async ({ request }) => {
  if (coupon) {
    await deleteTestCoupon(request, coupon.code);
  }
});

The helper checks the HTTP result and throws on failure. Cleanup must do that too. If DELETE returns 404 because the route was never deployed, the run fails rather than quietly leaving a working coupon behind.

Deleting an already-absent coupon is different: our handler returns a successful response with deleted: false. Repeating cleanup is therefore safe.

We delete through WooCommerce’s data store and invalidate the coupon cache group after writes. A later run must not resolve the code to an ID that was deleted during the previous run.

Normal test teardown runs after failures, but an abruptly killed process can prevent cleanup. This implementation does not expire its coupons automatically. Leftovers need reconciliation using the ownership marker; arbitrary coupons must never be swept up by a prefix-only cleanup job.

For larger suites, Playwright fixtures can keep reusable setup and teardown together. The essential contract is the same: the lifecycle that creates the data is responsible for its removal.

Apply Through the Storefront

Use the API for preparation. Exercise the action under test through the customer-facing controls.

Our helper supports the classic cart and the block cart. The classic branch fills the coupon input and clicks the real submit button:

const input = page.locator('input[name="coupon_code"]');
await input.fill(coupon.code);
await page.locator('button[name="apply_coupon"]').click();

await expect(
  page.locator('.cart-discount').filter({ hasText: coupon.code })
).toBeVisible();

The block branch expands the coupon panel, fills its input, submits, and waits for the applied-coupon chip:

const panel = page.locator('.wc-block-components-totals-coupon');
await panel.locator('.wc-block-components-panel__button').click();
await panel.locator('input').fill(coupon.code);
await panel.locator('button[type="submit"]').click();

await expect(
  page.locator('.wc-block-components-chip')
    .filter({ hasText: coupon.code })
).toBeVisible();

These are selectors from the cart implementations we exercise. A custom cart may need its own selectors. The test should fail if the form stops working; silently switching to an API mutation would bypass that failure.

Read the Same Cart and Check the Money

After the UI action, we read /wp-json/wc/store/v1/cart through page.request.

Playwright’s page-associated request context shares cookies with the browser. That lets us inspect the cart belonging to the browser session. A separate API context would not automatically have the same customer session.

The Store API provides structured amounts and currency precision. Our helper converts minor currency units to normal amounts and exposes net line subtotals, net line totals and the net coupon discount. This avoids comparing a gross display price against a net subtotal.

The regression creates two cart lines: the product with extra cable and the same product without it. It then applies the coupon:

const before = await readCart(page);
expect(before.items).toHaveLength(2);

const after = await applyCouponThroughUI(
  page,
  shop.cart_url,
  coupon.code,
);

expect(after.coupons.map(code => code.toLowerCase()))
  .toContain(coupon.code.toLowerCase());

const expectedDiscount = before.subtotal * COUPON_PERCENT / 100;
const tolerance =
  before.items.length * Math.pow(10, -before.minorUnit) + 1e-8;

expect(after.discount).toBeGreaterThan(0);
expect(Math.abs(after.discount - expectedDiscount))
  .toBeLessThanOrEqual(tolerance);

const netAfter = after.items.reduce(
  (sum, item) => sum + item.lineTotal,
  0,
);
expect(Math.abs(netAfter - (before.subtotal - after.discount)))
  .toBeLessThanOrEqual(tolerance);
expect(after.subtotal).toBe(before.subtotal);

The tolerance is one minor currency unit per line, plus a tiny floating-point allowance. A tolerance proportional to the basket’s value could hide a meaningful pricing error on an expensive order.

We separately assert that the personalized line still carries a positive surcharge and that its unit price remains higher than the plain line’s price. That is the business rule the original regression threatened.

This checks item-level pricing. A test of the final payable total would additionally need controlled shipping, taxes and fees, with assertions for those components. This suite does not place an order or verify payment settlement.

The Test Harness Can Get the Price Wrong Too

While updating this test, the local run exposed a parsing error in our own helper.

The surcharge metadata contained a formatted price with an HTML-encoded dollar sign: &#36;. Removing tags and then keeping digits turned that entity into part of the amount. A $16 surcharge could be read as 3616.

We fixed the helper to decode numeric HTML entities before parsing the formatted metadata, and added regression cases for decimal entities, hexadecimal entities and a literal dollar sign.

For primary monetary assertions we use the Store API’s numeric fields. The formatted metadata is only needed here to identify and verify the custom surcharge row.

Isolate More Than the Coupon

WooCommerce can restore a logged-in customer’s persistent cart. Two tests using the same B2B account can interfere even when their browser contexts and coupon codes differ.

Our two regression tests run serially and reset the shared customer’s cart before each scenario. Separate concurrent suite executions still need separate customers or scheduling that prevents overlap.

The suite also checks its prerequisites: a matching product and a nonzero cable surcharge. Without them, it skips. A skipped test is not evidence that the pricing calculation works.

What This Gives Us Before an Update

The resulting check covers a concrete chain:

  1. A dedicated coupon exists with known rules.
  2. The shopper can apply it through the cart interface.
  3. The expected percentage is deducted from the item subtotal.
  4. Paid customization survives recalculation.
  5. Cleanup removes only a coupon owned by the fixtures API.

That is the kind of regression coverage we build into WooCommerce Playwright testing. It pairs naturally with checkout performance diagnostics: the purchase flow needs to produce the right result as well as respond quickly.

Start with one pricing rule your store cannot afford to get wrong. Give the test controlled data, exercise the real interface, and assert the amount you expect to charge.

M
Written by

Mateusz Zadorozny

SHIFT64 Founder. WooCommerce performance specialist helping store owners achieve faster load times and better conversions.