Commit 498a4b9e4e0 for woocommerce

commit 498a4b9e4e080e01d651d31d0ca3c42cb608c402
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Fri Nov 6 12:46:38 2026 +0200

    [tests] Fix order-dependent failures in Jest and PHPUnit (#68877)

    * test(blocks): Make the Product Gallery editor tests pass in any order

    The file's MSW server had no handler for the `OPTIONS /wp/v2/settings`
    preflight the editor sends, so MSW warned about an unhandled request.
    Only the second `initializeEditor()` call in the file raised the warning,
    whichever test made it, which left the two editor tests contradicting
    each other: one asserted a warning was raised and the other asserted
    none was. Under `--randomize --seed=42` one of them always failed.

    Handle the preflight and the settings request so neither editor test
    warns, and drop the blanket `expect( console ).toHaveWarned()` from the
    image ratio test, which asserted nothing about the block.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(blocks): Make the Product Details editor tests pass in any order

    The auto-remove test installs MSW overrides that answer the product
    requests with a product that has no specifications, and nothing took
    them back out. Whenever it ran first, the specifications test got the
    override too and never found the Additional information tab, so
    `--randomize --seed=987654321` failed.

    Reset the handlers after every editor integration test, which also makes
    the reset at the top of the auto-remove test redundant.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(email editor): Register email editor routes inside rest_api_init

    Both route-registration tests called `rest_get_server()`, which fires
    `rest_api_init` only for the first caller in the process. WordPress
    teardown rewinds `did_action()` but leaves the `$wp_rest_server` global
    in place, so whichever test ran first registered its routes without a
    notice and the other one got the "routes must be registered on
    rest_api_init" notice. One of the two failed on every random order
    tried, including `--random-order-seed=11`.

    Give each test its own `WP_REST_Server`, fire `rest_api_init` on it,
    register inside that action and restore the previous global, the way the
    reset dispatch test already does. Neither test needs to expect the
    notice any more.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(product reviews): Restore the reviews list table on the shared Reviews instance

    `test_render_reviews_list_table` reflects a `ReviewsListTable` onto the
    container's shared `Reviews` instance and left it there. The instance
    outlives the test, so `test_load_reviews_screen`, which asserts the
    property starts out null, failed whenever it ran after, as it does under
    `--random-order-seed=11`.

    Capture the previous value and put it back in a `finally` block, the way
    `test_load_reviews_screen_registers_per_page_option` already does. The
    filter removal moves into the same block so an assertion failure cannot
    leave it registered.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(admin): Load marketplace suggestions for the admin assets tests

    `WC_Admin_Assets` calls into `WC_Marketplace_Suggestions`, and neither
    the bootstrap nor this test file loads that class. It happened to exist
    only when `OrderAttributionTest.php` was part of the same process, since
    that file requires it at file scope. Running the class on its own errors
    5 of its 9 tests with `Class "WC_Marketplace_Suggestions" not found`.

    Require the class from the test file so it does not depend on which
    other test files the process loaded.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(orders): Find the status transition note by its text

    The order data meta box save test read the newest order note and
    compared it to the status-change text. When the transactional email for
    the new status actually sends, the email logger records a note after the
    status note, so the newest note is `Email "Cancelled order" sent.` and
    the cancelled data set fails. Whether the email sends depends on what an
    earlier test in the class left behind, so the result depends on the
    order: `--random-order-seed=2` and `3` fail.

    Look for the status-change note among all the order's notes, which
    keeps the assertion on the note text and no longer depends on how many
    notes the transition produced.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(cart): Give the coupon case-sensitivity test its own taxable address

    `WC_Tax::find_rates()` returns nothing for a customer without a country,
    so the coupon discount tax came out as 0.00 instead of 2.00 whenever the
    test ran after a test that left the customer address empty.
    `--random-order-seed=6` does that.

    Set the customer's billing and shipping country in the test and put the
    previous values back, so it no longer inherits an address. The shipping
    requirements test also gets a real restore: it reset the customer to a
    hardcoded GB address rather than the one it found, which handed the same
    dependency to whatever ran next.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(store api): Clear saved additional field values between tests

    `WC()->customer` is built once for the whole process, so the additional
    field values the checkout route saves on it stay readable by every later
    test in the class. A conditional rule reads those saved values, so
    whether a hidden field's posted value was cleared depended on what an
    earlier test had saved for the field it depends on:
    `--random-order-seed=20260917` left `referral-source` at `other`, which
    made the dependent field visible and kept its posted value.

    Delete the billing, shipping and other additional field values from the
    customer in `tearDown()`, so each test sees only what it saves itself.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(blocks): Run the Product Query tests in the main process

    `@runInSeparateProcess` makes the child re-run the whole bootstrap,
    including the WordPress install, which drops and recreates every table
    mid-suite and replaces committed state for the tests that follow.
    `WP_UnitTestCase` already restores hooks between tests, so the test does
    not need its own process.

    The two rest query tests hardcoded 0 as the excluded-from-catalog
    product visibility term, which only held while no product had been
    created in the process. Read the term id instead, so they pass whether
    the file runs alone or after
    `ProductCollection/RouteContextParityTest.php`.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(framework): Reset code hacks when a WooCommerce test case ends

    `CodeHacker` mocks replace functions and static methods for the whole
    process. `WC_Unit_Test_Case::setUp()` clears them, which covers the next
    test in that hierarchy but leaves them installed for a test case built on
    a WordPress base class. In the `G2-includes` group at
    `--random-order-seed=11`, `WC_Settings_General_Test` left a
    `get_woocommerce_currencies()` mock returning two made-up codes, so
    `wc_create_order()` in `WC_AJAX_Test` threw "Invalid currency code" and
    returned a `WP_Error`. The order helper then called `add_item()` on it,
    erroring 15 of that class's tests.

    Clear the mocks in `tearDown()` as well, so the test that installs them
    is the last one that sees them.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(cart): Give the cart totals tests their own taxable customer location

    The tax rates these tests insert carry no country, and
    `WC_Tax::find_rates()` returns nothing for a customer whose taxable
    address has no country either. The class never set one, so it read
    whatever `WC()->customer` held, and that object outlives a test. With
    `WC_Cart_Test` restoring the address it finds instead of leaving `GB`
    behind, `class-wc-cart-totals-test.php` runs next in the suite with an
    empty country, calculates no tax, and all four PHPUnit CI jobs fail
    `test_discount_tax_rounding`,
    `test_subtotal_rounding_with_changing_precision` and
    `test_total_rounding_with_price_entered_has_high_precision` with
    '2000' where '1575' is expected.

    Set the customer's billing and shipping country in `setUp()` and put the
    previous values back in `tearDown()`, so the class no longer depends on
    what ran before it.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(store api): Restore, not delete, the customer's additional field values

    `AdditionalFields::tearDown()` deleted every additional field value on
    `WC()->customer` (any meta under the billing, shipping or other field
    prefixes). That also removed values the class did not create, for
    example a `_wc_other/another-plugin/field` left by earlier code, so the
    cleanup itself changed shared state.

    Snapshot the class's view of those values in `setUp()` and, in
    `tearDown()`, delete only the keys added since and put the saved values
    back. Each test still starts from the same values, so the conditional
    rule tests keep their isolation.

    Checked: a probe that sets `_wc_other/another-plugin/field`, then runs
    `test_conditional_required_contact_field_ignores_missing_dependency_key`,
    then reads the meta, lost it before this change and keeps it after. The
    reverse-order pairs from the original leak
    (`test_conditional_required_contact_field_ignores_missing_dependency_key`
    before `test_conditional_hidden_field_cleared_when_dependency_has_no_value`
    or `test_conditional_required_field_ignores_missing_dependency_key`) fail
    with the restore call removed and pass with it. The class passes at
    seeds 1, 7, 1234 and 20260917 (74 tests).

    Refs TESTOPS-234

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * chore: Fix test isolation changelog format

    Keep this test-only change out of merchant release notes by moving
    the description into the reviewer-only Comment field.

    * test: Simplify test isolation using existing lifecycle helpers

    Trunk now restores the cart tests' shipping addresses at teardown,
    so the per-test snapshots duplicate the shared lifecycle. Rely on
    that restoration and the existing cart cleanup and database rollback,
    keeping the coupon test's explicit taxable address and billing restore.

    Use scoped REST registration for the two email route tests and let
    WordPress teardown restore the review-table filter. Preserve the REST
    server and shared list-table restoration.

    The 170 focused tests pass at random seeds 6 and 11. Changed-file PHP
    lint and Semgrep pass. The staged gate excludes these test files from
    PHPStan because they are outside its configured production roots.

    * test: Restore ProductQuery interactivity state after rendering

    Running the ProductQuery context test in the main process leaves
    ProductsStore's products and getters_registered statics and the
    Interactivity API state_data changed. Database rollback does not reset
    those stores.

    Snapshot the affected values and restore them in finally, preserving
    pre-existing state even when the test throws. Keep the test in the main
    process to avoid the child bootstrap rerunning the database install.

    A local probe fails before the fix and passes afterward for empty and
    populated stores and an exception after hydration. The 44 related tests
    pass at random seeds 11 and 20260917. PHP lint and Semgrep pass; PHPStan
    reports the same 71 existing test-framework diagnostics on both versions.

    ---------

    Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-test-order-dependence b/plugins/woocommerce/changelog/fix-test-order-dependence
new file mode 100644
index 00000000000..4656446c261
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-test-order-dependence
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Improve Jest and PHPUnit test isolation; no production change.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-details/test/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-details/test/block.tsx
index faf68edc7eb..0a26677e3b7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-details/test/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-details/test/block.tsx
@@ -184,6 +184,7 @@ describe( 'Product Details block', () => {
 		} );

 		afterEach( () => {
+			server.resetHandlers();
 			( useSelect as jest.Mock ).mockClear();
 		} );

@@ -244,7 +245,6 @@ describe( 'Product Details block', () => {
 		} );

 		test( 'should auto-remove block when product has no specifications', async () => {
-			server.resetHandlers();
 			server.use(
 				http.get( '/wc/store/v1/products/*', () =>
 					HttpResponse.json( productWithoutSpecifications )
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/test/block.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/test/block.ts
index f9eade60479..7d683d338ff 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/test/block.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/test/block.ts
@@ -71,6 +71,15 @@ const handlers = [
 		return HttpResponse.json( {} );
 	} ),

+	http.get( '/wp/v2/settings', () => {
+		return HttpResponse.json( {} );
+	} ),
+
+	// The editor preflights several of its requests, and an unhandled request makes MSW warn.
+	http.options( '*', () => {
+		return HttpResponse.json( {} );
+	} ),
+
 	http.get( '/wc/store/v1/products/:id', () => {
 		return HttpResponse.json( {
 			id: 123,
@@ -250,9 +259,5 @@ describe( 'Product Gallery Block', () => {
 			name: /Block: Thumbnails/i,
 		} );
 		expect( thumbnailsBlock ).toBeInTheDocument();
-
-		// wp-6.8: upstream @wordpress/* deprecation warnings that we cannot
-		// opt out of without changing the visual output.
-		expect( console ).toHaveWarned();
 	} );
 } );
diff --git a/plugins/woocommerce/tests/legacy/framework/class-wc-unit-test-case.php b/plugins/woocommerce/tests/legacy/framework/class-wc-unit-test-case.php
index 363045e2347..23af2964158 100644
--- a/plugins/woocommerce/tests/legacy/framework/class-wc-unit-test-case.php
+++ b/plugins/woocommerce/tests/legacy/framework/class-wc-unit-test-case.php
@@ -185,6 +185,11 @@ class WC_Unit_Test_Case extends WP_HTTP_TestCase {
 	 */
 	public function tearDown(): void {
 		try {
+			// Function and static-method mocks are process-wide. Reset them here so the test
+			// that installs one is the last to see it, including when the next test extends a
+			// WordPress base class that never runs setUp() on this hierarchy.
+			CodeHacker::reset_hacks();
+
 			$this->clear_wc_singleton_state();
 		} finally {
 			// The parent teardown must always run: it rolls back the database
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php
index 7ea70a2b62d..15a2be56724 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php
@@ -1,6 +1,9 @@
 <?php
 declare( strict_types = 1 );

+// WC_Admin_Assets reads the marketplace suggestions, and nothing in the bootstrap loads that class.
+require_once WC_ABSPATH . 'includes/admin/marketplace-suggestions/class-wc-marketplace-suggestions.php';
+
 /**
  * Tests for WC_Admin_Assets.
  *
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
index 7433577dcc6..d54e41d9218 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -1689,7 +1689,6 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
 	 * @see https://github.com/woocommerce/woocommerce/issues/58864
 	 */
 	public function test_coupon_discount_amount_case_sensitivity() {
-		$old_calc_taxes = get_option( 'woocommerce_calc_taxes', 'no' );
 		update_option( 'woocommerce_calc_taxes', 'yes' );

 		$tax_rate = array(
@@ -1703,57 +1702,55 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
 			'tax_rate_order'    => '1',
 		);

-		$tax_rate_id = WC_Tax::_insert_tax_rate( $tax_rate );
+		WC_Tax::_insert_tax_rate( $tax_rate );

-		// Create a product to add to cart.
-		$product = WC_Helper_Product::create_simple_product();
-		$product->set_regular_price( 100 );
-		$product->save();
+		// Rates are only ever found for a customer with a country, and the customer object
+		// carries whatever address the test before this one left on it.
+		$previous_billing_country = WC()->customer->get_billing_country();
+		WC()->customer->set_shipping_country( 'GB' );
+		WC()->customer->set_billing_country( 'GB' );

-		// Create a coupon with uppercase code.
-		$coupon = WC_Helper_Coupon::create_coupon( 'TESTCOUPON123' );
-		$coupon->set_discount_type( 'fixed_cart' );
-		$coupon->set_amount( 10 );
-		$coupon->save();
-
-		// Add product to cart.
-		WC()->cart->add_to_cart( $product->get_id(), 1 );
-		WC()->cart->calculate_totals();
+		try {
+			// Create a product to add to cart.
+			$product = WC_Helper_Product::create_simple_product();
+			$product->set_regular_price( 100 );
+			$product->save();

-		// Apply the coupon using lowercase code.
-		$applied = WC()->cart->apply_coupon( 'testcoupon123' );
-		$this->assertTrue( $applied, 'Coupon should be applied successfully with lowercase code' );
+			// Create a coupon with uppercase code.
+			$coupon = WC_Helper_Coupon::create_coupon( 'TESTCOUPON123' );
+			$coupon->set_discount_type( 'fixed_cart' );
+			$coupon->set_amount( 10 );
+			$coupon->save();

-		// Verify the coupon is in applied coupons (should be stored in original case).
-		$applied_coupons = WC()->cart->get_applied_coupons();
-		$this->assertContains( 'testcoupon123', $applied_coupons, 'Coupon should be stored in provided case' );
+			// Add product to cart.
+			WC()->cart->add_to_cart( $product->get_id(), 1 );
+			WC()->cart->calculate_totals();

-		// Test get_coupon_discount_amount with lowercase code.
-		$discount_amount = WC()->cart->get_coupon_discount_amount( 'testcoupon123' );
-		$this->assertEquals( 10.00, $discount_amount, 'get_coupon_discount_amount should return correct value with lowercase code' );
+			// Apply the coupon using lowercase code.
+			$applied = WC()->cart->apply_coupon( 'testcoupon123' );
+			$this->assertTrue( $applied, 'Coupon should be applied successfully with lowercase code' );

-		// Test get_coupon_discount_amount with uppercase code.
-		$discount_amount_upper = WC()->cart->get_coupon_discount_amount( 'TESTCOUPON123' );
-		$this->assertEquals( 10.00, $discount_amount_upper, 'get_coupon_discount_amount should return correct value with uppercase code' );
+			// Verify the coupon is in applied coupons (should be stored in original case).
+			$applied_coupons = WC()->cart->get_applied_coupons();
+			$this->assertContains( 'testcoupon123', $applied_coupons, 'Coupon should be stored in provided case' );

-		// Test get_coupon_discount_tax_amount with lowercase code.
-		$tax_amount = WC()->cart->get_coupon_discount_tax_amount( 'testcoupon123' );
-		$this->assertEquals( 2.00, $tax_amount, 'get_coupon_discount_tax_amount should return correct value with lowercase code' );
+			// Test get_coupon_discount_amount with lowercase code.
+			$discount_amount = WC()->cart->get_coupon_discount_amount( 'testcoupon123' );
+			$this->assertEquals( 10.00, $discount_amount, 'get_coupon_discount_amount should return correct value with lowercase code' );

-		// Test get_coupon_discount_tax_amount with uppercase code.
-		$tax_amount_upper = WC()->cart->get_coupon_discount_tax_amount( 'TESTCOUPON123' );
-		$this->assertEquals( 2.00, $tax_amount_upper, 'get_coupon_discount_tax_amount should return correct value with uppercase code' );
+			// Test get_coupon_discount_amount with uppercase code.
+			$discount_amount_upper = WC()->cart->get_coupon_discount_amount( 'TESTCOUPON123' );
+			$this->assertEquals( 10.00, $discount_amount_upper, 'get_coupon_discount_amount should return correct value with uppercase code' );

-		// Clean up.
-		WC()->cart->empty_cart();
-		WC()->cart->remove_coupons();
-		$product->delete( true );
-		$coupon->delete( true );
+			// Test get_coupon_discount_tax_amount with lowercase code.
+			$tax_amount = WC()->cart->get_coupon_discount_tax_amount( 'testcoupon123' );
+			$this->assertEquals( 2.00, $tax_amount, 'get_coupon_discount_tax_amount should return correct value with lowercase code' );

-		// Restore global state.
-		update_option( 'woocommerce_calc_taxes', $old_calc_taxes );
-		if ( $tax_rate_id ) {
-			WC_Tax::_delete_tax_rate( $tax_rate_id );
+			// Test get_coupon_discount_tax_amount with uppercase code.
+			$tax_amount_upper = WC()->cart->get_coupon_discount_tax_amount( 'TESTCOUPON123' );
+			$this->assertEquals( 2.00, $tax_amount_upper, 'get_coupon_discount_tax_amount should return correct value with uppercase code' );
+		} finally {
+			WC()->customer->set_billing_country( $previous_billing_country );
 		}
 	}

diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-totals-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-totals-test.php
index 9e9f62353d2..6268109e80f 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-totals-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-totals-test.php
@@ -13,6 +13,20 @@ class WC_Cart_Totals_Tests extends WC_Unit_Test_Case {
 	 */
 	private $shipping_was_enabled;

+	/**
+	 * Customer billing country before the test.
+	 *
+	 * @var string
+	 */
+	private $previous_billing_country;
+
+	/**
+	 * Customer shipping country before the test.
+	 *
+	 * @var string
+	 */
+	private $previous_shipping_country;
+
 	/**
 	 * Set up non-shipping cart total tests.
 	 */
@@ -21,12 +35,24 @@ class WC_Cart_Totals_Tests extends WC_Unit_Test_Case {

 		$this->shipping_was_enabled = WC()->shipping()->enabled;
 		WC()->shipping()->enabled   = false;
+
+		// The tax rates these tests insert have no country of their own, and
+		// WC_Tax::find_rates() finds nothing for a customer whose taxable address has no
+		// country. WC()->customer outlives a test, so set a country here instead of reading
+		// whatever the test before left on it.
+		$this->previous_billing_country  = WC()->customer->get_billing_country();
+		$this->previous_shipping_country = WC()->customer->get_shipping_country();
+		WC()->customer->set_billing_country( 'US' );
+		WC()->customer->set_shipping_country( 'US' );
 	}

 	/**
 	 * tearDown.
 	 */
 	public function tearDown(): void {
+		WC()->customer->set_billing_country( $this->previous_billing_country );
+		WC()->customer->set_shipping_country( $this->previous_shipping_country );
+
 		parent::tearDown();
 		WC()->shipping()->enabled = $this->shipping_was_enabled;
 	}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductQuery.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductQuery.php
index 1e8a00206cc..7db4a68d8b5 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductQuery.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductQuery.php
@@ -2,6 +2,7 @@
 namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;

 use Automattic\WooCommerce\Tests\Blocks\Mocks\ProductQueryMock;
+use Automattic\WooCommerce\Blocks\SharedStores\ProductsStore;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use WC_Helper_Product;

@@ -18,72 +19,92 @@ class ProductQuery extends \WP_UnitTestCase {

 	/**
 	 * @testdox Should add Interactivity API context only to valid product loop items.
-	 *
-	 * @runInSeparateProcess
-	 * @preserveGlobalState disabled
 	 */
 	public function test_add_iapi_context_updates_only_valid_product_loop_items(): void {
-		$product_ids   = array();
-		$product_ids[] = WC_Helper_Product::create_simple_product()->get_id();
-		$product_ids[] = WC_Helper_Product::create_simple_product()->get_id();
-		$post_id       = self::factory()->post->create();
-
-		$markup = sprintf(
-			'<ul><li id="first-product" class="wp-block-post post-%1$d"></li><li id="second-product" class="extra wp-block-post post-%2$d"></li><li id="missing-id" class="wp-block-post"></li><li id="malformed-id" class="wp-block-post post-not-a-number"></li><li id="non-product" class="wp-block-post post-%3$d"></li></ul>',
-			$product_ids[0],
-			$product_ids[1],
-			$post_id
-		);
-
-		$this->assertSame(
-			$markup,
-			$this->block_instance->add_iapi_context(
-				$markup,
-				array( 'attrs' => array( '__woocommerceNamespace' => 'another-block' ) )
-			),
-			'A different block namespace should leave the markup byte-identical.'
-		);
+		// These stores outlive the WordPress database rollback.
+		$products_store = new \ReflectionClass( ProductsStore::class );
+		$previous_store = array();
+		foreach ( array( 'products', 'getters_registered' ) as $name ) {
+			$property = $products_store->getProperty( $name );
+			$property->setAccessible( true );
+			$previous_store[ $name ] = $property->getValue();
+		}

-		$processed_markup = $this->block_instance->add_iapi_context(
-			$markup,
-			array( 'attrs' => array( '__woocommerceNamespace' => 'woocommerce/product-query/product-template' ) )
-		);
-		$processor        = new \WP_HTML_Tag_Processor( $processed_markup );
-		$items            = array();
-
-		while ( $processor->next_tag( array( 'tag_name' => 'LI' ) ) ) {
-			$items[ $processor->get_attribute( 'id' ) ] = array(
-				'interactive' => $processor->get_attribute( 'data-wp-interactive' ),
-				'context'     => $processor->get_attribute( 'data-wp-context' ),
-				'key'         => $processor->get_attribute( 'data-wp-key' ),
+		$interactivity  = wp_interactivity();
+		$state_property = new \ReflectionProperty( $interactivity, 'state_data' );
+		$state_property->setAccessible( true );
+		$previous_state = $state_property->getValue( $interactivity );
+
+		try {
+			$product_ids   = array();
+			$product_ids[] = WC_Helper_Product::create_simple_product()->get_id();
+			$product_ids[] = WC_Helper_Product::create_simple_product()->get_id();
+			$post_id       = self::factory()->post->create();
+
+			$markup = sprintf(
+				'<ul><li id="first-product" class="wp-block-post post-%1$d"></li><li id="second-product" class="extra wp-block-post post-%2$d"></li><li id="missing-id" class="wp-block-post"></li><li id="malformed-id" class="wp-block-post post-not-a-number"></li><li id="non-product" class="wp-block-post post-%3$d"></li></ul>',
+				$product_ids[0],
+				$product_ids[1],
+				$post_id
 			);
-		}

-		foreach (
-			array(
-				'first-product'  => $product_ids[0],
-				'second-product' => $product_ids[1],
-			) as $item_id => $product_id
-		) {
-			$this->assertSame( 'woocommerce/products', $items[ $item_id ]['interactive'] );
-			$this->assertSame( 'product-item-' . $product_id, $items[ $item_id ]['key'] );
-			$context = $items[ $item_id ]['context'];
-			$this->assertIsString( $context );
-			list( $namespace, $json_context ) = explode( '::', $context, 2 );
-			$this->assertSame( 'woocommerce/products', $namespace );
 			$this->assertSame(
-				array(
-					'productId'   => $product_id,
-					'variationId' => null,
+				$markup,
+				$this->block_instance->add_iapi_context(
+					$markup,
+					array( 'attrs' => array( '__woocommerceNamespace' => 'another-block' ) )
 				),
-				json_decode( $json_context, true )
+				'A different block namespace should leave the markup byte-identical.'
 			);
-		}

-		foreach ( array( 'missing-id', 'malformed-id', 'non-product' ) as $item_id ) {
-			$this->assertNull( $items[ $item_id ]['interactive'], "$item_id should not become interactive." );
-			$this->assertNull( $items[ $item_id ]['context'], "$item_id should not receive product context." );
-			$this->assertNull( $items[ $item_id ]['key'], "$item_id should not receive an Interactivity API key." );
+			$processed_markup = $this->block_instance->add_iapi_context(
+				$markup,
+				array( 'attrs' => array( '__woocommerceNamespace' => 'woocommerce/product-query/product-template' ) )
+			);
+			$processor        = new \WP_HTML_Tag_Processor( $processed_markup );
+			$items            = array();
+
+			while ( $processor->next_tag( array( 'tag_name' => 'LI' ) ) ) {
+				$items[ $processor->get_attribute( 'id' ) ] = array(
+					'interactive' => $processor->get_attribute( 'data-wp-interactive' ),
+					'context'     => $processor->get_attribute( 'data-wp-context' ),
+					'key'         => $processor->get_attribute( 'data-wp-key' ),
+				);
+			}
+
+			foreach (
+				array(
+					'first-product'  => $product_ids[0],
+					'second-product' => $product_ids[1],
+				) as $item_id => $product_id
+			) {
+				$this->assertSame( 'woocommerce/products', $items[ $item_id ]['interactive'] );
+				$this->assertSame( 'product-item-' . $product_id, $items[ $item_id ]['key'] );
+				$context = $items[ $item_id ]['context'];
+				$this->assertIsString( $context );
+				list( $namespace, $json_context ) = explode( '::', $context, 2 );
+				$this->assertSame( 'woocommerce/products', $namespace );
+				$this->assertSame(
+					array(
+						'productId'   => $product_id,
+						'variationId' => null,
+					),
+					json_decode( $json_context, true )
+				);
+			}
+
+			foreach ( array( 'missing-id', 'malformed-id', 'non-product' ) as $item_id ) {
+				$this->assertNull( $items[ $item_id ]['interactive'], "$item_id should not become interactive." );
+				$this->assertNull( $items[ $item_id ]['context'], "$item_id should not receive product context." );
+				$this->assertNull( $items[ $item_id ]['key'], "$item_id should not receive an Interactivity API key." );
+			}
+		} finally {
+			foreach ( $previous_store as $name => $value ) {
+				$property = $products_store->getProperty( $name );
+				$property->setAccessible( true );
+				$property->setValue( null, $value );
+			}
+			$state_property->setValue( $interactivity, $previous_state );
 		}
 	}

@@ -613,6 +634,9 @@ class ProductQuery extends \WP_UnitTestCase {
 		$args    = array();
 		$request = $this->build_request();

+		// The term is created on install, so its id depends on what the site already holds.
+		$excluded_from_catalog = wc_get_product_visibility_term_ids()['exclude-from-catalog'];
+
 		$updated_query = $this->block_instance->update_rest_query( $args, $request );

 		$this->assertContainsEquals(
@@ -629,7 +653,7 @@ class ProductQuery extends \WP_UnitTestCase {
 				array(
 					'taxonomy' => 'product_visibility',
 					'field'    => 'term_taxonomy_id',
-					'terms'    => array( 0 ),
+					'terms'    => array( $excluded_from_catalog ),
 					'operator' => 'NOT IN',
 				),
 			),
@@ -652,6 +676,9 @@ class ProductQuery extends \WP_UnitTestCase {
 		$stock_status = array( ProductStockStatus::IN_STOCK, ProductStockStatus::OUT_OF_STOCK );
 		$request      = $this->build_request( $on_sale, $attributes, $stock_status );

+		// The term is created on install, so its id depends on what the site already holds.
+		$excluded_from_catalog = wc_get_product_visibility_term_ids()['exclude-from-catalog'];
+
 		$updated_query = $this->block_instance->update_rest_query( $args, $request );

 		$this->assertContainsEquals(
@@ -674,7 +701,7 @@ class ProductQuery extends \WP_UnitTestCase {
 				array(
 					'taxonomy' => 'product_visibility',
 					'field'    => 'term_taxonomy_id',
-					'terms'    => array( 0 ),
+					'terms'    => array( $excluded_from_catalog ),
 					'operator' => 'NOT IN',
 				),
 			),
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
index b481075eebb..54f29efeae6 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
@@ -49,6 +49,13 @@ class AdditionalFields extends \WP_Test_REST_TestCase {
 	 */
 	protected $products;

+	/**
+	 * Additional field values on the customer before the test, restored at teardown.
+	 *
+	 * @var array<string, mixed>
+	 */
+	private $previous_customer_field_values = array();
+
 	/**
 	 * Create immutable catalog rows shared by all test methods.
 	 */
@@ -100,6 +107,7 @@ class AdditionalFields extends \WP_Test_REST_TestCase {
 	 */
 	protected function setUp(): void {
 		parent::setUp();
+		$this->previous_customer_field_values = $this->get_customer_additional_field_values();
 		update_option( 'woocommerce_checkout_phone_field', 'optional' );
 		add_filter( 'doing_it_wrong_trigger_error', '__return_false' );

@@ -131,9 +139,64 @@ class AdditionalFields extends \WP_Test_REST_TestCase {
 		remove_all_actions( 'doing_it_wrong_run' );
 		remove_filter( 'doing_it_wrong_trigger_error', '__return_false' );
 		$this->unregister_fields();
+		$this->restore_customer_additional_field_values();
 		parent::tearDown();
 	}

+	/**
+	 * Put the customer's additional field values back to what they were before the test.
+	 *
+	 * `WC()->customer` is built once for the whole process, so the values one test saves stay
+	 * readable by the next one and decide what a conditional rule sees. Restoring them in memory
+	 * is enough: the customer is read back from the session, which this teardown destroys.
+	 */
+	private function restore_customer_additional_field_values() {
+		$customer = WC()->customer;
+
+		if ( ! $customer ) {
+			return;
+		}
+
+		foreach ( array_diff_key( $this->get_customer_additional_field_values(), $this->previous_customer_field_values ) as $key => $value ) {
+			$customer->delete_meta_data( $key );
+		}
+
+		foreach ( $this->previous_customer_field_values as $key => $value ) {
+			$customer->update_meta_data( $key, $value );
+		}
+	}
+
+	/**
+	 * Get the additional field values saved on the customer, keyed by meta key.
+	 *
+	 * @return array<string, mixed>
+	 */
+	private function get_customer_additional_field_values(): array {
+		$customer = WC()->customer;
+		$values   = array();
+
+		if ( ! $customer ) {
+			return $values;
+		}
+
+		$prefixes = array(
+			CheckoutFields::BILLING_FIELDS_PREFIX,
+			CheckoutFields::SHIPPING_FIELDS_PREFIX,
+			CheckoutFields::OTHER_FIELDS_PREFIX,
+		);
+
+		foreach ( $customer->get_meta_data() as $meta ) {
+			foreach ( $prefixes as $prefix ) {
+				if ( 0 === strpos( (string) $meta->key, $prefix ) ) {
+					$values[ $meta->key ] = $meta->value;
+					break;
+				}
+			}
+		}
+
+		return $values;
+	}
+
 	/**
 	 * Register fields for testing.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsTest.php
index 3bbe6e4a4df..087b02c156d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsTest.php
@@ -319,11 +319,12 @@ class ReviewsTest extends WC_Unit_Test_Case {
 	public function test_render_reviews_list_table() : void {
 		$GLOBALS['hook_suffix'] = 'product_page_product-reviews'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited

-		$reviews = wc_get_container()->get( Reviews::class );
+		$reviews    = wc_get_container()->get( Reviews::class );
 		$list_table = new ReviewsListTable( [ 'screen' => 'product_page_product-reviews' ] );

 		$property = ( new ReflectionClass( $reviews ) )->getProperty( 'reviews_list_table' );
 		$property->setAccessible( true );
+		$previous_list_table = $property->getValue( $reviews );
 		$property->setValue( $reviews, $list_table );

 		add_filter(
@@ -335,9 +336,14 @@ class ReviewsTest extends WC_Unit_Test_Case {

 		ob_start();

-		$reviews->render_reviews_list_table();
+		try {
+			$reviews->render_reviews_list_table();
+		} finally {
+			$output = ob_get_clean();

-		$output = ob_get_clean();
+			// The container hands out one shared Reviews instance, and tear_down() does not reset it.
+			$property->setValue( $reviews, $previous_list_table );
+		}

 		$this->assertStringContainsString( '<form id="reviews-filter" method="get">', $output );
 		$this->assertStringContainsString( '<input type="hidden" name="page" value="' . Reviews::MENU_SLUG . '" />', $output );
@@ -345,8 +351,6 @@ class ReviewsTest extends WC_Unit_Test_Case {
 		$this->assertStringContainsString( '<input type="hidden" name="pagegen_timestamp" value="', $output );
 		$this->assertStringContainsString( 'id="trash-undo-holder"', $output, 'Core comment list script copies the trash Undo notice from this holder.' );
 		$this->assertStringEndsWith( 'custom additional content', $output );
-
-		remove_all_filters( 'woocommerce_product_reviews_list_table' );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
index 94a6c758867..88071a5e53b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
@@ -1010,10 +1010,19 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 	 * @testdox Should register a POST /reset route alongside the existing default-content route.
 	 */
 	public function test_register_routes_registers_reset_endpoint(): void {
-		$rest_server = rest_get_server();
-		$this->email_api_controller->register_routes();
+		global $wp_rest_server;
+
+		$previous_rest_server = $wp_rest_server;
+
+		try {
+			$this->create_rest_server_with_routes( array( array( $this->email_api_controller, 'register_routes' ) ) );
+
+			$routes = $wp_rest_server->get_routes();
+		} finally {
+			// tear_down() does not touch $wp_rest_server.
+			$wp_rest_server = $previous_rest_server;
+		}

-		$routes = $rest_server->get_routes();
 		$this->assertArrayHasKey( '/woocommerce-email-editor/v1/emails/(?P<id>\d+)/reset', $routes );

 		$reset_route_handlers = $routes['/woocommerce-email-editor/v1/emails/(?P<id>\d+)/reset'];
@@ -1030,16 +1039,19 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 	 * @testdox Should register a GET /change-summary route alongside the existing default-content route.
 	 */
 	public function test_register_routes_registers_change_summary_endpoint(): void {
-		// `register_rest_route()` warns when called outside `rest_api_init`. The
-		// reset sibling test only avoids the warning because it's the first
-		// caller of `rest_get_server()` in the suite, which lazily fires the
-		// action; this test runs after that, so we opt into the warning.
-		$this->setExpectedIncorrectUsage( 'register_rest_route' );
+		global $wp_rest_server;
+
+		$previous_rest_server = $wp_rest_server;
+
+		try {
+			$this->create_rest_server_with_routes( array( array( $this->email_api_controller, 'register_routes' ) ) );

-		$rest_server = rest_get_server();
-		$this->email_api_controller->register_routes();
+			$routes = $wp_rest_server->get_routes();
+		} finally {
+			// tear_down() does not touch $wp_rest_server.
+			$wp_rest_server = $previous_rest_server;
+		}

-		$routes = $rest_server->get_routes();
 		$this->assertArrayHasKey( '/woocommerce-email-editor/v1/emails/(?P<id>\d+)/change-summary', $routes );

 		$change_summary_route_handlers = $routes['/woocommerce-email-editor/v1/emails/(?P<id>\d+)/change-summary'];