Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions tests/Unit/AbstractWPUnitTestWithOptionIsolationAndSafeFiltering.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<?php
/**
* Abstract test case for unit tests that require option isolation.
*/

namespace WooCommerce\Facebook\Tests;

use WooCommerce\Facebook\Tests\AbstractWPUnitTestWithSafeFiltering;

/**
* Abstract test case that provides isolation for WordPress options (get_option/update_option).
*
* This class intercepts calls to `get_option` and `update_option` during tests,
* storing values in a local array instead of the database. The state is reset
* after each test method.
*/
abstract class AbstractWPUnitTestWithOptionIsolationAndSafeFiltering extends AbstractWPUnitTestWithSafeFiltering {

/**
* Stores mocked option values during a test.
* Format: [ 'option_name' => 'option_value' ]
*
* @var array
*/
protected $mocked_options = [];

/**
* Stores the original values of options that were mocked.
* Used to restore state if necessary, though typically cleared in tearDown.
* Format: [ 'option_name' => 'original_value' ]
*
* @var array
*/
protected $original_option_values = [];

/**
* Set up before each test.
*
* Initializes the mocked options array and sets up filters to intercept
* get_option and update_option calls.
*/
public function setUp(): void {
parent::setUp();
$this->mocked_options = [];
$this->original_option_values = [];

// Intercept any attempt to get an option.
$this->add_filter_with_safe_teardown('pre_option', function( $value, $option_name, $default ) {
return $this->filter_pre_option( $value, $option_name, $default );
}, 10, 3);

// Intercept any attempt to update an option.
// We hook into the specific option filter first if available.
$this->add_filter_with_safe_teardown('pre_update_option', function( $value, $option_name, $old_value ) {
return $this->filter_pre_update_option( $value, $option_name, $old_value );
}, 10, 3);

// Add specific filters for each option being updated.
// Note: This requires knowing the option name *before* it's updated.
// The generic 'pre_update_option' filter above handles cases where
// the specific filter isn't set up beforehand by a test.
// We might need a more dynamic way if tests update arbitrary options.
}

/**
* Clean up after each test.
*
* Clears the mocked options array and restores original values
* (though parent::tearDown typically handles filter removal).
*/
public function tearDown(): void {
// Clear mocked options to ensure isolation between tests.
$this->mocked_options = [];
$this->original_option_values = [];

// Parent tearDown will remove the filters added in setUp.
parent::tearDown();
}

/**
* Filter callback for 'pre_option'.
*
* Checks if the requested option has been mocked. If so, returns the
* mocked value. Otherwise, returns false to let WordPress continue.
*
* @param mixed $value The value to return instead of the option value. Default false.
* @param string $option_name Name of the option.
* @param mixed $default Default value to return if the option does not exist.
* @return mixed Mocked value if set, otherwise false.
*/
protected function filter_pre_option( $value, $option_name, $default ) {
if ( array_key_exists( $option_name, $this->mocked_options ) ) {
// Return the mocked value. Use null for 'not found' to distinguish from false.
return $this->mocked_options[ $option_name ] ?? null;
}

// If not mocked, let WordPress handle it (might return $default).
// Returning false allows the original get_option logic to proceed.
return false;
}

/**
* Filter callback for 'pre_update_option'.
*
* Intercepts the update, stores the new value in the local mock array,
* and prevents the database update by returning the old value.
*
* @param mixed $value The new value of the option.
* @param string $option_name Name of the option.
* @param mixed $old_value The old option value.
* @return mixed The $old_value to effectively cancel the database update.
*/
protected function filter_pre_update_option( $value, $option_name, $old_value ) {
// Store the value being set in our mock array
$this->mocked_options[ $option_name ] = $value;

// Store the original value if we haven't already
if ( ! array_key_exists( $option_name, $this->original_option_values ) ) {
// Note: $old_value provided by the filter might not be the true DB value
// if another filter ran before this one. For simplicity here, we use it.
// A more robust solution might fetch the actual value before adding the filter.
$this->original_option_values[ $option_name ] = $old_value;
}

// Return the $old_value to prevent the actual database update.
// Returning null or false might also work depending on WP version,
// but returning $old_value is documented behavior for short-circuiting.
return $old_value;
}

/**
* Directly set a mocked option value for testing purposes.
*
* This is useful for setting up the initial state before an action.
*
* @param string $option_name The name of the option to mock.
* @param mixed $value The value to set for the mocked option.
*/
protected function mock_set_option( string $option_name, $value ): void {
if ( ! array_key_exists( $option_name, $this->original_option_values ) ) {
$this->original_option_values[ $option_name ] = get_option( $option_name, null ); // Store original before overriding
}
$this->mocked_options[ $option_name ] = $value;
}

/**
* Get a mocked option value.
*
* @param string $option_name The name of the option.
* @param mixed $default The default value if the option isn't mocked.
* @return mixed The mocked value or the default.
*/
protected function mock_get_option( string $option_name, $default = false ) {
return array_key_exists( $option_name, $this->mocked_options ) ? $this->mocked_options[ $option_name ] : $default;
}

/**
* Get all mocked options.
*
* @return array
*/
protected function mock_get_all_options(): array {
return $this->mocked_options;
}

/**
* Assert that an option was "updated" (mocked) with a specific value during the test.
*
* @param string $option_name The name of the option.
* @param mixed $expected_value The expected value.
* @param string $message Optional assertion message.
*/
protected function assertOptionUpdated( string $option_name, $expected_value, string $message = '' ): void {
$this->assertTrue(
array_key_exists( $option_name, $this->mocked_options ),
$message ?: "Failed asserting that option '{$option_name}' was updated (mocked)."
);
$this->assertSame(
$expected_value,
$this->mocked_options[ $option_name ],
$message ?: "Failed asserting that option '{$option_name}' was updated (mocked) with the expected value."
);
}

/**
* Assert that an option was *not* "updated" (mocked) during the test.
*
* @param string $option_name The name of the option.
* @param string $message Optional assertion message.
*/
protected function assertOptionNotUpdated( string $option_name, string $message = '' ): void {
$this->assertFalse(
array_key_exists( $option_name, $this->mocked_options ),
$message ?: "Failed asserting that option '{$option_name}' was not updated (mocked)."
);
}
}
3 changes: 2 additions & 1 deletion tests/Unit/Admin/Settings_Screens/ConnectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
use PHPUnit\Framework\TestCase;
use WC_Facebookcommerce;
use WooCommerce\Facebook\Admin\Settings_Screens\Connection;
use WooCommerce\Facebook\Tests\AbstractWPUnitTestWithOptionIsolationAndSafeFiltering;

/**
* Class ConnectionTest
*
* @package WooCommerce\Facebook\Tests\Unit\Admin\Settings_Screens
*/
class ConnectionTest extends TestCase {
class ConnectionTest extends AbstractWPUnitTestWithOptionIsolationAndSafeFiltering {

/**
* Helper method to invoke private/protected methods
Expand Down
5 changes: 3 additions & 2 deletions tests/Unit/Admin/Settings_Screens/ShopsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,22 @@

use PHPUnit\Framework\TestCase;
use WooCommerce\Facebook\Admin\Settings_Screens\Shops;
use WooCommerce\Facebook\Tests\AbstractWPUnitTestWithOptionIsolationAndSafeFiltering;

/**
* Class ShopsTest
*
* @package WooCommerce\Facebook\Tests\Unit\Admin\Settings_Screens
*/
class ShopsTest extends TestCase {
class ShopsTest extends AbstractWPUnitTestWithOptionIsolationAndSafeFiltering {

/** @var Shops */
private $shops;

/**
* Set up the test environment
*/
protected function setUp(): void {
public function setUp(): void {
parent::setUp();
$this->shops = new Shops();
}
Expand Down
18 changes: 17 additions & 1 deletion tests/Unit/Api/REST/RestAPITest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
use WooCommerce\Facebook\API\Plugin\Settings\Handler;
use WooCommerce\Facebook\API\Plugin\Settings\Update\Request as UpdateRequest;
use PHPUnit\Framework\TestCase;
use WooCommerce\Facebook\Tests\AbstractWPUnitTestWithOptionIsolationAndSafeFiltering;

/**
* The REST API unit test class.
*/
class RestAPITest extends TestCase {
class RestAPITest extends AbstractWPUnitTestWithOptionIsolationAndSafeFiltering {

/**
* Test REST API routes are registered
Expand Down Expand Up @@ -223,4 +224,19 @@ public function test_js_exposable_classes_match_api_definitions() {
"JS_Exposable class '$class' with function name '$function_name' has no corresponding API definition");
}
}

/**
* Test that options set in a previous test are reset due to isolation.
*/
public function test_options_are_reset_after_previous_test() {
// These options were set in test_settings_update_succeeds_with_valid_data
// Due to setUp/tearDown isolation, they should now return their default value (false)
$this->assertFalse( get_option('wc_facebook_access_token'), 'Option wc_facebook_access_token should be reset.' );
$this->assertFalse( get_option('wc_facebook_merchant_access_token'), 'Option wc_facebook_merchant_access_token should be reset.' );
$this->assertFalse( get_option('wc_facebook_product_catalog_id'), 'Option wc_facebook_product_catalog_id should be reset.' );
$this->assertFalse( get_option('wc_facebook_pixel_id'), 'Option wc_facebook_pixel_id should be reset.' );
$this->assertFalse( get_option('wc_facebook_has_connected_fbe_2'), 'Option wc_facebook_has_connected_fbe_2 should be reset.' );
$this->assertFalse( get_option('wc_facebook_has_authorized_pages_read_engagement'), 'Option wc_facebook_has_authorized_pages_read_engagement should be reset.' );
$this->assertFalse( get_option('wc_facebook_enable_messenger'), 'Option wc_facebook_enable_messenger should be reset.' );
}
}
3 changes: 2 additions & 1 deletion tests/Unit/ExternalVersionUpdate/UpdateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
use ReflectionObject;
use WC_Facebookcommerce_Utils;
use WP_Error;
use WooCommerce\Facebook\Tests\AbstractWPUnitTestWithOptionIsolationAndSafeFiltering;

/**
* The External version update unit test class.
*/
class UpdateTest extends WP_UnitTestCase {
class UpdateTest extends AbstractWPUnitTestWithOptionIsolationAndSafeFiltering {

/**
* Instance of the Update class that we are testing.
Expand Down
4 changes: 3 additions & 1 deletion tests/Unit/FacebookCommercePixelEventTest.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<?php
declare( strict_types=1 );

class FacebookCommercePixelTest extends WP_UnitTestCase {
use WooCommerce\Facebook\Tests\AbstractWPUnitTestWithOptionIsolationAndSafeFiltering;

class FacebookCommercePixelTest extends AbstractWPUnitTestWithOptionIsolationAndSafeFiltering {

/**
* Unit tests for WC_Facebookcommerce_Pixel class.
Expand Down
3 changes: 2 additions & 1 deletion tests/Unit/Feed/AbstractFeedTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use WP_UnitTestCase;
use WooCommerce\Facebook\Utilities\Heartbeat;
use WooCommerce\Facebook\Tests\AbstractWPUnitTestWithOptionIsolationAndSafeFiltering;

class TestFeed extends AbstractFeed {
public function __construct(FeedFileWriter $file_writer, AbstractFeedHandler $feed_handler, FeedGenerator $feed_generator) {
Expand Down Expand Up @@ -39,7 +40,7 @@ protected static function get_feed_gen_scheduling_interval(): string {
}
}

class AbstractFeedTest extends WP_UnitTestCase {
class AbstractFeedTest extends AbstractWPUnitTestWithOptionIsolationAndSafeFiltering {
/**
* The test feed class.
*
Expand Down
4 changes: 3 additions & 1 deletion tests/Unit/Feed/FeedUploadUtilsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@

require_once __DIR__ . '/../../../includes/Feed/FeedUploadUtils.php';

use WooCommerce\Facebook\Tests\AbstractWPUnitTestWithOptionIsolationAndSafeFiltering;

/**
* Class FeedUploadUtilsTest
*
* Sets up environment to test various logic in FeedUploadUtils
*/
class FeedUploadUtilsTest extends \WooCommerce\Facebook\Tests\AbstractWPUnitTestWithSafeFiltering {
class FeedUploadUtilsTest extends AbstractWPUnitTestWithOptionIsolationAndSafeFiltering {

/** @var int Shop page ID */
protected static $shop_page_id;
Expand Down
3 changes: 2 additions & 1 deletion tests/Unit/Handlers/MetaExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@

use WooCommerce\Facebook\Handlers\MetaExtension;
use WP_UnitTestCase;
use WooCommerce\Facebook\Tests\AbstractWPUnitTestWithOptionIsolationAndSafeFiltering;

/**
* The Meta Extension unit test class.
*/
class MetaExtensionTest extends \WP_UnitTestCase {
class MetaExtensionTest extends AbstractWPUnitTestWithOptionIsolationAndSafeFiltering {

/**
* Instance of the MetaExtension class that we are testing.
Expand Down
34 changes: 34 additions & 0 deletions tests/Unit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Unit Testing Guidelines

## Important: Shared WordPress Instance

All PHPUnit tests run against a **single, shared WordPress instance**. This means any changes made to the global state, such as:

* Adding or modifying WordPress filters/actions
* Setting or updating WordPress options (`update_option`)

**will persist across all subsequent tests** run in the same session. This can lead to unexpected failures and flaky tests if not handled carefully.

## Using Isolation Traits

To mitigate state leakage between tests, we provide two abstract base classes (implementing traits) that your test classes should extend **selectively**, based on what the test modifies:

1. **`AbstractWPUnitTestWithSafeFiltering`**:
* **Use when:** Your test needs to add WordPress filters or actions (`add_filter`, `add_action`).
* **How it helps:** Provides `add_filter_with_safe_teardown` (works for actions too). Filters added using this method are automatically tracked and removed after each test (`tearDown`), preventing filter leakage.
* **Example:** Testing code that relies on a specific filter being present.

2. **`AbstractWPUnitTestWithOptionIsolationAndSafeFiltering`**:
* **Use when:** Your test reads (`get_option`) or writes (`update_option`) WordPress options.
* **How it helps:** Extends the safe filtering above *and* intercepts `get_option` and `update_option` calls. It stores option values in a temporary, test-specific array instead of the database. This ensures option changes within one test do not affect others. It also provides helper methods like `mock_set_option`, `mock_get_option`, and assertions like `assertOptionUpdated`.
* **Example:** Testing settings functionality or code that relies on specific option values.

**Choose the appropriate base class based on the *minimum* isolation level your test requires.** If your test only adds filters, use `AbstractWPUnitTestWithSafeFiltering`. If it interacts with options (even indirectly), use `AbstractWPUnitTestWithOptionIsolationAndSafeFiltering`.

## Caution: Indirect Option Setting

Be particularly mindful of option setting. Even if your test method doesn't directly call `update_option`, the code under test might.

* **Example:** A test interacting with a REST API endpoint might trigger code that saves settings via `update_option` deep within the API controller logic (e.g., as seen in `RestAPITest.php` scenarios).

If your test, or the code it executes, *could* potentially modify options, **you MUST use `AbstractWPUnitTestWithOptionIsolationAndSafeFiltering`** to prevent side effects on other tests. When in doubt, use the option isolation trait.
3 changes: 2 additions & 1 deletion tests/Unit/WCFacebookCommerceIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
use WooCommerce\Facebook\Products;
use WooCommerce\Facebook\ProductSync\ProductValidator;
use WooCommerce\Facebook\Framework\AdminMessageHandler;
use WooCommerce\Facebook\Tests\AbstractWPUnitTestWithOptionIsolationAndSafeFiltering;

/**
* Unit tests for Facebook Graph API calls.
*/
class WCFacebookCommerceIntegrationTest extends \WooCommerce\Facebook\Tests\AbstractWPUnitTestWithSafeFiltering {
class WCFacebookCommerceIntegrationTest extends \WooCommerce\Facebook\Tests\AbstractWPUnitTestWithOptionIsolationAndSafeFiltering {

/**
* @var WC_Facebookcommerce
Expand Down