Commit 0cb60089c6c for woocommerce
commit 0cb60089c6c94826defd6695eaf5cd19c9deb8f1
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Tue Sep 15 14:10:22 2026 +0300
[tests] Move 4 Single Product page E2E tests to PHPUnit (#68603)
* test(blocks): Move 4 Single Product page E2E tests to PHPUnit
The Single Product page specs ran five browser titles. Two checked
the product classes on a product page's body, and two checked that
the Product Rating block hides when reviews are off for the product
or for the store. The server decides both.
Add PHPUnit tests: the Single Product template adds the product's
classes to the body, and the Product Rating block renders when
reviews are on and hides when they are off for the product or the
store. Delete the Product Rating spec, and keep the password form
title in the template spec.
Consolidates the mega-branch slices:
- Slice 016: test(blocks): Move Product Rating guards below E2E
- Slice 097: test(blocks): Move product body classes below E2E
Refs TESTOPS-234
Refs #68046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Stop the single product tests restoring base-class state
The body-class test cloned the body_class hook stack and put it back,
deleted its product and every variation, and restored $GLOBALS['post'].
_restore_hooks() rebuilds $wp_filter from the suite baseline, the
rollback covers the fixtures, and tear_down() nulls the post global.
The remove_all_filters() call at the top of the test stays: it has to
hold while the template installs its own callback.
The rating test dropped its option restore, its review and product
deletes with the four cache and transient clears that went with them,
and the post global.
What both keep is what the base class does not own: $GLOBALS['product'],
$GLOBALS['woocommerce_loop'], the four ProductsStore statics and the
Interactivity API store. The last two are also asserted on after the
finally, so they were never only cleanup.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Drop a leak assertion that compared a value to itself
The two "should not leak" assertions in ProductRatingTest ran after the
finally block had already restored ProductsStore and the Interactivity
store from the same snapshot variables they then asserted against, so
each compared a value to a reconstruction of itself and could not fail.
Moving the second snapshot to just after do_blocks(), where it would
have meant something, fails on all three data rows: rendering really
does cache the product in ProductsStore and flip getters_registered.
The assertions were not just always-green, they asserted something
untrue, and only passed because of where they sat. The restores are the
part carrying the value, so keep those and say so in the comment.
While here, pin the filter registration in SingleProductTemplateTests.
The body-class test called update_single_product_content() directly, so
deleting the add_filter() in init() left the whole file green. Calling
init() and asserting the callback is registered at priority 11 closes
that without routing the assertions through the real get_block_templates
chain, where other templates' callbacks would also fire.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/testops-234-single-product-server-render b/plugins/woocommerce/changelog/testops-234-single-product-server-render
new file mode 100644
index 00000000000..860335436f5
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-single-product-server-render
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move 4 Single Product page E2E tests to PHPUnit; SingleProductTemplateTests owns the body classes and ProductRatingTest owns the review visibility.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/single-product-template/single-product-template.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/single-product-template/single-product-template.block_theme.spec.ts
index 2e1317e04d5..fca3ba7a93a 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/single-product-template/single-product-template.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/single-product-template/single-product-template.block_theme.spec.ts
@@ -3,59 +3,6 @@
*/
import { test, expect } from '@woocommerce/e2e-utils';
-const products = [
- {
- product: 'Album',
- // Copy-pasted by WooCommerce Core Legacy Template.
- classes: [
- 'product',
- 'type-product',
- 'status-publish',
- 'instock',
- 'product_cat-music',
- 'has-post-thumbnail',
- 'downloadable',
- 'virtual',
- 'purchasable',
- 'product-type-simple',
- ],
- frontendPage: '/product/album/',
- },
- {
- product: 'Hoodie',
- // Copy-pasted by WooCommerce Core Legacy Template.
- classes: [
- 'product',
- 'type-product',
- 'status-publish',
- 'instock',
- 'product_cat-hoodies',
- 'has-post-thumbnail',
- 'sale',
- 'shipping-taxable',
- 'purchasable',
- 'product-type-variable',
- ],
- frontendPage: '/product/hoodie/',
- },
-];
-
-for ( const { classes, product, frontendPage } of products ) {
- test.describe( `The Single Product page of the ${ product }`, () => {
- test( 'add product specific classes to the body', async ( {
- page,
- } ) => {
- await page.goto( frontendPage );
- const body = page.locator( 'body' );
- const bodyClasses = await body.getAttribute( 'class' );
-
- classes.forEach( ( className ) => {
- expect( bodyClasses?.split( ' ' ) ).toContain( className );
- } );
- } );
- } );
-}
-
test( 'shows password form in products protected with password', async ( {
page,
} ) => {
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/single-product/product-rating-single-product.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/single-product/product-rating-single-product.spec.ts
deleted file mode 100644
index 2351a8c18eb..00000000000
--- a/plugins/woocommerce/tests/e2e/tests/blocks/single-product/product-rating-single-product.spec.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * External dependencies
- */
-import {
- Editor,
- test as base,
- expect,
- getPostIdBySlug,
- wpCLI,
-} from '@woocommerce/e2e-utils';
-
-const blockData = {
- slug: 'woocommerce/single-product',
- productSlug: 'hoodie',
-};
-
-class BlockUtils {
- editor: Editor;
- admin;
-
- constructor( { editor, admin }: { editor: Editor; admin } ) {
- this.editor = editor;
- this.admin = admin;
- }
-
- async configureSingleProductBlockForProduct( product: string ) {
- const singleProductBlock = await this.editor.getBlockByName(
- 'woocommerce/single-product'
- );
-
- await singleProductBlock
- .locator( `input[type="radio"][value="${ product }"]` )
- .nth( 0 )
- .click();
-
- await singleProductBlock.getByText( 'Done' ).click();
- }
-
- async insertBlockAndVisit( block: string, product: string ) {
- await this.admin.createNewPost();
- await this.editor.insertBlock( { name: block } );
- await this.configureSingleProductBlockForProduct( product );
- await this.editor.publishAndVisitPost();
- }
-}
-
-const test = base.extend< { blockUtils: BlockUtils } >( {
- blockUtils: async ( { editor, admin }, use ) => {
- await use( new BlockUtils( { editor, admin } ) );
- },
-} );
-
-test.describe( `${ blockData.slug } Block`, () => {
- test( 'Product Rating block is not visible if ratings are disabled for product', async ( {
- page,
- blockUtils,
- } ) => {
- await test.step( `Disable reviews for ${ blockData.productSlug }`, async () => {
- const productId = await getPostIdBySlug( blockData.productSlug );
- await wpCLI( `post update ${ productId } --comment_status=closed` );
- } );
-
- await test.step( `Insert the block in a post and visit it`, async () => {
- await blockUtils.insertBlockAndVisit(
- blockData.slug,
- blockData.productSlug
- );
- } );
-
- await expect(
- page.locator( '.wc-block-components-product-rating' )
- ).toBeHidden();
- } );
-
- test( 'Product Rating block is not visible if ratings are disabled globally in the store', async ( {
- admin,
- page,
- blockUtils,
- } ) => {
- await test.step( `Disable reviews in the store`, async () => {
- await page.goto(
- '/wp-admin/admin.php?page=wc-settings&tab=products'
- );
- await admin.page
- .getByRole( 'checkbox', {
- name: 'Enable product reviews',
- } )
- .uncheck();
- await page.getByRole( 'button', { name: 'Save changes' } ).click();
- } );
-
- await test.step( 'Insert the block in a post and visit it', async () => {
- await blockUtils.insertBlockAndVisit(
- blockData.slug,
- blockData.productSlug
- );
- } );
-
- await expect(
- page.locator( '.wc-block-components-product-rating' )
- ).toBeHidden();
- } );
-} );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductRatingTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductRatingTest.php
new file mode 100644
index 00000000000..fc8cef6b6b1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductRatingTest.php
@@ -0,0 +1,181 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Blocks\SharedStores\ProductsStore as TestedProductsStore;
+use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ProductRating block type.
+ */
+class ProductRatingTest extends WC_Unit_Test_Case {
+
+ /**
+ * @testdox Product Rating visibility follows the product and global review settings.
+ * @dataProvider review_visibility_settings
+ *
+ * @param bool $product_reviews_allowed Whether reviews are enabled for the product.
+ * @param string $global_reviews_setting The global product review option value.
+ * @param bool $expected_visible Whether the Product Rating markup should render.
+ */
+ public function test_render_visibility_follows_review_settings( bool $product_reviews_allowed, string $global_reviews_setting, bool $expected_visible ): void {
+ $had_global_product = array_key_exists( 'product', $GLOBALS );
+ $previous_global_product = $GLOBALS['product'] ?? null;
+ $products_store_state = $this->snapshot_products_store_static_state();
+ $interactivity_state = $this->snapshot_interactivity_state();
+
+ try {
+ update_option( 'woocommerce_enable_reviews', $global_reviews_setting );
+
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Rated product',
+ 'regular_price' => '10',
+ 'status' => 'publish',
+ 'reviews_allowed' => true,
+ )
+ );
+ $fixtures->add_product_review( $product->get_id(), 5 );
+
+ $product->set_reviews_allowed( $product_reviews_allowed );
+ $product->save();
+ \WC_Comments::clear_transients( $product->get_id() );
+ clean_post_cache( $product->get_id() );
+
+ $product = wc_get_product( $product->get_id() );
+ if ( ! $product instanceof \WC_Product ) {
+ throw new \RuntimeException( 'The review fixture should remain a loadable product.' );
+ }
+ $this->assertGreaterThan( 0, $product->get_review_count(), 'The review fixture must have a real approved review before rendering.' );
+
+ $markup = do_blocks(
+ '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} -->' .
+ '<!-- wp:woocommerce/product-rating {"isDescendentOfSingleProductBlock":true} /-->' .
+ '<!-- /wp:woocommerce/single-product -->'
+ );
+
+ if ( $expected_visible ) {
+ $this->assertStringContainsString( 'wc-block-components-product-rating', $markup, 'A rated product with both review settings enabled should render Product Rating markup.' );
+ } else {
+ $this->assertStringNotContainsString( 'wc-block-components-product-rating', $markup, 'Product Rating markup should not render when either review setting is disabled.' );
+ }
+ } finally {
+ // The option row, the fixtures and $GLOBALS['post'] go back with tear_down().
+ // The product global, the ProductsStore statics and the Interactivity store
+ // do not. Rendering really does populate all three -- ProductsStore caches
+ // the product and flips getters_registered -- so these restores are what
+ // keeps the render out of the next test, not a precaution.
+ try {
+ if ( $had_global_product ) {
+ $GLOBALS['product'] = $previous_global_product;
+ } else {
+ unset( $GLOBALS['product'] );
+ }
+ } finally {
+ try {
+ $this->restore_products_store_static_state( $products_store_state );
+ } finally {
+ $this->restore_interactivity_state( $interactivity_state );
+ }
+ }
+ }
+ }
+
+ /**
+ * Snapshot the four private static properties mutated by ProductsStore.
+ *
+ * @return array<string, mixed>
+ */
+ private function snapshot_products_store_static_state(): array {
+ $reflection = new \ReflectionClass( TestedProductsStore::class );
+ $state = array();
+
+ foreach ( array( 'products', 'product_variations', 'loaded_variation_parents', 'getters_registered' ) as $name ) {
+ $property = $reflection->getProperty( $name );
+ $property->setAccessible( true );
+ $state[ $name ] = $property->getValue();
+ }
+
+ return $state;
+ }
+
+ /**
+ * Restore the four private static properties mutated by ProductsStore.
+ *
+ * @param array<string, mixed> $state ProductsStore state captured before rendering.
+ */
+ private function restore_products_store_static_state( array $state ): void {
+ $reflection = new \ReflectionClass( TestedProductsStore::class );
+
+ foreach ( $state as $name => $value ) {
+ $property = $reflection->getProperty( $name );
+ $property->setAccessible( true );
+ $property->setValue( null, $value );
+ }
+ }
+
+ /**
+ * Snapshot the global Interactivity API properties mutated by ProductsStore.
+ *
+ * @return array{api: object, properties: array<string, mixed>}|null
+ */
+ private function snapshot_interactivity_state(): ?array {
+ if ( ! function_exists( 'wp_interactivity' ) ) {
+ return null;
+ }
+
+ $api = wp_interactivity();
+ $reflection = new \ReflectionClass( $api );
+ $properties = array();
+ foreach ( array( 'state_data', 'config_data', 'derived_state_closures' ) as $name ) {
+ if ( ! $reflection->hasProperty( $name ) ) {
+ continue;
+ }
+
+ $property = $reflection->getProperty( $name );
+ $property->setAccessible( true );
+ $properties[ $name ] = $property->getValue( $api );
+ }
+
+ return array(
+ 'api' => $api,
+ 'properties' => $properties,
+ );
+ }
+
+ /**
+ * Restore the global Interactivity API properties mutated by ProductsStore.
+ *
+ * @param array{api: object, properties: array<string, mixed>}|null $state Interactivity state captured before rendering.
+ */
+ private function restore_interactivity_state( ?array $state ): void {
+ if ( null === $state ) {
+ return;
+ }
+
+ $api = $state['api'];
+ $reflection = new \ReflectionClass( $api );
+ foreach ( $state['properties'] as $name => $value ) {
+ $property = $reflection->getProperty( $name );
+ $property->setAccessible( true );
+ $property->setValue( $api, $value );
+ }
+ }
+
+ /**
+ * Review visibility settings.
+ *
+ * @return array<string, array{bool, string, bool}>
+ */
+ public static function review_visibility_settings(): array {
+ return array(
+ 'reviews enabled' => array( true, 'yes', true ),
+ 'product reviews disabled' => array( false, 'yes', false ),
+ 'global reviews disabled' => array( true, 'no', false ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php b/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php
index 54f9c7c844f..c671e9df61e 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php
@@ -184,6 +184,92 @@ class SingleProductTemplateTests extends WP_UnitTestCase {
);
}
+ /**
+ * @testdox Should add the $product_type product's body classes through the filter the template installs.
+ *
+ * @dataProvider product_type_provider
+ *
+ * @param string $product_type Product type to create.
+ * @param string $type_class Expected product type class.
+ */
+ public function test_update_single_product_content_adds_product_body_classes( $product_type, $type_class ) {
+ $product_global_existed = array_key_exists( 'product', $GLOBALS );
+ $product_global = $product_global_existed ? $GLOBALS['product'] : null;
+ $loop_global_existed = array_key_exists( 'woocommerce_loop', $GLOBALS );
+ $loop_global = $loop_global_existed ? $GLOBALS['woocommerce_loop'] : null;
+
+ try {
+ // The template under test installs its own body_class callback, so clear the
+ // stack first to isolate it. _restore_hooks() rebuilds the stack afterwards.
+ remove_all_filters( 'body_class' );
+
+ $product = 'variable' === $product_type
+ ? \WC_Helper_Product::create_variation_product()
+ : \WC_Helper_Product::create_simple_product();
+
+ $GLOBALS['product'] = $product;
+ $GLOBALS['post'] = get_post( $product->get_id() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The template reads the current post; tear_down() nulls it again.
+
+ $template = new \WP_Block_Template();
+ $template->slug = 'single-product';
+ $template->title = 'Single Product';
+ $template->content = '<!-- wp:woocommerce/product-price /-->';
+ $template->type = 'wp_template';
+
+ $seed_classes = array( 'existing-body-class' );
+ wc_reset_loop();
+ $product_classes = wc_get_product_class( '', $product );
+ wc_reset_loop();
+
+ $single_product_template = new SingleProductTemplate();
+
+ // Calling the method directly is what makes the body_class assertions below
+ // deterministic, but on its own it would still pass if init() stopped wiring
+ // the method up at all. Pin the registration separately.
+ $single_product_template->init();
+ $this->assertSame(
+ 11,
+ has_filter( 'get_block_templates', array( $single_product_template, 'update_single_product_content' ) ),
+ 'init() must register the callback that installs the body_class filter.'
+ );
+
+ $single_product_template->update_single_product_content( array( $template ) );
+
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Exercise the public filter installed by the template under test.
+ $filtered_classes = apply_filters( 'body_class', $seed_classes );
+
+ $this->assertSame( array_merge( $seed_classes, $product_classes ), $filtered_classes );
+ $this->assertContains( 'product', $filtered_classes );
+ $this->assertContains( $type_class, $filtered_classes );
+ } finally {
+ // tear_down() nulls $GLOBALS['post'] and rolls back the fixtures; these two
+ // globals it leaves exactly as the test left them.
+ if ( $product_global_existed ) {
+ $GLOBALS['product'] = $product_global;
+ } else {
+ unset( $GLOBALS['product'] );
+ }
+
+ if ( $loop_global_existed ) {
+ $GLOBALS['woocommerce_loop'] = $loop_global;
+ } else {
+ unset( $GLOBALS['woocommerce_loop'] );
+ }
+ }
+ }
+
+ /**
+ * Product types for the Single Product body class contract.
+ *
+ * @return array<string, array<string>>
+ */
+ public function product_type_provider() {
+ return array(
+ 'simple product' => array( 'simple', 'product-type-simple' ),
+ 'variable product' => array( 'variable', 'product-type-variable' ),
+ );
+ }
+
/**
* @testdox Should not add the password form when the template has no Single Product blocks.
*/