Commit 52b3201fb9f for woocommerce

commit 52b3201fb9f1717e4117472b5bcb22a2848ad1bb
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Oct 27 12:02:21 2026 +0200

    [tests] Add a trait that swaps block types in and out of the registry per test (#69611)

    * test(blocks): Add a trait that swaps block types in for a test

    Block tests register their mock block types in one of two ways.
    CouponCodeTest, MiniCart and MiniCartFooterBlock each copy the same
    save, unregister, register and restore steps. AddToCartWithOptions,
    VariationSelectorAttribute and ProductPageIntegrationTest register
    their mocks only when missing and never clean up, which led to two
    order-dependent failures in #69117.

    BlockTypeRegistryTrait extracts the first pattern: replace_block_type()
    saves what a name had before the test and registers the replacement,
    and restore_block_types() puts the registry back. It rejects a callback
    that registers a different name, since that replacement would leak
    under its real name.

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

    * test(blocks): Swap mock block types through BlockTypeRegistryTrait

    CouponCodeTest, MiniCart and MiniCartFooterBlock each saved the
    registered block type, unregistered it, registered a mock, and put the
    original back in tearDown(). Use BlockTypeRegistryTrait for those steps
    instead; behavior is unchanged.

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

    * test(blocks): Swap the Add to Cart with Options mocks in per test

    AddToCartWithOptions, VariationSelectorAttribute and
    ProductPageIntegrationTest registered their mock block types only when
    missing and never removed them, so each class saw whatever the classes
    before it had registered. #69117 fixed two order-dependent failures
    this caused by checking each block by name.

    Use BlockTypeRegistryTrait instead: each test registers the mocks it
    needs and tearDown() puts the registry back, so no class depends on
    what ran before it. The static "already registered" flags go away.
    Registering per test does not change the three classes' runtime
    (about 1.4s before and after).

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

    * test(blocks): Use arrow functions for the block type factories

    Review simplification. Every factory passed to replace_block_type()
    only returns a new block type, so a static arrow function says the
    same in one line instead of five; the repository already uses them in
    tests and supports PHP 7.4. Also explain why BlockTypeRegistryTraitTest
    still cleans up in tearDown(): restore_block_types() puts back the
    original a test registered, and a callback can register a name the
    trait never tracked.

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

    * test: Simplify block registry cleanup with a PHPUnit hook

    Block tests repeated explicit restoration calls even though PHPUnit can
    run the shared cleanup after each test. Use the after hook to own that
    lifecycle and remove the redundant teardown code from its adopters.

    Verify automatic restoration through an actual PHPUnit lifecycle, and
    assert that a rejected registration callback still allows the original
    block type object to be restored. Keep explicit cleanup in the helper
    tests before their synthetic originals are removed.

    * test: Strengthen block registry replacement assertions

    The class-construction test only checked that the coupon block was
    registered, so a no-op replacement still passed against the existing
    registration.

    Assert that replacement changes the registered object and assigns the
    requested mock as its render callback owner. Confirm the stronger test
    fails when replacement is disabled and passes with the helper restored.

    * test: Remove unused block mock registration methods

    All Add to Cart with Options mock consumers now use get_mock_classes()
    with the per-test registry replacement trait. The register() and
    register_all() entry points no longer have callers.

    Remove both methods and describe the helper's remaining responsibility
    as a map of block names to mock classes.

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/test-block-type-registry-trait b/plugins/woocommerce/changelog/test-block-type-registry-trait
new file mode 100644
index 00000000000..94eb7f80a28
--- /dev/null
+++ b/plugins/woocommerce/changelog/test-block-type-registry-trait
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Add a test helper that swaps block types in and out of the registry per test; no production change.
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
index adee11bb09a..d328bdc792b 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;

 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Tests\Blocks\Utils\WC_Product_Custom;
+use Automattic\WooCommerce\Tests\Blocks\Helpers\BlockTypeRegistryTrait;
 use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
 use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsMocks;
 use Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\Utils;
@@ -15,6 +16,7 @@ use Automattic\WooCommerce\Internal\Features\FeaturesController;
  * Tests for the AddToCartWithOptions block type
  */
 class AddToCartWithOptions extends \WP_UnitTestCase {
+	use BlockTypeRegistryTrait;

 	/**
 	 * Initiate the mock object.
@@ -22,7 +24,8 @@ class AddToCartWithOptions extends \WP_UnitTestCase {
 	protected function setUp(): void {
 		parent::setUp();

-		AddToCartWithOptionsMocks::register_all();
+		// The blocks are not registered on `init`, because `init` runs with a classic theme.
+		$this->replace_block_types( AddToCartWithOptionsMocks::get_mock_classes() );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/CouponCodeTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/CouponCodeTest.php
index 4a9ecab8d98..5668d8bf723 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/CouponCodeTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/CouponCodeTest.php
@@ -3,6 +3,7 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;

+use Automattic\WooCommerce\Tests\Blocks\Helpers\BlockTypeRegistryTrait;
 use Automattic\WooCommerce\Tests\Blocks\Mocks\CouponCodeMock;
 use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
 use Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller;
@@ -12,6 +13,7 @@ use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering
  * Tests for the CouponCode block type.
  */
 class CouponCodeTest extends \WP_UnitTestCase {
+	use BlockTypeRegistryTrait;

 	/**
 	 * Mock instance of the CouponCode block.
@@ -27,50 +29,18 @@ class CouponCodeTest extends \WP_UnitTestCase {
 	 */
 	private Rendering_Context $rendering_context;

-	/**
-	 * The original block type registry entry for the CouponCode block.
-	 *
-	 * @var \WP_Block_Type|null
-	 */
-	private $original_block_type;
-
 	/**
 	 * Setup test fixtures.
 	 */
 	public function setUp(): void {
 		parent::setUp();

-		$registry = \WP_Block_Type_Registry::get_instance();
-
-		$this->original_block_type = null;
-		if ( $registry->is_registered( 'woocommerce/coupon-code' ) ) {
-			$this->original_block_type = $registry->get_registered( 'woocommerce/coupon-code' );
-			$registry->unregister( 'woocommerce/coupon-code' );
-		}
-
-		$this->mock = new CouponCodeMock();
+		$this->mock = $this->replace_block_type( 'woocommerce/coupon-code', static fn () => new CouponCodeMock() );

 		$theme_controller        = Email_Editor_Container::container()->get( Theme_Controller::class );
 		$this->rendering_context = new Rendering_Context( $theme_controller->get_theme(), array() );
 	}

-	/**
-	 * Tear down test fixtures.
-	 */
-	public function tearDown(): void {
-		$registry = \WP_Block_Type_Registry::get_instance();
-
-		if ( $registry->is_registered( 'woocommerce/coupon-code' ) ) {
-			$registry->unregister( 'woocommerce/coupon-code' );
-		}
-
-		if ( $this->original_block_type ) {
-			$registry->register( $this->original_block_type );
-		}
-
-		parent::tearDown();
-	}
-
 	/**
 	 * Test that render returns empty string when no coupon code is provided.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/MiniCart.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/MiniCart.php
index 2e8037c06da..22c35d1e4d7 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/MiniCart.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/MiniCart.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;
 use Automattic\WooCommerce\Blocks\BlockTypes\MiniCart as MiniCartBlock;
 use Automattic\WooCommerce\Blocks\Package;
 use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
+use Automattic\WooCommerce\Tests\Blocks\Helpers\BlockTypeRegistryTrait;
 use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Tests\Blocks\Mocks\MiniCartMock;
@@ -15,6 +16,7 @@ use Automattic\WooCommerce\Tests\Blocks\Mocks\MiniCartMock;
  * @since $VID:$
  */
 class MiniCart extends \WP_UnitTestCase {
+	use BlockTypeRegistryTrait;

 	/**
 	 * Mock instance of the MiniCart block.
@@ -23,13 +25,6 @@ class MiniCart extends \WP_UnitTestCase {
 	 */
 	protected $mock;

-	/**
-	 * The original block type registry entry for the MiniCart block.
-	 *
-	 * @var \WP_Block_Type
-	 */
-	protected $original_block_type;
-
 	/**
 	 * The upcoming template for the Mini-Cart block.
 	 *
@@ -121,15 +116,7 @@ class MiniCart extends \WP_UnitTestCase {
 	public function setUp(): void {
 		parent::setUp();

-		$registry = \WP_Block_Type_Registry::get_instance();
-
-		$this->original_block_type = null;
-		if ( $registry->is_registered( 'woocommerce/mini-cart' ) ) {
-			$this->original_block_type = $registry->get_registered( 'woocommerce/mini-cart' );
-			$registry->unregister( 'woocommerce/mini-cart' );
-		}
-
-		$this->mock = new MiniCartMock();
+		$this->mock = $this->replace_block_type( 'woocommerce/mini-cart', static fn () => new MiniCartMock() );

 		$fixtures       = new FixtureData();
 		$this->products = array(
@@ -154,12 +141,6 @@ class MiniCart extends \WP_UnitTestCase {
 		parent::tearDown();
 		WC()->cart->empty_cart();
 		remove_filter( 'woocommerce_is_rest_api_request', '__return_false', 1 );
-
-		$registry = \WP_Block_Type_Registry::get_instance();
-		$registry->unregister( 'woocommerce/mini-cart' );
-		if ( $this->original_block_type ) {
-			$registry->register( $this->original_block_type );
-		}
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/MiniCartFooterBlock.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/MiniCartFooterBlock.php
index 8fb7cf44b8f..7e9f94ef280 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/MiniCartFooterBlock.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/MiniCartFooterBlock.php
@@ -7,6 +7,7 @@ use Automattic\WooCommerce\Blocks\Package;
 use Automattic\WooCommerce\Blocks\Assets\Api;
 use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
 use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry;
+use Automattic\WooCommerce\Tests\Blocks\Helpers\BlockTypeRegistryTrait;

 /**
  * Tests for the MiniCartFooterBlock block type.
@@ -14,6 +15,7 @@ use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry;
  * @since 10.4.0
  */
 class MiniCartFooterBlock extends \WP_UnitTestCase {
+	use BlockTypeRegistryTrait;

 	/**
 	 * Instance of the block being tested.
@@ -22,30 +24,19 @@ class MiniCartFooterBlock extends \WP_UnitTestCase {
 	 */
 	protected $block;

-	/**
-	 * The original block type registry entry for the block.
-	 *
-	 * @var \WP_Block_Type|null
-	 */
-	private $original_block_type;
-
 	/**
 	 * Setup test.
 	 */
 	public function setUp(): void {
 		parent::setUp();

-		$registry = \WP_Block_Type_Registry::get_instance();
-
-		$this->original_block_type = $registry->get_registered( 'woocommerce/mini-cart-footer-block' );
-		if ( $this->original_block_type ) {
-			$registry->unregister( 'woocommerce/mini-cart-footer-block' );
-		}
-
-		$this->block = new MiniCartFooterBlockType(
-			Package::container()->get( Api::class ),
-			Package::container()->get( AssetDataRegistry::class ),
-			new IntegrationRegistry()
+		$this->block = $this->replace_block_type(
+			'woocommerce/mini-cart-footer-block',
+			static fn () => new MiniCartFooterBlockType(
+				Package::container()->get( Api::class ),
+				Package::container()->get( AssetDataRegistry::class ),
+				new IntegrationRegistry()
+			)
 		);
 	}

@@ -53,13 +44,6 @@ class MiniCartFooterBlock extends \WP_UnitTestCase {
 	 * Tear down test.
 	 */
 	public function tearDown(): void {
-		$registry = \WP_Block_Type_Registry::get_instance();
-		if ( $registry->is_registered( 'woocommerce/mini-cart-footer-block' ) ) {
-			$registry->unregister( 'woocommerce/mini-cart-footer-block' );
-		}
-		if ( $this->original_block_type ) {
-			$registry->register( $this->original_block_type );
-		}
 		unset( $this->block );
 		parent::tearDown();
 	}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php
index e1637b4e3e5..f753e6e9190 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php
@@ -5,6 +5,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;

 use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Tests\Blocks\Helpers\BlockTypeRegistryTrait;
 use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
 use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsMocks;
 use WC_Unit_Test_Case;
@@ -13,6 +14,7 @@ use WC_Unit_Test_Case;
  * Tests for the VariationSelectorAttribute block type.
  */
 class VariationSelectorAttribute extends WC_Unit_Test_Case {
+	use BlockTypeRegistryTrait;

 	/**
 	 * Register blocks required for do_blocks tests.
@@ -20,9 +22,11 @@ class VariationSelectorAttribute extends WC_Unit_Test_Case {
 	public function setUp(): void {
 		parent::setUp();

-		AddToCartWithOptionsMocks::register(
-			'add-to-cart-with-options-variation-selector-attribute',
-			'add-to-cart-with-options-variation-selector-attribute-name'
+		$this->replace_block_types(
+			AddToCartWithOptionsMocks::get_mock_classes(
+				'add-to-cart-with-options-variation-selector-attribute',
+				'add-to-cart-with-options-variation-selector-attribute-name'
+			)
 		);
 	}

diff --git a/plugins/woocommerce/tests/php/src/Blocks/Helpers/BlockTypeRegistryTrait.php b/plugins/woocommerce/tests/php/src/Blocks/Helpers/BlockTypeRegistryTrait.php
new file mode 100644
index 00000000000..5274e1f384d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Helpers/BlockTypeRegistryTrait.php
@@ -0,0 +1,80 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Helpers;
+
+/**
+ * Registers block types for a test and puts the block type registry back as the test found it.
+ *
+ * Constructing a block class registers its block type, and WP_Block_Type_Registry outlives a
+ * test, so a type registered without cleanup changes what every later test sees.
+ * PHPUnit restores the replaced types automatically after each test.
+ */
+trait BlockTypeRegistryTrait {
+
+	/**
+	 * What each replaced name had registered before the test, or null when it had nothing.
+	 *
+	 * @var array<string, \WP_Block_Type|null>
+	 */
+	private $block_types_before_test = array();
+
+	/**
+	 * Register a block type for this test in place of whatever is registered under its name.
+	 *
+	 * @param string   $block_name Full block name, such as 'woocommerce/mini-cart'.
+	 * @param callable $register   Registers the block type, usually by constructing a block class.
+	 * @return mixed What $register returned.
+	 * @throws \LogicException When $register does not register $block_name.
+	 */
+	protected function replace_block_type( string $block_name, callable $register ) {
+		$registry = \WP_Block_Type_Registry::get_instance();
+
+		if ( ! array_key_exists( $block_name, $this->block_types_before_test ) ) {
+			$this->block_types_before_test[ $block_name ] = $registry->get_registered( $block_name );
+		}
+
+		if ( $registry->is_registered( $block_name ) ) {
+			$registry->unregister( $block_name );
+		}
+
+		$result = $register();
+
+		if ( ! $registry->is_registered( $block_name ) ) {
+			throw new \LogicException( esc_html( "The callback did not register {$block_name}." ) );
+		}
+
+		return $result;
+	}
+
+	/**
+	 * Register block classes for this test, each constructed with no arguments.
+	 *
+	 * @param array<string, string> $block_classes Block class names keyed by full block name.
+	 */
+	protected function replace_block_types( array $block_classes ): void {
+		foreach ( $block_classes as $block_name => $block_class ) {
+			$this->replace_block_type( $block_name, static fn () => new $block_class() );
+		}
+	}
+
+	/**
+	 * Put back the block types replaced during the test, and remove the ones that were new.
+	 *
+	 * @after
+	 */
+	protected function restore_block_types(): void {
+		$registry = \WP_Block_Type_Registry::get_instance();
+
+		foreach ( $this->block_types_before_test as $block_name => $block_type ) {
+			if ( $registry->is_registered( $block_name ) ) {
+				$registry->unregister( $block_name );
+			}
+			if ( $block_type ) {
+				$registry->register( $block_type );
+			}
+		}
+
+		$this->block_types_before_test = array();
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Helpers/BlockTypeRegistryTraitTest.php b/plugins/woocommerce/tests/php/src/Blocks/Helpers/BlockTypeRegistryTraitTest.php
new file mode 100644
index 00000000000..4d4a738e5bc
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Helpers/BlockTypeRegistryTraitTest.php
@@ -0,0 +1,131 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Helpers;
+
+use Automattic\WooCommerce\Tests\Blocks\Mocks\CouponCodeMock;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for BlockTypeRegistryTrait.
+ */
+class BlockTypeRegistryTraitTest extends WC_Unit_Test_Case {
+	use BlockTypeRegistryTrait;
+
+	/**
+	 * Names that outlive restore_block_types(): the original a test puts back, and the name
+	 * a callback registers without the trait tracking it.
+	 */
+	private const TEST_BLOCK_NAMES = array( 'woocommerce-test/swapped', 'woocommerce-test/other' );
+
+	/**
+	 * Remove anything a test left registered.
+	 */
+	public function tearDown(): void {
+		try {
+			// Restore before removing the synthetic originals, since @after runs after tearDown().
+			$this->restore_block_types();
+			$registry = \WP_Block_Type_Registry::get_instance();
+			foreach ( self::TEST_BLOCK_NAMES as $block_name ) {
+				if ( $registry->is_registered( $block_name ) ) {
+					$registry->unregister( $block_name );
+				}
+			}
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * @testdox Replacing a registered block type puts the original back on restore.
+	 */
+	public function test_replacing_a_registered_block_type_puts_the_original_back(): void {
+		$original = register_block_type( 'woocommerce-test/swapped', array( 'title' => 'Original' ) );
+
+		$replacement = $this->replace_block_type( 'woocommerce-test/swapped', static fn () => register_block_type( 'woocommerce-test/swapped', array( 'title' => 'Replacement' ) ) );
+
+		$registry = \WP_Block_Type_Registry::get_instance();
+		$this->assertSame( $replacement, $registry->get_registered( 'woocommerce-test/swapped' ), 'The replacement should be registered during the test.' );
+
+		$this->restore_block_types();
+
+		$this->assertSame( $original, $registry->get_registered( 'woocommerce-test/swapped' ), 'The original block type should be registered again.' );
+	}
+
+	/**
+	 * @testdox A block type that was not registered before the test is removed on restore.
+	 */
+	public function test_a_block_type_that_was_not_registered_is_removed(): void {
+		$this->replace_block_type( 'woocommerce-test/swapped', static fn () => register_block_type( 'woocommerce-test/swapped' ) );
+
+		$this->restore_block_types();
+
+		$this->assertFalse( \WP_Block_Type_Registry::get_instance()->is_registered( 'woocommerce-test/swapped' ), 'A block type the test added should not stay registered.' );
+	}
+
+	/**
+	 * @testdox Replacing the same name twice in a test still restores the block type from before the test.
+	 */
+	public function test_replacing_twice_restores_the_block_type_from_before_the_test(): void {
+		$original = register_block_type( 'woocommerce-test/swapped', array( 'title' => 'Original' ) );
+
+		foreach ( array( 'First', 'Second' ) as $title ) {
+			$this->replace_block_type( 'woocommerce-test/swapped', static fn () => register_block_type( 'woocommerce-test/swapped', array( 'title' => $title ) ) );
+		}
+
+		$this->restore_block_types();
+
+		$this->assertSame( $original, \WP_Block_Type_Registry::get_instance()->get_registered( 'woocommerce-test/swapped' ), 'The block type from before the first replacement should come back.' );
+	}
+
+	/**
+	 * @testdox replace_block_types() replaces an existing registration with the requested mock class.
+	 */
+	public function test_replace_block_types_constructs_each_class(): void {
+		$registry = \WP_Block_Type_Registry::get_instance();
+		$original = $registry->get_registered( 'woocommerce/coupon-code' );
+		$this->assertInstanceOf( \WP_Block_Type::class, $original, 'The coupon block should be registered before replacement.' );
+
+		$this->replace_block_types( array( 'woocommerce/coupon-code' => CouponCodeMock::class ) );
+
+		$replacement = $registry->get_registered( 'woocommerce/coupon-code' );
+		$this->assertNotSame( $original, $replacement, 'The mock should replace the original block type.' );
+		$this->assertInstanceOf( CouponCodeMock::class, $replacement->render_callback[0], 'The registered block should render through the requested mock.' );
+	}
+
+	/**
+	 * @testdox A callback that registers a different name than the one replaced is rejected.
+	 */
+	public function test_a_callback_that_registers_another_name_is_rejected(): void {
+		$original = register_block_type( 'woocommerce-test/swapped', array( 'title' => 'Original' ) );
+		$this->expectException( \LogicException::class );
+
+		try {
+			$this->replace_block_type( 'woocommerce-test/swapped', static fn () => register_block_type( 'woocommerce-test/other' ) );
+		} finally {
+			$this->restore_block_types();
+			$this->assertSame( $original, \WP_Block_Type_Registry::get_instance()->get_registered( 'woocommerce-test/swapped' ), 'The original block type should be restored after a rejected callback.' );
+		}
+	}
+
+	/**
+	 * @testdox PHPUnit restores the original block type without an explicit teardown call.
+	 */
+	public function test_phpunit_restores_block_types_automatically(): void {
+		$original = register_block_type( 'woocommerce-test/swapped', array( 'title' => 'Original' ) );
+		$test     = new class( 'test_replace_block_type' ) extends \PHPUnit\Framework\TestCase {
+			use BlockTypeRegistryTrait;
+
+			/**
+			 * @testdox A block type can be replaced during a test.
+			 */
+			public function test_replace_block_type(): void {
+				$this->replace_block_type( 'woocommerce-test/swapped', static fn () => register_block_type( 'woocommerce-test/swapped' ) );
+			}
+		};
+
+		$test->runBare();
+
+		$this->assertSame( $original, \WP_Block_Type_Registry::get_instance()->get_registered( 'woocommerce-test/swapped' ), 'PHPUnit should restore the original block type after the test.' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Mocks/AddToCartWithOptionsMocks.php b/plugins/woocommerce/tests/php/src/Blocks/Mocks/AddToCartWithOptionsMocks.php
index 84db3c741ff..5f9f3dee839 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Mocks/AddToCartWithOptionsMocks.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Mocks/AddToCartWithOptionsMocks.php
@@ -4,7 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Blocks\Mocks;

 /**
- * Registers the Add to Cart with Options mock blocks, which `init` skips under a classic theme.
+ * Maps the Add to Cart with Options block names to their mock classes.
  */
 class AddToCartWithOptionsMocks {

@@ -23,24 +23,16 @@ class AddToCartWithOptionsMocks {
 	);

 	/**
-	 * Register the given mock blocks, skipping any already registered, since the registry outlives a test.
+	 * Get mock classes keyed by full block name, for per-test registry replacement.
 	 *
-	 * @param string ...$block_names Block names without the `woocommerce/` namespace.
+	 * @param string ...$block_names Block names without the namespace; omit to get all mocks.
+	 * @return array<string, string>
 	 */
-	public static function register( string ...$block_names ): void {
-		$registry = \WP_Block_Type_Registry::get_instance();
-		foreach ( $block_names as $block_name ) {
-			if ( ! $registry->is_registered( 'woocommerce/' . $block_name ) ) {
-				$mock_class = self::MOCKS[ $block_name ];
-				new $mock_class();
-			}
+	public static function get_mock_classes( string ...$block_names ): array {
+		$mock_classes = array();
+		foreach ( $block_names ? $block_names : array_keys( self::MOCKS ) as $block_name ) {
+			$mock_classes[ 'woocommerce/' . $block_name ] = self::MOCKS[ $block_name ];
 		}
-	}
-
-	/**
-	 * Register every mock block that is not registered yet.
-	 */
-	public static function register_all(): void {
-		self::register( ...array_keys( self::MOCKS ) );
+		return $mock_classes;
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/ProductPageIntegrationTest.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/ProductPageIntegrationTest.php
index e13b8174002..10a448588f0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/ProductPageIntegrationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/ProductPageIntegrationTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Frontend;

 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\ProductPageIntegration;
+use Automattic\WooCommerce\Tests\Blocks\Helpers\BlockTypeRegistryTrait;
 use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
 use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsMocks;
 use Automattic\WooCommerce\Tests\Internal\StockNotifications\StockNotificationsFeatureTrait;
@@ -15,6 +16,7 @@ use WC_Unit_Test_Case;
  */
 class ProductPageIntegrationTest extends WC_Unit_Test_Case {

+	use BlockTypeRegistryTrait;
 	use StockNotificationsFeatureTrait;

 	/**
@@ -40,12 +42,15 @@ class ProductPageIntegrationTest extends WC_Unit_Test_Case {
 		// go_to() sets REQUEST_URI and no base test case puts it back.
 		$this->request_uri_at_setup = $_SERVER['REQUEST_URI'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput

-		AddToCartWithOptionsMocks::register(
-			'add-to-cart-with-options',
-			'add-to-cart-with-options-quantity-selector',
-			'add-to-cart-with-options-variation-selector',
-			'add-to-cart-with-options-variation-selector-attribute',
-			'add-to-cart-with-options-variation-selector-attribute-name'
+		// The blocks are not registered on `init`, because `init` runs with a classic theme.
+		$this->replace_block_types(
+			AddToCartWithOptionsMocks::get_mock_classes(
+				'add-to-cart-with-options',
+				'add-to-cart-with-options-quantity-selector',
+				'add-to-cart-with-options-variation-selector',
+				'add-to-cart-with-options-variation-selector-attribute',
+				'add-to-cart-with-options-variation-selector-attribute-name'
+			)
 		);

 		update_option( 'woocommerce_customer_stock_notifications_allow_signups', 'yes' );