Commit f24a690f30c for woocommerce
commit f24a690f30cd449e1ef3beb61ce8bcac30fb0d12
Author: Mike Jolley <mike.jolley@me.com>
Date: Wed Apr 8 11:58:03 2026 +0100
Add unit tests for cart and checkout SlotFill components (#63950)
* Add unit tests for cart and checkout SlotFill components
Add tests for the 4 public SlotFill components (ExperimentalOrderMeta,
ExperimentalDiscountsMeta, ExperimentalOrderShippingPackages,
ExperimentalOrderLocalPickupPackages) and 3 consumer blocks that render
their slots, ensuring fills render correctly, fillProps are forwarded,
and slots are always mounted regardless of consumer state.
Includes a shared test utility (renderSlotFill/getFillProps) and a
missing @woocommerce/sanitize jest moduleNameMapper entry.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix CI lint and test failures in SlotFill tests
- Move test-utils.tsx out of test/ directory so Jest doesn't pick it up
as a test file (it has no tests, only utility exports)
- Remove unused imports (useEditorContext, useShippingData) from
shipping-rates-control slotfill test
- Fix prettier formatting issues across test files
- Fix @woocommerce/dependency-group lint error in test-utils.tsx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/slotfill.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/slotfill.tsx
new file mode 100644
index 00000000000..009ad8bd99e
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/slotfill.tsx
@@ -0,0 +1,87 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import { useStoreCart } from '@woocommerce/base-context';
+
+/**
+ * Internal dependencies
+ */
+import ShippingRatesControl from '..';
+
+jest.mock( '@woocommerce/base-context', () => ( {
+ useStoreCart: jest.fn(),
+ useEditorContext: jest.fn( () => ( { isEditor: false } ) ),
+ useShippingData: jest.fn( () => ( {
+ hasSelectedLocalPickup: false,
+ selectedRates: {},
+ } ) ),
+} ) );
+
+jest.mock( '@woocommerce/base-hooks', () => ( {
+ usePrevious: jest.fn(),
+} ) );
+
+const mockSlotRender = jest.fn( () => <div data-testid="shipping-slot" /> );
+jest.mock( '@woocommerce/blocks-checkout', () => {
+ const MockFill = ( { children }: { children: React.ReactNode } ) => (
+ <>{ children }</>
+ );
+ MockFill.Slot = ( props: Record< string, unknown > ) =>
+ mockSlotRender( props );
+ return { ExperimentalOrderShippingPackages: MockFill };
+} );
+
+const defaultProps = {
+ shippingRates: [],
+ isLoadingRates: false,
+ className: 'test-class',
+ collapsible: false,
+ showItems: false,
+ noResultsMessage: <span>No rates</span>,
+ renderOption: jest.fn(),
+ context: 'woocommerce/checkout',
+};
+
+describe( 'ShippingRatesControl slot rendering', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ ( useStoreCart as jest.Mock ).mockReturnValue( {
+ extensions: { 'ship-ext': true },
+ receiveCart: jest.fn(),
+ cartTotals: {},
+ } );
+ } );
+
+ it( 'renders ExperimentalOrderShippingPackages.Slot with correct props when not loading', () => {
+ render( <ShippingRatesControl { ...defaultProps } /> );
+
+ expect( screen.getByTestId( 'shipping-slot' ) ).toBeInTheDocument();
+
+ expect( mockSlotRender ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ context: 'woocommerce/checkout',
+ extensions: { 'ship-ext': true },
+ collapsible: false,
+ showItems: false,
+ } )
+ );
+
+ const slotProps = mockSlotRender.mock.calls[ 0 ][ 0 ];
+ expect( slotProps.cart ).not.toHaveProperty( 'receiveCart' );
+ expect( slotProps ).toHaveProperty( 'components' );
+ expect( slotProps ).toHaveProperty( 'renderOption' );
+ expect( slotProps ).toHaveProperty( 'noResultsMessage' );
+ } );
+
+ it( 'does not render the slot when rates are loading', () => {
+ render(
+ <ShippingRatesControl { ...defaultProps } isLoadingRates={ true } />
+ );
+
+ expect(
+ screen.queryByTestId( 'shipping-slot' )
+ ).not.toBeInTheDocument();
+ expect( mockSlotRender ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-order-summary-block/test/slotfills.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-order-summary-block/test/slotfills.tsx
new file mode 100644
index 00000000000..aa910cbe464
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-order-summary-block/test/slotfills.tsx
@@ -0,0 +1,54 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+import { useStoreCart } from '@woocommerce/base-context/hooks';
+
+/**
+ * Internal dependencies
+ */
+import { OrderMetaSlotFill } from '../slotfills';
+
+jest.mock( '@woocommerce/base-context/hooks', () => ( {
+ useStoreCart: jest.fn(),
+} ) );
+
+const mockSlotRender = jest.fn( () => <div data-testid="order-meta-slot" /> );
+jest.mock( '@woocommerce/blocks-checkout', () => {
+ const MockFill = ( { children }: { children: React.ReactNode } ) => (
+ <>{ children }</>
+ );
+ MockFill.Slot = ( props: Record< string, unknown > ) =>
+ mockSlotRender( props );
+ return { ExperimentalOrderMeta: MockFill };
+} );
+
+describe( 'Cart OrderMetaSlotFill', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'always renders ExperimentalOrderMeta.Slot with cart context and correct props', () => {
+ ( useStoreCart as jest.Mock ).mockReturnValue( {
+ extensions: { 'my-ext': true },
+ receiveCart: jest.fn(),
+ cartTotals: { total: '1000' },
+ } );
+
+ render( <OrderMetaSlotFill /> );
+
+ expect( mockSlotRender ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ context: 'woocommerce/cart',
+ extensions: { 'my-ext': true },
+ cart: expect.objectContaining( {
+ cartTotals: { total: '1000' },
+ } ),
+ } )
+ );
+
+ // receiveCart should be excluded from cart props.
+ const slotProps = mockSlotRender.mock.calls[ 0 ][ 0 ];
+ expect( slotProps.cart ).not.toHaveProperty( 'receiveCart' );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-discount/test/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-discount/test/block.tsx
new file mode 100644
index 00000000000..7aa188154bd
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-discount/test/block.tsx
@@ -0,0 +1,97 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import { useStoreCart } from '@woocommerce/base-context/hooks';
+
+/**
+ * Internal dependencies
+ */
+import Block from '../block';
+
+jest.mock( '@woocommerce/base-context/hooks', () => ( {
+ useStoreCart: jest.fn(),
+ useStoreCartCoupons: jest.fn( () => ( {
+ removeCoupon: jest.fn(),
+ isRemovingCoupon: false,
+ } ) ),
+ useOrderSummaryLoadingState: jest.fn( () => ( { isLoading: false } ) ),
+} ) );
+
+const mockSlotRender = jest.fn( () => <div data-testid="discount-slot" /> );
+jest.mock( '@woocommerce/blocks-checkout', () => {
+ const MockFill = ( { children }: { children: React.ReactNode } ) => (
+ <>{ children }</>
+ );
+ MockFill.Slot = ( props: Record< string, unknown > ) =>
+ mockSlotRender( props );
+ return {
+ ExperimentalDiscountsMeta: MockFill,
+ applyCheckoutFilter: jest.fn(
+ ( { defaultValue }: { defaultValue: unknown } ) => defaultValue
+ ),
+ };
+} );
+
+const mockCartData = {
+ cartTotals: {
+ currency_code: 'USD',
+ currency_symbol: '$',
+ currency_minor_unit: 2,
+ currency_decimal_separator: '.',
+ currency_thousand_separator: ',',
+ currency_prefix: '$',
+ currency_suffix: '',
+ total_discount: '0',
+ total_discount_tax: '0',
+ },
+ cartCoupons: [],
+ extensions: { some: 'data' },
+ receiveCart: jest.fn(),
+};
+
+describe( 'Checkout Order Summary Discount Block', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ ( useStoreCart as jest.Mock ).mockReturnValue( mockCartData );
+ } );
+
+ it( 'renders the DiscountsMeta slot with checkout context when no coupons', () => {
+ render( <Block /> );
+
+ expect( screen.getByTestId( 'discount-slot' ) ).toBeInTheDocument();
+ expect( mockSlotRender ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ context: 'woocommerce/checkout',
+ extensions: { some: 'data' },
+ } )
+ );
+
+ const slotProps = mockSlotRender.mock.calls[ 0 ][ 0 ];
+ expect( slotProps.cart ).not.toHaveProperty( 'receiveCart' );
+ } );
+
+ it( 'still renders the DiscountsMeta slot when coupons are present', () => {
+ ( useStoreCart as jest.Mock ).mockReturnValue( {
+ ...mockCartData,
+ cartCoupons: [
+ {
+ code: 'SAVE10',
+ label: 'SAVE10',
+ totals: {
+ total_discount: '1000',
+ total_discount_tax: '0',
+ },
+ },
+ ],
+ cartTotals: {
+ ...mockCartData.cartTotals,
+ total_discount: '1000',
+ },
+ } );
+
+ render( <Block className="test-class" /> );
+
+ expect( screen.getByTestId( 'discount-slot' ) ).toBeInTheDocument();
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/checkout/components/discounts-meta/test/index.tsx b/plugins/woocommerce/client/blocks/packages/checkout/components/discounts-meta/test/index.tsx
new file mode 100644
index 00000000000..bde36f8d19f
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/checkout/components/discounts-meta/test/index.tsx
@@ -0,0 +1,58 @@
+/**
+ * External dependencies
+ */
+import { screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import ExperimentalDiscountsMeta from '..';
+import { renderSlotFill, getFillProps } from '../../../slot/test-utils';
+
+const defaultSlotProps = {
+ extensions: {},
+ cart: {},
+ context: 'woocommerce/checkout',
+};
+
+describe( 'ExperimentalDiscountsMeta', () => {
+ it( 'renders fill content inside the slot', () => {
+ renderSlotFill( ExperimentalDiscountsMeta, defaultSlotProps );
+
+ expect( screen.getByTestId( 'fill-content' ) ).toBeInTheDocument();
+ } );
+
+ it( 'wraps the slot in a TotalsWrapper with expected classes', () => {
+ const { container } = renderSlotFill( ExperimentalDiscountsMeta, {
+ ...defaultSlotProps,
+ className: 'my-discount-class',
+ } );
+
+ const wrapper = container.querySelector(
+ '.wc-block-components-totals-wrapper.slot-wrapper'
+ );
+ expect( wrapper ).toBeInTheDocument();
+
+ const slot = container.querySelector(
+ '.wc-block-components-discounts-meta'
+ );
+ expect( slot ).toBeInTheDocument();
+ expect( slot ).toHaveClass( 'my-discount-class' );
+ } );
+
+ it( 'passes extensions, cart, and context via fillProps', () => {
+ const extensions = { 'discount-ext': { active: true } };
+ const cart = { coupons: [ { code: 'SAVE10' } ] };
+ const context = 'woocommerce/cart';
+
+ const fillProps = getFillProps( ExperimentalDiscountsMeta, {
+ extensions,
+ cart,
+ context,
+ } );
+
+ expect( fillProps ).toEqual(
+ expect.objectContaining( { extensions, cart, context } )
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/checkout/components/order-local-pickup-packages/test/index.tsx b/plugins/woocommerce/client/blocks/packages/checkout/components/order-local-pickup-packages/test/index.tsx
new file mode 100644
index 00000000000..7ef645ccfc7
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/checkout/components/order-local-pickup-packages/test/index.tsx
@@ -0,0 +1,56 @@
+/**
+ * External dependencies
+ */
+import { screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import ExperimentalOrderLocalPickupPackages from '..';
+import { renderSlotFill, getFillProps } from '../../../slot/test-utils';
+
+describe( 'ExperimentalOrderLocalPickupPackages', () => {
+ const defaultSlotProps = {
+ extensions: {},
+ cart: {},
+ components: {},
+ renderPickupLocation: jest.fn(),
+ };
+
+ it( 'renders fill content inside the slot with expected classes', () => {
+ const { container } = renderSlotFill(
+ ExperimentalOrderLocalPickupPackages,
+ defaultSlotProps
+ );
+
+ expect( screen.getByTestId( 'fill-content' ) ).toBeInTheDocument();
+ expect(
+ container.querySelector(
+ '.wc-block-components-local-pickup-rates-control'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'passes all expected fillProps', () => {
+ const extensions = { 'pickup-ext': { locations: [] } };
+ const cart = { items: [ { id: 1 } ] };
+ const components = { PickupOption: () => null };
+ const renderPickupLocation = jest.fn();
+
+ const fillProps = getFillProps( ExperimentalOrderLocalPickupPackages, {
+ extensions,
+ cart,
+ components,
+ renderPickupLocation,
+ } );
+
+ expect( fillProps ).toEqual(
+ expect.objectContaining( {
+ extensions,
+ cart,
+ components,
+ renderPickupLocation,
+ } )
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/checkout/components/order-meta/test/index.tsx b/plugins/woocommerce/client/blocks/packages/checkout/components/order-meta/test/index.tsx
new file mode 100644
index 00000000000..53e32bb5ff1
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/checkout/components/order-meta/test/index.tsx
@@ -0,0 +1,58 @@
+/**
+ * External dependencies
+ */
+import { screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import ExperimentalOrderMeta from '..';
+import { renderSlotFill, getFillProps } from '../../../slot/test-utils';
+
+const defaultSlotProps = {
+ extensions: {},
+ cart: {},
+ context: 'woocommerce/checkout',
+};
+
+describe( 'ExperimentalOrderMeta', () => {
+ it( 'renders fill content inside the slot', () => {
+ renderSlotFill( ExperimentalOrderMeta, defaultSlotProps );
+
+ expect( screen.getByTestId( 'fill-content' ) ).toBeInTheDocument();
+ } );
+
+ it( 'wraps the slot in a TotalsWrapper with expected classes', () => {
+ const { container } = renderSlotFill( ExperimentalOrderMeta, {
+ ...defaultSlotProps,
+ className: 'custom-class',
+ } );
+
+ const wrapper = container.querySelector(
+ '.wc-block-components-totals-wrapper.slot-wrapper'
+ );
+ expect( wrapper ).toBeInTheDocument();
+
+ const slot = container.querySelector(
+ '.wc-block-components-order-meta'
+ );
+ expect( slot ).toBeInTheDocument();
+ expect( slot ).toHaveClass( 'custom-class' );
+ } );
+
+ it( 'passes extensions, cart, and context via fillProps', () => {
+ const extensions = { 'my-extension': { key: 'value' } };
+ const cart = { items: [], totals: {} };
+ const context = 'woocommerce/cart';
+
+ const fillProps = getFillProps( ExperimentalOrderMeta, {
+ extensions,
+ cart,
+ context,
+ } );
+
+ expect( fillProps ).toEqual(
+ expect.objectContaining( { extensions, cart, context } )
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/checkout/components/order-shipping-packages/test/index.tsx b/plugins/woocommerce/client/blocks/packages/checkout/components/order-shipping-packages/test/index.tsx
new file mode 100644
index 00000000000..dee192f8420
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/checkout/components/order-shipping-packages/test/index.tsx
@@ -0,0 +1,70 @@
+/**
+ * External dependencies
+ */
+import { screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import ExperimentalOrderShippingPackages from '..';
+import { renderSlotFill, getFillProps } from '../../../slot/test-utils';
+
+describe( 'ExperimentalOrderShippingPackages', () => {
+ const defaultSlotProps = {
+ extensions: {},
+ cart: {},
+ components: {},
+ context: 'woocommerce/checkout',
+ noResultsMessage: 'No shipping options.',
+ renderOption: jest.fn(),
+ collapsible: false,
+ showItems: false,
+ };
+
+ it( 'renders fill content inside the slot with expected classes', () => {
+ const { container } = renderSlotFill(
+ ExperimentalOrderShippingPackages,
+ { ...defaultSlotProps, className: 'custom-shipping' }
+ );
+
+ expect( screen.getByTestId( 'fill-content' ) ).toBeInTheDocument();
+
+ const slot = container.querySelector(
+ '.wc-block-components-shipping-rates-control'
+ );
+ expect( slot ).toBeInTheDocument();
+ expect( slot ).toHaveClass( 'custom-shipping' );
+ } );
+
+ it( 'passes all expected fillProps', () => {
+ const extensions = { 'shipping-ext': { zones: [] } };
+ const cart = { shippingRates: [ { rate: '5.00' } ] };
+ const components = { ShippingRate: () => null };
+ const renderOption = jest.fn();
+
+ const fillProps = getFillProps( ExperimentalOrderShippingPackages, {
+ extensions,
+ cart,
+ components,
+ context: 'woocommerce/cart',
+ noResultsMessage: 'No options available',
+ renderOption,
+ collapsible: true,
+ showItems: true,
+ } );
+
+ expect( fillProps ).toEqual(
+ expect.objectContaining( {
+ extensions,
+ cart,
+ components,
+ context: 'woocommerce/cart',
+ noResultsMessage: 'No options available',
+ renderOption,
+ collapsible: true,
+ collapse: true,
+ showItems: true,
+ } )
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/checkout/slot/test-utils.tsx b/plugins/woocommerce/client/blocks/packages/checkout/slot/test-utils.tsx
new file mode 100644
index 00000000000..bee29d66682
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/checkout/slot/test-utils.tsx
@@ -0,0 +1,66 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+import { Provider as SlotFillProvider } from 'wordpress-components-slotfill/build-module/slot-fill'; // eslint-disable-line @typescript-eslint/no-unused-vars -- Provider is used as JSX.
+
+type SlotFillComponent = {
+ ( props: { children: React.ReactNode } ): JSX.Element;
+ Slot: ( props: Record< string, unknown > ) => JSX.Element;
+};
+
+/**
+ * Renders a SlotFill pair within a SlotFillProvider.
+ *
+ * Places a spy component inside the Fill that captures the fillProps
+ * passed via cloneElement. Returns both the render result and a
+ * jest.fn() that was called with the received fillProps.
+ *
+ * @param Fill The Fill component (e.g. ExperimentalOrderMeta).
+ * @param slotProps Props passed to Fill.Slot (including fillProps).
+ * @return Object with render result and fillPropsSpy.
+ */
+export const renderSlotFill = (
+ Fill: SlotFillComponent,
+ slotProps: Record< string, unknown > = {}
+) => {
+ const fillPropsSpy = jest.fn();
+
+ // A component that captures all props it receives (merged via cloneElement)
+ // and calls the spy with them for assertion.
+ const FillPropsSpy = ( props: Record< string, unknown > ) => {
+ fillPropsSpy( props );
+ return <div data-testid="fill-content" />;
+ };
+
+ const result = render(
+ <SlotFillProvider>
+ <Fill>
+ <FillPropsSpy />
+ </Fill>
+ <Fill.Slot { ...slotProps } />
+ </SlotFillProvider>
+ );
+
+ return { ...result, fillPropsSpy };
+};
+
+/**
+ * Renders a SlotFill and returns the fillProps received by the fill child.
+ *
+ * Unlike JSON-based approaches, this preserves function references and
+ * other non-serializable values.
+ *
+ * @param Fill The Fill component.
+ * @param slotProps Props to pass to Fill.Slot.
+ * @return The fillProps object.
+ */
+export const getFillProps = (
+ Fill: SlotFillComponent,
+ slotProps: Record< string, unknown > = {}
+): Record< string, unknown > => {
+ const { fillPropsSpy } = renderSlotFill( Fill, slotProps );
+
+ expect( fillPropsSpy ).toHaveBeenCalled();
+ return fillPropsSpy.mock.calls[ 0 ][ 0 ];
+};
diff --git a/plugins/woocommerce/client/blocks/tests/js/jest.config.json b/plugins/woocommerce/client/blocks/tests/js/jest.config.json
index e1d14755539..0207c4da582 100644
--- a/plugins/woocommerce/client/blocks/tests/js/jest.config.json
+++ b/plugins/woocommerce/client/blocks/tests/js/jest.config.json
@@ -14,6 +14,7 @@
"@wordpress/core-data": "<rootDir>/node_modules/@wordpress/core-data",
"@wordpress/components": "<rootDir>/node_modules/@wordpress/components",
"@woocommerce/data": "<rootDir>/node_modules/@woocommerce/data/build",
+ "@woocommerce/sanitize": "<rootDir>/node_modules/@woocommerce/sanitize/src/index.ts",
"@woocommerce/atomic-blocks": "assets/js/atomic/blocks",
"@woocommerce/atomic-utils": "assets/js/atomic/utils",
"@woocommerce/icons": "assets/js/icons",