Introduce a simplified dependency injection container (#52296)

The code for the old underlying container (ExtendedContainer) is still in the codebase, 
it will be used (instead of the new one) if any of these snippets is present:

define('WOOCOMMERCE_USE_OLD_DI_CONTAINER', true);
add_filter('woocommerce_use_old_di_container', '__return_true');
This commit is contained in:
Néstor Soriano 2024-11-11 12:35:22 +01:00 committed by GitHub
parent 713c0c79b2
commit b65c371618
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1247 additions and 146 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Introduce a simplified dependency injection container

View File

@ -335,13 +335,13 @@ final class WooCommerce {
/**
* These classes have a register method for attaching hooks.
*
* @var RegisterHooksInterface[] $hook_register_classes
*/
$hook_register_classes = $container->get( RegisterHooksInterface::class );
foreach ( $hook_register_classes as $hook_register_class ) {
$hook_register_class->register();
}
$container->get( Automattic\WooCommerce\Internal\Utilities\PluginInstaller::class )->register();
$container->get( Automattic\WooCommerce\Internal\TransientFiles\TransientFilesEngine::class )->register();
$container->get( Automattic\WooCommerce\Internal\Orders\OrderAttributionController::class )->register();
$container->get( Automattic\WooCommerce\Internal\Orders\OrderAttributionBlocksController::class )->register();
$container->get( Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController::class )->register();
$container->get( Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub::class )->register();
}
/**

View File

@ -7,7 +7,9 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce;
use Automattic\WooCommerce\Internal\DependencyManagement\ContainerException;
use Automattic\WooCommerce\Internal\DependencyManagement\ExtendedContainer;
use Automattic\WooCommerce\Internal\DependencyManagement\RuntimeContainer;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\CostOfGoodsSoldServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\COTMigrationServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\DownloadPermissionsAdjusterServiceProvider;
@ -54,49 +56,17 @@ use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\Import
* Class registration should be done via service providers that inherit from Automattic\WooCommerce\Internal\DependencyManagement
* and those should go in the `src\Internal\DependencyManagement\ServiceProviders` folder unless there's a good reason
* to put them elsewhere. All the service provider class names must be in the `SERVICE_PROVIDERS` constant.
*
* IMPORTANT NOTE: By default an instance of RuntimeContainer will be used as the underlying container,
* but it's possible to use the old ExtendedContainer (backed by the PHP League's container package) instead,
* see RuntimeContainer::should_use() for configuration instructions.
* The League's container, the ExtendedContainer class and the related support code will be removed in WooCommerce 10.0.
*/
final class Container {
/**
* The list of service provider classes to register.
*
* @var string[]
*/
private $service_providers = array(
AssignDefaultCategoryServiceProvider::class,
DownloadPermissionsAdjusterServiceProvider::class,
EmailPreviewServiceProvider::class,
OptionSanitizerServiceProvider::class,
OrdersDataStoreServiceProvider::class,
ProductAttributesLookupServiceProvider::class,
ProductDownloadsServiceProvider::class,
ProductImageBySKUServiceProvider::class,
ProductReviewsServiceProvider::class,
ProxiesServiceProvider::class,
RestockRefundedItemsAdjusterServiceProvider::class,
UtilsClassesServiceProvider::class,
COTMigrationServiceProvider::class,
OrdersControllersServiceProvider::class,
OrderAttributionServiceProvider::class,
ObjectCacheServiceProvider::class,
BatchProcessingServiceProvider::class,
OrderMetaBoxServiceProvider::class,
OrderAdminServiceProvider::class,
FeaturesServiceProvider::class,
MarketingServiceProvider::class,
MarketplaceServiceProvider::class,
LayoutTemplatesServiceProvider::class,
LoggingServiceProvider::class,
EnginesServiceProvider::class,
ComingSoonServiceProvider::class,
StatsServiceProvider::class,
ImportExportServiceProvider::class,
CostOfGoodsSoldServiceProvider::class,
);
/**
* The underlying container.
*
* @var \League\Container\Container
* @var RuntimeContainer
*/
private $container;
@ -104,6 +74,19 @@ final class Container {
* Class constructor.
*/
public function __construct() {
if ( RuntimeContainer::should_use() ) {
// When the League container was in use we allowed to retrieve the container itself
// by using 'Psr\Container\ContainerInterface' as the class identifier,
// we continue allowing that for compatibility.
$this->container = new RuntimeContainer(
array(
__CLASS__ => $this,
'Psr\Container\ContainerInterface' => $this,
)
);
return;
}
$this->container = new ExtendedContainer();
// Add ourselves as the shared instance of ContainerInterface,
@ -111,20 +94,23 @@ final class Container {
$this->container->share( __CLASS__, $this );
foreach ( $this->service_providers as $service_provider_class ) {
foreach ( $this->get_service_providers() as $service_provider_class ) {
$this->container->addServiceProvider( $service_provider_class );
}
}
/**
* Finds an entry of the container by its identifier and returns it.
* See the comment about ContainerException in RuntimeContainer::get.
*
* @param string $id Identifier of the entry to look for.
*
* @throws NotFoundExceptionInterface No entry was found for **this** identifier.
* @throws Psr\Container\ContainerExceptionInterface Error while retrieving the entry.
* @return mixed Resolved entry.
*
* @return mixed Entry.
* @throws NotFoundExceptionInterface No entry was found for the supplied identifier (only when using ExtendedContainer).
* @throws Psr\Container\ContainerExceptionInterface Error while retrieving the entry.
* @throws ContainerException Error when resolving the class to an object instance, or (when using RuntimeContainer) class not found.
* @throws \Exception Exception thrown in the constructor or in the 'init' method of one of the resolved classes.
*/
public function get( string $id ) {
return $this->container->get( $id );
@ -144,4 +130,43 @@ final class Container {
public function has( string $id ): bool {
return $this->container->has( $id );
}
/**
* The list of service provider classes to register.
*
* @var string[]
*/
private function get_service_providers(): array {
return array(
AssignDefaultCategoryServiceProvider::class,
DownloadPermissionsAdjusterServiceProvider::class,
EmailPreviewServiceProvider::class,
OptionSanitizerServiceProvider::class,
OrdersDataStoreServiceProvider::class,
ProductAttributesLookupServiceProvider::class,
ProductDownloadsServiceProvider::class,
ProductImageBySKUServiceProvider::class,
ProductReviewsServiceProvider::class,
ProxiesServiceProvider::class,
RestockRefundedItemsAdjusterServiceProvider::class,
UtilsClassesServiceProvider::class,
COTMigrationServiceProvider::class,
OrdersControllersServiceProvider::class,
OrderAttributionServiceProvider::class,
ObjectCacheServiceProvider::class,
BatchProcessingServiceProvider::class,
OrderMetaBoxServiceProvider::class,
OrderAdminServiceProvider::class,
FeaturesServiceProvider::class,
MarketingServiceProvider::class,
MarketplaceServiceProvider::class,
LayoutTemplatesServiceProvider::class,
LoggingServiceProvider::class,
EnginesServiceProvider::class,
ComingSoonServiceProvider::class,
StatsServiceProvider::class,
ImportExportServiceProvider::class,
CostOfGoodsSoldServiceProvider::class,
);
}
}

View File

@ -15,6 +15,8 @@ use Automattic\WooCommerce\Vendor\League\Container\Definition\DefinitionInterfac
/**
* This class extends the original League's Container object by adding some functionality
* that we need for WooCommerce.
*
* NOTE: This class will be removed in WooCommerce 10.0.
*/
class ExtendedContainer extends BaseContainer {
@ -65,7 +67,7 @@ class ExtendedContainer extends BaseContainer {
* @return DefinitionInterface The generated definition for the container.
* @throws ContainerException Invalid parameters.
*/
public function add( string $class_name, $concrete = null, bool $shared = null ) : DefinitionInterface {
public function add( string $class_name, $concrete = null, bool $shared = null ): DefinitionInterface {
if ( ! $this->is_class_allowed( $class_name ) ) {
throw new ContainerException( "You cannot add '$class_name', only classes in the {$this->woocommerce_namespace} namespace are allowed." );
}
@ -92,7 +94,7 @@ class ExtendedContainer extends BaseContainer {
* @return DefinitionInterface The modified definition.
* @throws ContainerException Invalid parameters.
*/
public function replace( string $class_name, $concrete ) : DefinitionInterface {
public function replace( string $class_name, $concrete ): DefinitionInterface {
if ( ! $this->has( $class_name ) ) {
throw new ContainerException( "The container doesn't have '$class_name' registered, please use 'add' instead of 'replace'." );
}
@ -117,7 +119,7 @@ class ExtendedContainer extends BaseContainer {
* @param string $class_name The class name whose definition had been replaced.
* @return bool True if the registration has been reset, false if no replacement had been made for the specified class name.
*/
public function reset_replacement( string $class_name ) : bool {
public function reset_replacement( string $class_name ): bool {
if ( ! array_key_exists( $class_name, $this->original_concretes ) ) {
return false;
}

View File

@ -0,0 +1,225 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\DependencyManagement;
use Automattic\WooCommerce\Blocks\Package as BlocksPackage;
use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\Utilities\StringUtil;
/**
* Dependency injection container used at runtime.
*
* This is a simple container that doesn't implement explicit class registration.
* Instead, all the classes in the Automattic\WooCommerce namespace can be resolved
* and are considered as implicitly registered as single-instance classes
* (so each class will be instantiated only once and the instance will be cached).
*/
class RuntimeContainer {
/**
* The root namespace of all WooCommerce classes in the `src` directory.
*
* @var string
*/
const WOOCOMMERCE_NAMESPACE = 'Automattic\\WooCommerce\\';
/**
* Cache of classes already resolved.
*
* @var array
*/
protected array $resolved_cache;
/**
* A copy of the initial resolved classes cache passed to the constructor.
*
* @var array
*/
protected array $initial_resolved_cache;
/**
* Initializes a new instance of the class.
*
* @param array $initial_resolved_cache Dictionary of class name => instance, to be used as the starting point for the resolved classes cache.
*/
public function __construct( array $initial_resolved_cache ) {
$this->initial_resolved_cache = $initial_resolved_cache;
$this->resolved_cache = $initial_resolved_cache;
}
/**
* Get an instance of a class.
*
* ContainerException will be thrown in these cases:
*
* - $class_name is outside the WooCommerce root namespace (and wasn't included in the initial resolve cache).
* - The class referred by $class_name doesn't exist.
* - Recursive resolution condition found.
* - Reflection exception thrown when instantiating or initializing the class.
*
* A "recursive resolution condition" happens when class A depends on class B and at the same time class B depends on class A, directly or indirectly;
* without proper handling this would lead to an infinite loop.
*
* Note that this method throwing ContainerException implies that code fixes are needed, it's not an error condition that's recoverable at runtime.
*
* @param string $class_name The class name.
*
* @return object The instance of the requested class.
* @throws ContainerException Error when resolving the class to an object instance.
* @throws \Exception Exception thrown in the constructor or in the 'init' method of one of the resolved classes.
*/
public function get( string $class_name ) {
$class_name = trim( $class_name, '\\' );
$resolve_chain = array();
return $this->get_core( $class_name, $resolve_chain );
}
/**
* Core function to get an instance of a class.
*
* @param string $class_name The class name.
* @param array $resolve_chain Classes already resolved in this resolution chain. Passed between recursive calls to the method in order to detect a recursive resolution condition.
* @return object The resolved object.
* @throws ContainerException Error when resolving the class to an object instance.
*/
protected function get_core( string $class_name, array &$resolve_chain ) {
if ( isset( $this->resolved_cache[ $class_name ] ) ) {
return $this->resolved_cache[ $class_name ];
}
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
if ( in_array( $class_name, $resolve_chain, true ) ) {
throw new ContainerException( "Recursive resolution of class '$class_name'. Resolution chain: " . implode( ', ', $resolve_chain ) );
}
if ( ! $this->is_class_allowed( $class_name ) ) {
throw new ContainerException( "Attempt to get an instance of class '$class_name', which is not in the " . self::WOOCOMMERCE_NAMESPACE . ' namespace. Did you forget to add a namespace import?' );
}
if ( ! class_exists( $class_name ) ) {
throw new ContainerException( "Attempt to get an instance of class '$class_name', which doesn't exist." );
}
// Account for the containers used by the Store API and Blocks.
if ( StringUtil::starts_with( $class_name, 'Automattic\WooCommerce\StoreApi\\' ) ) {
return StoreApi::container()->get( $class_name );
}
if ( StringUtil::starts_with( $class_name, 'Automattic\WooCommerce\Blocks\\' ) ) {
return BlocksPackage::container()->get( $class_name );
}
$resolve_chain[] = $class_name;
try {
$instance = $this->instantiate_class_using_reflection( $class_name, $resolve_chain );
} catch ( \ReflectionException $e ) {
throw new ContainerException( "Reflection error when resolving '$class_name': (" . get_class( $e ) . ") {$e->getMessage()}", 0, $e );
}
// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
$this->resolved_cache[ $class_name ] = $instance;
return $instance;
}
/**
* Get an instance of a class using reflection.
* This method recursively calls 'get_core' (which in turn calls this method) for each of the arguments
* in the 'init' method of the resolved class (if the method is public and non-static).
*
* @param string $class_name The name of the class to resolve.
* @param array $resolve_chain Classes already resolved in this resolution chain. Passed between recursive calls to the method in order to detect a recursive resolution condition.
* @return object The resolved object.
*
* @throws ContainerException The 'init' method has invalid arguments.
* @throws \ReflectionException Something went wrong when using reflection to get information about the class to resolve.
*/
private function instantiate_class_using_reflection( string $class_name, array &$resolve_chain ): object {
$ref_class = new \ReflectionClass( $class_name );
$instance = $ref_class->newInstance();
if ( ! $ref_class->hasMethod( 'init' ) ) {
return $instance;
}
$init_method = $ref_class->getMethod( 'init' );
if ( ! $init_method->isPublic() || $init_method->isStatic() ) {
return $instance;
}
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
$init_args = $init_method->getParameters();
$init_arg_instances = array_map(
function ( \ReflectionParameter $arg ) use ( $class_name, &$resolve_chain ) {
$arg_type = $arg->getType();
if ( ! ( $arg_type instanceof \ReflectionNamedType ) ) {
throw new ContainerException( "Error resolving '$class_name': argument '\${$arg->getName()}' doesn't have a type declaration." );
}
if ( $arg_type->isBuiltin() ) {
throw new ContainerException( "Error resolving '$class_name': argument '\${$arg->getName()}' is not of a class type." );
}
if ( $arg->isPassedByReference() ) {
throw new ContainerException( "Error resolving '$class_name': argument '\${$arg->getName()}' is passed by reference." );
}
return $this->get_core( $arg_type->getName(), $resolve_chain );
},
$init_args
);
// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
$init_method->invoke( $instance, ...$init_arg_instances );
return $instance;
}
/**
* Tells if the 'get' method can be used to resolve a given class.
*
* @param string $class_name The class name.
* @return bool True if the class with the supplied name can be resolved with 'get'.
*/
public function has( string $class_name ): bool {
$class_name = trim( $class_name, '\\' );
return $this->is_class_allowed( $class_name ) || isset( $this->resolved_cache[ $class_name ] );
}
/**
* Checks to see whether a class is allowed to be registered.
*
* @param string $class_name The class to check.
*
* @return bool True if the class is allowed to be registered, false otherwise.
*/
protected function is_class_allowed( string $class_name ): bool {
return StringUtil::starts_with( $class_name, self::WOOCOMMERCE_NAMESPACE, false );
}
/**
* Tells if this class should be used as the core WooCommerce dependency injection container (or if the old ExtendedContainer should be used instead).
*
* By default, this returns true, to have it return false you can:
*
* 1. Define the WOOCOMMERCE_USE_OLD_DI_CONTAINER constant with a value of true; or
* 2. Hook on the 'woocommerce_use_old_di_container' filter and have it return false (it receives the value of WOOCOMMERCE_USE_OLD_DI_CONTAINER, or false if the constant doesn't exist).
*
* @return bool True if this class should be used as the core WooCommerce dependency injection container, false if ExtendedContainer should be used instead.
*/
public static function should_use(): bool {
$should_use = ! defined( 'WOOCOMMERCE_USE_OLD_DI_CONTAINER' ) || true !== WOOCOMMERCE_USE_OLD_DI_CONTAINER;
/**
* Hook to decide if the old ExtendedContainer class (instead of RuntimeContainer) should be used as the underlying WooCommerce dependency injection container.
*
* NOTE: This hook will be removed in WooCommerce 9.5.
*
* @param bool $should_use Value of the WOOCOMMERCE_USE_OLD_DI_CONTAINER constant, false if the constant doesn't exist.
*
* @since 9.5.0
*/
return apply_filters( 'woocommerce_use_old_di_container', $should_use );
}
}

View File

@ -71,16 +71,15 @@ class OrderAttributionController implements RegisterHooksInterface {
*
* @internal
*
* @param LegacyProxy $proxy The legacy proxy.
* @param FeaturesController $controller The feature controller.
* @param WPConsentAPI $consent The WPConsentAPI integration.
* @param WC_Logger_Interface $logger The logger object. If not provided, it will be obtained from the proxy.
* @param LegacyProxy $proxy The legacy proxy.
* @param FeaturesController $controller The feature controller.
* @param WPConsentAPI $consent The WPConsentAPI integration.
*/
final public function init( LegacyProxy $proxy, FeaturesController $controller, WPConsentAPI $consent, ?WC_Logger_Interface $logger = null ) {
final public function init( LegacyProxy $proxy, FeaturesController $controller, WPConsentAPI $consent ) {
$this->proxy = $proxy;
$this->feature_controller = $controller;
$this->consent = $consent;
$this->logger = $logger ?? $proxy->call_function( 'wc_get_logger' );
$this->logger = $proxy->call_function( 'wc_get_logger' );
$this->set_fields_and_prefix();
}
@ -112,14 +111,14 @@ class OrderAttributionController implements RegisterHooksInterface {
add_action(
'wp_enqueue_scripts',
function() {
function () {
$this->enqueue_scripts_and_styles();
}
);
add_action(
'admin_enqueue_scripts',
function() {
function () {
$this->enqueue_admin_scripts_and_styles();
}
);
@ -150,7 +149,7 @@ class OrderAttributionController implements RegisterHooksInterface {
// Update order based on submitted fields.
add_action(
'woocommerce_checkout_order_created',
function( $order ) {
function ( $order ) {
// Nonce check is handled by WooCommerce before woocommerce_checkout_order_created hook.
// phpcs:ignore WordPress.Security.NonceVerification
$params = $this->get_unprefixed_field_values( $_POST );
@ -168,7 +167,7 @@ class OrderAttributionController implements RegisterHooksInterface {
add_action(
'woocommerce_order_save_attribution_data',
function( $order, $data ) {
function ( $order, $data ) {
$source_data = $this->get_source_values( $data );
$this->send_order_tracks( $source_data, $order );
$this->set_order_source_data( $source_data, $order );
@ -179,7 +178,7 @@ class OrderAttributionController implements RegisterHooksInterface {
add_action(
'user_register',
function( $customer_id ) {
function ( $customer_id ) {
try {
$customer = new WC_Customer( $customer_id );
$this->set_customer_source_data( $customer );
@ -192,14 +191,14 @@ class OrderAttributionController implements RegisterHooksInterface {
// Add origin data to the order table.
add_action(
'admin_init',
function() {
function () {
$this->register_order_origin_column();
}
);
add_action(
'woocommerce_new_order',
function( $order_id, $order ) {
function ( $order_id, $order ) {
$this->maybe_set_admin_source( $order );
},
2,
@ -521,7 +520,7 @@ class OrderAttributionController implements RegisterHooksInterface {
private function register_order_origin_column() {
$screen_id = $this->get_order_screen_id();
$add_column = function( $columns ) {
$add_column = function ( $columns ) {
$columns['origin'] = esc_html__( 'Origin', 'woocommerce' );
return $columns;
@ -530,7 +529,7 @@ class OrderAttributionController implements RegisterHooksInterface {
add_filter( "manage_{$screen_id}_columns", $add_column );
add_filter( "manage_edit-{$screen_id}_columns", $add_column );
$display_column = function( $column_name, $order_id ) {
$display_column = function ( $column_name, $order_id ) {
if ( 'origin' !== $column_name ) {
return;
}

View File

@ -1,30 +1,28 @@
# WooCommerce `src` files
## Table of contents
* [Installing Composer](#installing-composer)
+ [Updating the autoloader class maps](#updating-the-autoloader-class-maps)
* [Installing packages](#installing-packages)
* [The container](#the-container)
+ [Resolving classes](#resolving-classes)
- [From other classes in the `src` directory](#1-other-classes-in-the-src-directory)
- [From code in the `includes` directory](#2-code-in-the-includes-directory)
+ [Registering classes](#registering-classes)
- [Using concretes](#using-concretes)
- [A note on legacy classes](#a-note-on-legacy-classes)
* [The `Internal` namespace](#the-internal-namespace)
* [Interacting with legacy code](#interacting-with-legacy-code)
+ [The `LegacyProxy` class](#the-legacyproxy-class)
+ [Using the legacy proxy](#using-the-legacy-proxy)
+ [Using the mockable proxy in tests](#using-the-mockable-proxy-in-tests)
+ [But how does `get_instance_of` work?](#but-how-does-get_instance_of-work)
+ [Creating specialized proxies](#creating-specialized-proxies)
* [Defining new actions and filters](#defining-new-actions-and-filters)
* [Writing unit tests](#writing-unit-tests)
+ [Mocking dependencies](#mocking-dependencies)
+ [Additional tools for writing unit tests](#additional-tools-for-writing-unit-tests)
* [Installing Composer](#installing-composer)
* [Updating the autoloader class maps](#updating-the-autoloader-class-maps)
* [Installing packages](#installing-packages)
* [The container](#the-container)
* [Resolving classes](#resolving-classes)
* [From other classes in the `src` directory](#1-other-classes-in-the-src-directory)
* [From code in the `includes` directory](#2-code-in-the-includes-directory)
* [Registering classes](#registering-classes)
* [Using concretes](#using-concretes)
* [A note on legacy classes](#a-note-on-legacy-classes)
* [The `Internal` namespace](#the-internal-namespace)
* [Interacting with legacy code](#interacting-with-legacy-code)
* [The `LegacyProxy` class](#the-legacyproxy-class)
* [Using the legacy proxy](#using-the-legacy-proxy)
* [Using the mockable proxy in tests](#using-the-mockable-proxy-in-tests)
* [But how does `get_instance_of` work?](#but-how-does-get_instance_of-work)
* [Creating specialized proxies](#creating-specialized-proxies)
* [Defining new actions and filters](#defining-new-actions-and-filters)
* [Writing unit tests](#writing-unit-tests)
* [Mocking dependencies](#mocking-dependencies)
* [Additional tools for writing unit tests](#additional-tools-for-writing-unit-tests)
This directory is home to new WooCommerce class files under the `Automattic\WooCommerce` namespace using [PSR-4](https://www.php-fig.org/psr/psr-4/) file naming. This is to take full advantage of autoloading.
@ -32,7 +30,6 @@ Ideally, all the new code for WooCommerce should consist of classes following th
A [PSR-11](https://www.php-fig.org/psr/psr-11/) container is in place for registering and resolving the classes in this directory by using the [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) pattern. There are tools in place to interact with legacy code (and code outside the `src` directory in general) in a way that makes it easy to write unit tests.
## Installing Composer
Composer is used to generate autoload class-maps for the files here. The stable release of WooCommerce comes with the autoloader, however, if you're running a development version you'll need to use Composer.
@ -43,36 +40,50 @@ If you don't have Composer installed, go and check how to [install Composer](htt
If you add a class to WooCommerce you need to run the following to ensure it's included in the autoloader class-maps:
```
```bash
composer dump-autoload
```
## Installing packages
To install the packages WooCommerce requires, from the main directory run:
```
```bash
composer install
```
To update packages run:
```
```bash
composer update
```
## The container
### Important notice
As of version 9.5 WooCommerce uses a new, simpler, custom-made container instead of the old one based on the PHP League's Container package. However, the old container is still in place and it's possible to configure WooCommerce to use it (instead of the new container) by adding one of these snippets:
1. `define('WOOCOMMERCE_USE_OLD_DI_CONTAINER', true);`
2. `add_filter('woocommerce_use_old_di_container', '__return_true');`
This should be done **only** if a problem with the new container causes disruption in the site *(and if you discover such a problem, please [create an issue in GitHub](https://github.com/woocommerce/woocommerce/issues/new) so that we can keep track of it and work on a fix)*. This fallback mechanism, together with the old container and the PHP League's Container package, will be removed in WooCommerce 10.0.
The new container is used in the same way as the old one: it's only for classes in the `src` directory, dependencies are defined in an `init` method of the classes, the container exposes a `get` method and a `has` method, and `wc_get_container` can be used to use the container from [legacy code](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/README.md/?rgh-link-date=2024-10-29T14%3A32%3A58Z#L6-L7); the difference is that explicit class registration is not needed anymore (any class in the `Automattic\Woocommerce` namespace can be resolved). **However**, until WooCommerce 10.0 arrives please keep registering new classes using service providers as instructed below, so that the old container can be used if needed.
As for the unit tests, they can be forced to use the old container by defining the `USE_OLD_DI_CONTAINER` environment variable.
#### End of important notice
WooCommerce uses a [PSR-11](https://www.php-fig.org/psr/psr-11/) compatible container for registering and resolving all the classes in this directory by using the [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) pattern. More specifically, we use [the container from The PHP League](https://container.thephpleague.com/); this is relevant when registering classes, but not when resolving them. The full class name of the container used is `Automattic\WooCommerce\Container` (it uses the PHP League's container under the hood).
_Resolving_ a class means asking the container to provide an instance of the class (or interface). _Registering_ a class means telling the container how the class should be resolved.
*Resolving* a class means asking the container to provide an instance of the class (or interface). *Registering* a class means telling the container how the class should be resolved.
In principle, the container should be used to register and resolve all the classes in the `src` directory. The exception might be data-only classes that could be created the old way (using a plain `new` statement); but as a rule of thumb, the container should always be used.
There are two ways to resolve registered classes, depending on from where they are resolved:
* Classes in the `src` directory specify their dependencies as `init` arguments, which are automatically supplied by the container when the class is resolved (this is called _dependency injection_).
* Classes in the `src` directory specify their dependencies as `init` arguments, which are automatically supplied by the container when the class is resolved (this is called *dependency injection*).
* For code in the `includes` directory there's a `wc_get_container` function that will return the container, then its `get` method can be used to resolve any class.
### Resolving classes
@ -142,7 +153,6 @@ This is also the recommended approach when moving code from `includes` to `src`
Worth noting: the container will throw a `ContainerException` when receiving a request for resolving a class that hasn't been registered. All classes need to have been registered prior to being resolved.
### Registering classes
For a class to be resolvable using the container, it needs to have been previously registered in the same container.
@ -189,7 +199,7 @@ class TheClassServiceProvider extends AbstractServiceProvider {
}
```
Last (but certainly not least, don't forget this step!), add the class name of the service provider to the `$service_providers` property in the `Container` class.
Last (but certainly not least, don't forget this step!), add the class name of the service provider to the array returned by the `get_service_providers` method in the `Container` class.
Worth noting:
@ -264,7 +274,6 @@ The container is intended for registering **only** classes in the `src` folder.
This implies that classes outside `src` can't be dependency-injected, and thus must not be used as type hints in `init` arguments. There are mechanisms in place to interact with "outside" code (including code from the `includes` folder and third-party code) in a way that makes it easy to write unit tests.
## The `Internal` namespace
While it's up to the developer to choose the appropriate namespaces for any newly created classes, and those namespaces should make sense from a semantic point of view, there's one namespace that has a special meaning: `Automattic\WooCommerce\Internal`.
@ -274,11 +283,10 @@ Classes in `Automattic\WooCommerce\Internal` are meant to be WooCommerce infrast
What this implies for you as developer depends on what type of contribution are you making:
* **If you are working on WooCommerce core:** When you need to add a new class please think carefully if the class could be useful for plugins. If you really think so, add it to the appropriate namespace rooted at `Automattic\WooCommerce`. If not, add it to the appropriate namespace but rooted at `Automattic\WooCommerce\Internal`.
* When in doubt, always make the code internal. If an internal class is later deemed to be worth being made public, the change can be made easily (by just changing the class namespace) and nothing will break. Turning a public class into an internal class, on the other hand, is impossible since it could break existing plugins.
* When in doubt, always make the code internal. If an internal class is later deemed to be worth being made public, the change can be made easily (by just changing the class namespace) and nothing will break. Turning a public class into an internal class, on the other hand, is impossible since it could break existing plugins.
* **If you are a plugin developer:** You should **never** use code from the `Automattic\WooCommerce\Internal` namespace in your plugins. Doing so might cause your plugin to break in future versions of WooCommerce.
## Interacting with legacy code
Here by "legacy code" we refer mainly to the old WooCommerce code in the `includes` directory, but the mechanisms described in this section are useful for dealing with any code outside the `src` directory.
@ -349,11 +357,11 @@ Here's an example of how function mocks are defined:
// In this context '$this' is a class that extends WC_Unit_Test_Case
$this->register_legacy_proxy_function_mocks(
array(
'the_function_name' => function( $param1, $param2 ) {
return "I'm the mock of the_function_name and I was invoked with $param1 and $param2.";
},
)
array(
'the_function_name' => function( $param1, $param2 ) {
return "I'm the mock of the_function_name and I was invoked with $param1 and $param2.";
},
)
);
```
@ -377,19 +385,18 @@ That said, an alternative middle ground would be to create more specialized case
```php
class ActionsProxy {
public function did_action( $tag ) {
return did_action( $tag );
}
public function did_action( $tag ) {
return did_action( $tag );
}
public function apply_filters( $tag, $value, ...$parameters ) {
return apply_filters( $tag, $value, ...$parameters );
}
public function apply_filters( $tag, $value, ...$parameters ) {
return apply_filters( $tag, $value, ...$parameters );
}
}
```
Note however that such a class would have to be explicitly dependency-injected (unless additional helper methods are defined in the `WooCommerce` class), and that you would need to create a pairing mock class (e.g. `MockableActionsProxy`) and replace the original registration using `wc_get_container()->replace( ActionsProxy::class, MockableActionsProxy::class )`.
## Defining new actions and filters
WordPress' hooks (actions and filters) are a very powerful extensibility mechanism and it's the core tool that allows WooCommerce extensions to be developer. However it has been often (ab)used in the WooCommerce core codebase to drive internal logic, e.g. an action is triggered from within one class or function with the assumption that somewhere there's some other class or function that will handle it and continue whatever processing is supposed to happen.
@ -398,7 +405,6 @@ In order to keep the code as easy as reasonably possible to read and maintain, *
As usual, there might be reasonable exceptions to this; but please keep this rule in mind whenever you consider creating a new hook.
## Writing unit tests
Unit tests are a fundamental tool to keep the code reliable and reasonably safe from regression errors. To that end, any new code added to the WooCommerce codebase, but especially to the `src` directory, should be reasonably covered by such tests.

View File

@ -0,0 +1,91 @@
<?php
/**
* TestingContainer class file.
*
* @package WooCommerce\Testing\Tools
*/
declare( strict_types=1 );
namespace Automattic\WooCommerce\Testing\Tools;
use Automattic\WooCommerce\Internal\DependencyManagement\ContainerException;
use Automattic\WooCommerce\Internal\DependencyManagement\RuntimeContainer;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Testing\Tools\DependencyManagement\MockableLegacyProxy;
/**
* Dependency injection container to be used in unit tests.
*/
class TestingContainer extends RuntimeContainer {
/**
* Class replacements as a dictionary of class name => instance.
*
* @var array
*/
private $replacements = array();
/**
* Initializes the instance.
*
* @param RuntimeContainer $base_container Base container to use for initialization.
*/
public function __construct( RuntimeContainer $base_container ) {
$initial_resolved_cache = $base_container->initial_resolved_cache;
$initial_resolved_cache[ LegacyProxy::class ] = new MockableLegacyProxy();
parent::__construct( $initial_resolved_cache );
}
/**
* Register a class replacement, so that whenever the class name is requested with 'get', the replacement will be returned instead.
*
* Note that if the instance of the specified class is already cached (the class was requested already)
* this will have no effect unless 'reset_all_resolved' is invoked.
*
* @param string $class_name The class name to replace.
* @param object $concrete The object that will be replaced when the class is requested.
*/
public function replace( string $class_name, object $concrete ): void {
$this->replacements[ $class_name ] = $concrete;
}
/**
* Replacement to the core 'get' method to take in account replacements registered with 'replace'.
*
* @param string $class_name The class name.
* @param array $resolve_chain Classes already resolved in this resolution chain. Passed between recursive calls to the method in order to detect a recursive resolution condition.
* @return object The resolved object.
* @throws ContainerException Error when resolving the class to an object instance.
*/
protected function get_core( string $class_name, array &$resolve_chain ) {
if ( isset( $this->replacements[ $class_name ] ) ) {
return $this->replacements[ $class_name ];
}
return parent::get_core( $class_name, $resolve_chain );
}
/**
* Reset a given class replacement, so that 'get' will return an instance of the original class again.
*
* @param string $class_name The class name whose replacement is to be reset.
*/
public function reset_replacement( string $class_name ) {
unset( $this->replacements[ $class_name ] );
}
/**
* Reset all the replacements registered with 'replace', so that 'get' will return instances of the original classes again.
*/
public function reset_all_replacements() {
$this->replacements = array();
}
/**
* Reset all the cached resolutions, so any further calls to 'get' will generate the appropriate class instances again.
*/
public function reset_all_resolved() {
$this->resolved_cache = $this->initial_resolved_cache;
}
}

View File

@ -13,6 +13,7 @@ use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\StaticMockerHack;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\BypassFinalsHack;
use Automattic\WooCommerce\Testing\Tools\DependencyManagement\MockableLegacyProxy;
use Automattic\WooCommerce\Testing\Tools\TestingContainer;
/**
* Class WC_Unit_Tests_Bootstrap
@ -37,7 +38,13 @@ class WC_Unit_Tests_Bootstrap {
* @since 2.2
*/
public function __construct() {
$this->tests_dir = dirname( __FILE__ );
$use_old_container = false;
if ( getenv( 'USE_OLD_DI_CONTAINER' ) ) {
define( 'WOOCOMMERCE_USE_OLD_DI_CONTAINER', true );
$use_old_container = true;
}
$this->tests_dir = __DIR__;
$this->plugin_dir = dirname( dirname( $this->tests_dir ) );
$this->register_autoloader_for_testing_tools();
@ -86,13 +93,14 @@ class WC_Unit_Tests_Bootstrap {
$this->includes();
// re-initialize dependency injection, this needs to be the last operation after everything else is in place.
$this->initialize_dependency_injection();
$this->initialize_dependency_injection( $use_old_container );
if ( getenv( 'HPOS' ) ) {
$this->initialize_hpos();
}
error_reporting(error_reporting() & ~E_DEPRECATED);
// phpcs:ignore WordPress.PHP.DevelopmentFunctions, WordPress.PHP.DiscouragedPHPFunctions
error_reporting( error_reporting() & ~E_DEPRECATED );
}
/**
@ -101,7 +109,7 @@ class WC_Unit_Tests_Bootstrap {
protected static function register_autoloader_for_testing_tools() {
spl_autoload_register(
function ( $class ) {
$tests_directory = dirname( __FILE__, 2 );
$tests_directory = dirname( __DIR__, 1 );
$helpers_directory = $tests_directory . '/php/helpers';
// Support loading top-level classes from the `php/helpers` directory.
@ -172,16 +180,17 @@ class WC_Unit_Tests_Bootstrap {
* Re-initialize the dependency injection engine.
*
* The dependency injection engine has been already initialized as part of the Woo initialization, but we need
* to replace the registered read-only container with a fully configurable one for testing.
* to replace the registered runtime container with one with extra capabilities for testing.
* To this end we hack a bit and use reflection to grab the underlying container that the read-only one stores
* in a private property.
*
* Additionally, we replace the legacy/function proxies with mockable versions to easily replace anything
* in tests as appropriate.
* Note also that TestingContainer replaces the instance of LegacyProxy with an instance of MockableLegacyProxy.
*
* @param bool $use_old_container The underlying container is the old ExtendedContainer class. This parameter will disappear in WooCommerce 10.0.
*
* @throws \Exception The Container class doesn't have a 'container' property.
*/
private function initialize_dependency_injection() {
private function initialize_dependency_injection( bool $use_old_container ) {
try {
$inner_container_property = new \ReflectionProperty( \Automattic\WooCommerce\Container::class, 'container' );
} catch ( ReflectionException $ex ) {
@ -189,9 +198,15 @@ class WC_Unit_Tests_Bootstrap {
}
$inner_container_property->setAccessible( true );
$inner_container = $inner_container_property->getValue( wc_get_container() );
$inner_container->replace( LegacyProxy::class, MockableLegacyProxy::class );
$container = wc_get_container();
$inner_container = $inner_container_property->getValue( $container );
if ( $use_old_container ) {
$inner_container->replace( LegacyProxy::class, MockableLegacyProxy::class );
} else {
$inner_container = new TestingContainer( $inner_container );
$inner_container_property->setValue( $container, $inner_container );
}
$GLOBALS['wc_container'] = $inner_container;
}

View File

@ -0,0 +1,21 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses;
/**
* An example of a class with a dependency that is passed by reference in the 'init' method.
*/
class ClassThatHasReferenceArgumentsInInit {
/**
* Initialize the class instance.
*
* @internal
*
* @param DependencyClass $dependency_by_reference A class we depend on.
*/
final public function init( DependencyClass &$dependency_by_reference ) {
}
}

View File

@ -0,0 +1,26 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses;
/**
* An example of a class that throws an exception in the 'init' method.
*/
class ClassThatThrowsOnInit {
/**
* The exception to throw.
*
* @var \Exception
*/
public static $exception;
/**
* Initialize the class instance.
*
* @internal
*/
final public function init() {
throw self::$exception;
}
}

View File

@ -0,0 +1,43 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses;
/**
* An example of a class with dependencies that are supplied via constructor arguments,
* with the depended on classes themselves having dependencies too.
*/
class ClassWithNestedDependencies {
/**
* Count of instances of the class created so far.
*
* @var int
*/
public static $instances_count = 0;
/**
* Value supplied to constructor in $dependency_class argument.
*
* @var DependencyClassWithInnerDependency
*/
public $dependency_class = null;
/**
* Creates a new instance of the class.
*/
public function __construct() {
++self::$instances_count;
}
/**
* Initialize the class instance.
*
* @internal
*
* @param DependencyClassWithInnerDependency $dependency_class A class we depend on.
*/
final public function init( DependencyClassWithInnerDependency $dependency_class ) {
$this->dependency_class = $dependency_class;
}
}

View File

@ -1,9 +1,6 @@
<?php
/**
* ClassWithPrivateInjectionMethod class file.
*
* @package Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses
*/
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses;
@ -14,12 +11,20 @@ class ClassWithPrivateInjectionMethod {
// phpcs:disable WooCommerce.Functions.InternalInjectionMethod.MissingPublic, WooCommerce.Functions.InternalInjectionMethod.MissingFinal
/**
* Tells whether the 'init' method has been executed.
*
* @var bool
*/
public $init_executed = false;
/**
* Initialize the class instance.
*
* @internal
*/
private function init() {
$this->init_executed = true;
}
// phpcs:enable

View File

@ -0,0 +1,20 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses;
/**
* An example of a class with a dependency that ends up being recursive (level 1/3).
*/
class ClassWithRecursiveDependencies1 {
/**
* Initialize the class instance.
*
* @internal
*
* @param ClassWithRecursiveDependencies2 $dependency_class A class we depend on.
*/
final public function init( ClassWithRecursiveDependencies2 $dependency_class ) {
}
}

View File

@ -0,0 +1,20 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses;
/**
* An example of a class with a dependency that ends up being recursive (level 2/3).
*/
class ClassWithRecursiveDependencies2 {
/**
* Initialize the class instance.
*
* @internal
*
* @param ClassWithRecursiveDependencies3 $dependency_class A class we depend on.
*/
final public function init( ClassWithRecursiveDependencies3 $dependency_class ) {
}
}

View File

@ -0,0 +1,20 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses;
/**
* An example of a class with a dependency that ends up being recursive (level 3/3).
*/
class ClassWithRecursiveDependencies3 {
/**
* Initialize the class instance.
*
* @internal
*
* @param ClassWithRecursiveDependencies1 $dependency_class A class we depend on.
*/
final public function init( ClassWithRecursiveDependencies1 $dependency_class ) {
}
}

View File

@ -0,0 +1,31 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses;
/**
* An example of a class with a static injection method.
*/
class ClassWithStaticInjectionMethod {
// phpcs:disable WooCommerce.Functions.InternalInjectionMethod.MissingPublic, WooCommerce.Functions.InternalInjectionMethod.MissingFinal
/**
* Tells whether the 'init' method has been executed.
*
* @var bool
*/
public static $init_executed = false;
/**
* Initialize the class instance.
*
* @internal
*/
public static function init() {
self::$init_executed = true;
}
// phpcs:enable
}

View File

@ -0,0 +1,30 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
/**
* Example class with a dependency that is to be resolved by the Store API.
*/
class ClassWithStoreApiDependency {
/**
* Dependency passed to the 'init' method.
*
* @var ExtendSchema
*/
public $dependency_class;
/**
* Initialize the class instance.
*
* @internal
*
* @param ExtendSchema $dependency_class A class we depend on.
*/
final public function init( ExtendSchema $dependency_class ) {
$this->dependency_class = $dependency_class;
}
}

View File

@ -0,0 +1,20 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses;
/**
* An example class that has an injector method argument without any type hint.
*/
class ClassWithUntypedInjectionMethodArgument {
/**
* Initialize the class instance.
*
* @internal
*
* @param mixed $some_argument Anything, really.
*/
final public function init( $some_argument ) {
}
}

View File

@ -0,0 +1,43 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses;
/**
* An example of a class other classes depend on, and has itself a dependency.
*/
class DependencyClassWithInnerDependency {
/**
* Count of instances of the class created so far.
*
* @var int
*/
public static $instances_count = 0;
/**
* Creates a new instance of the class.
*/
public function __construct() {
++self::$instances_count;
}
/**
* The instance of the inner dependency.
*
* @var InnerDependencyClass
*/
public $inner_dependency;
/**
* Initialize the class instance.
*
* @internal
*
* @param InnerDependencyClass $inner_dependency A class we depend on.
*/
final public function init( InnerDependencyClass $inner_dependency ) {
$this->inner_dependency = $inner_dependency;
}
}

View File

@ -0,0 +1,25 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses;
/**
* An example of a class other classes (that are themselves dependencies) depend on.
*/
class InnerDependencyClass {
/**
* Count of instances of the class created so far.
*
* @var int
*/
public static $instances_count = 0;
/**
* Creates a new instance of the class.
*/
public function __construct() {
++self::$instances_count;
}
}

View File

@ -0,0 +1,11 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses;
/**
* An example of a trait.
*/
trait SomeTrait {
}

View File

@ -0,0 +1,239 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement;
use Automattic\WooCommerce\Blocks\Assets\Api as BlocksAssetsApi;
use Automattic\WooCommerce\Blocks\Package as BlocksPackage;
use Automattic\WooCommerce\Internal\DependencyManagement\ContainerException;
use Automattic\WooCommerce\Internal\DependencyManagement\ExtendedContainer;
use Automattic\WooCommerce\Internal\DependencyManagement\RuntimeContainer;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassInterface;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassThatHasReferenceArgumentsInInit;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassWithNestedDependencies;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassWithPrivateInjectionMethod;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassWithRecursiveDependencies1;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassWithRecursiveDependencies2;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassWithRecursiveDependencies3;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassWithScalarInjectionMethodArgument;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassWithStaticInjectionMethod;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassWithUntypedInjectionMethodArgument;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\DependencyClassWithInnerDependency;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\InnerDependencyClass;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassThatThrowsOnInit;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassWithStoreApiDependency;
/**
* Tests for RuntimeContainer.
*/
class RuntimeContainerTest extends \WC_Unit_Test_Case {
/**
* The system under test.
*
* @var ExtendedContainer
*/
private $sut;
/**
* Runs before each test.
*/
public function setUp(): void {
$this->sut = new RuntimeContainer(
array( 'Foo\Bar' => $this )
);
}
/**
* @testdox 'get' throws 'ContainerException' when trying to resolve a class outside the root WooCommerce namespace.
*/
public function test_exception_when_trying_to_resolve_class_outside_root_namespace() {
$this->expectException( ContainerException::class );
$this->expectExceptionMessage( "Attempt to get an instance of class 'Fizz\Buzz', which is not in the Automattic\WooCommerce\ namespace. Did you forget to add a namespace import?" );
$this->sut->get( 'Fizz\Buzz' );
}
/**
* @testdox 'get' throws 'ContainerException' when trying to resolve a class that doesn't exist.
*/
public function test_exception_when_trying_to_resolve_non_existing_class() {
$this->expectException( ContainerException::class );
$this->expectExceptionMessage( "Attempt to get an instance of class 'Automattic\WooCommerce\Fizz\Buzz', which doesn't exist." );
$this->sut->get( 'Automattic\WooCommerce\Fizz\Buzz' );
}
/**
* @testdox 'get' can resolve classes passed to the constructor in the initial resolve cache.
*/
public function test_can_get_instance_included_in_initial_resolved_cache() {
$this->assertSame( $this, $this->sut->get( 'Foo\Bar' ) );
}
/**
* @testdox 'get' properly resolves and caches classes, and its dependencies, if they are in the root WooCommerce namespace.
*/
public function test_resolves_and_caches_classes_and_dependencies() {
ClassWithNestedDependencies::$instances_count = 0;
DependencyClassWithInnerDependency::$instances_count = 0;
InnerDependencyClass::$instances_count = 0;
$instance_1 = $this->sut->get( ClassWithNestedDependencies::class );
$instance_2 = $this->sut->get( ClassWithNestedDependencies::class );
$this->assertInstanceOf( ClassWithNestedDependencies::class, $instance_1 );
$this->assertSame( $instance_2, $instance_1 );
$this->assertEquals( 1, ClassWithNestedDependencies::$instances_count );
$this->assertInstanceOf( DependencyClassWithInnerDependency::class, $instance_1->dependency_class );
$this->assertEquals( 1, DependencyClassWithInnerDependency::$instances_count );
$this->assertInstanceOf( InnerDependencyClass::class, $instance_1->dependency_class->inner_dependency );
$this->assertEquals( 1, InnerDependencyClass::$instances_count );
}
/**
* @testdox 'get' doesn't invoke the 'init' method of the resolved classes if the method is private.
*/
public function test_private_init_method_is_not_invoked() {
$instance = $this->sut->get( ClassWithPrivateInjectionMethod::class );
$this->assertFalse( $instance->init_executed );
}
/**
* @testdox 'get' doesn't invoke the 'init' method of the resolved classes if the method is static.
*/
public function test_static_init_method_is_not_invoked() {
$this->sut->get( ClassWithStaticInjectionMethod::class );
$this->assertFalse( ClassWithStaticInjectionMethod::$init_executed );
}
/**
* @testdox 'get' throws 'ContainerException' when trying to resolve a class that has a scalar argument in the 'init' method.
*/
public function test_cant_use_scalar_init_arguments() {
$this->expectException( ContainerException::class );
$this->expectExceptionMessage( "Error resolving '" . ClassWithScalarInjectionMethodArgument::class . "': argument '\$scalar_argument_without_default_value' is not of a class type." );
$this->sut->get( ClassWithScalarInjectionMethodArgument::class );
}
/**
* @testdox 'get' throws 'ContainerException' when trying to resolve a class that has an unnamed argument in the 'init' method.
*/
public function test_cant_use_untyped_init_arguments() {
$this->expectException( ContainerException::class );
$this->expectExceptionMessage( "Error resolving '" . ClassWithUntypedInjectionMethodArgument::class . "': argument '\$some_argument' doesn't have a type declaration." );
$this->sut->get( ClassWithUntypedInjectionMethodArgument::class );
}
/**
* @testdox 'get' throws 'ContainerException' when trying to resolve an interface.
*/
public function test_cant_resolve_interfaces() {
$this->expectException( ContainerException::class );
$this->expectExceptionMessage( "Attempt to get an instance of class '" . ClassInterface::class . "', which doesn't exist." );
$this->sut->get( ClassInterface::class );
}
/**
* @testdox 'get' throws 'ContainerException' when trying to resolve a trait.
*/
public function test_cant_resolve_traits() {
$this->expectException( ContainerException::class );
$this->expectExceptionMessage( "Attempt to get an instance of class '" . SomeTrait::class . "', which doesn't exist." );
$this->sut->get( SomeTrait::class );
}
/**
* @testdox 'get' throws 'ContainerException' when trying to resolve a class that has recursive dependencies.
*/
public function test_recursive_dependencies_throws_error() {
$this->expectException( ContainerException::class );
$this->expectExceptionMessage( "Recursive resolution of class '" . ClassWithRecursiveDependencies1::class . "'. Resolution chain: " . ClassWithRecursiveDependencies1::class . ', ' . ClassWithRecursiveDependencies2::class . ', ' . ClassWithRecursiveDependencies3::class );
$this->sut->get( ClassWithRecursiveDependencies1::class );
}
/**
* @testdox 'get' throws 'ContainerException' when trying to resolve a class that has an argument passed by reference in the 'init' method.
*/
public function test_init_cant_contain_methods_by_reference() {
$this->expectException( ContainerException::class );
$this->expectExceptionMessage( "Error resolving '" . ClassThatHasReferenceArgumentsInInit::class . "': argument '\$dependency_by_reference' is passed by reference." );
$this->sut->get( ClassThatHasReferenceArgumentsInInit::class );
}
/**
* @testdox 'get' throws 'ContainerException' when 'ReflectionException' is thrown while trying to instantiate the class.
*/
public function test_reflection_exceptions_are_thrown_as_container_exception() {
$this->expectException( ContainerException::class );
$this->expectExceptionMessage( "Reflection error when resolving '" . ClassThatThrowsOnInit::class . "': (ReflectionException) This doesn't reflect well." );
ClassThatThrowsOnInit::$exception = new \ReflectionException( "This doesn't reflect well." );
$this->sut->get( ClassThatThrowsOnInit::class );
}
/**
* @testdox 'get' doesn't catch exceptions thrown inside the resolved class constructor or 'init' method.
*/
public function test_init_exceptions_are_thrown_as_is() {
$this->expectException( \InvalidArgumentException::class );
$this->expectExceptionMessage( "The argument that isn't here is invalid." );
ClassThatThrowsOnInit::$exception = new \InvalidArgumentException( "The argument that isn't here is invalid." );
$this->sut->get( ClassThatThrowsOnInit::class );
}
/**
* @testdox 'get' resolves Store API classes using the Store API container.
*/
public function test_store_api_dependencies_are_resolved_using_the_store_api_container() {
$resolved = $this->sut->get( ClassWithStoreApiDependency::class );
$this->assertSame( $resolved->dependency_class, StoreApi::container()->get( ExtendSchema::class ) );
}
/**
* @testdox 'get' resolves Blocks classes using the Store API container.
*/
public function test_blocks_classes_are_resolved_using_the_blocks_container() {
$resolved = $this->sut->get( BlocksAssetsApi::class );
$this->assertSame( $resolved, BlocksPackage::container()->get( BlocksAssetsApi::class ) );
}
/**
* @testdox 'has' returns true for classes in the root WooCommerce namespace, or passed to the constructor in the initial resolve cache.
*/
public function test_has_returns_true_for_classes_in_the_root_namespace_or_in_the_initial_resolve_list() {
$this->assertTrue( $this->sut->has( ClassWithNestedDependencies::class ) );
$this->assertTrue( $this->sut->has( 'Foo\Bar' ) );
}
/**
* @testdox 'has' properly handles '\' characters at the beginning or end of the class name.
*/
public function test_has_handles_backslash_at_the_beginning_or_end_of_class_names() {
$this->assertTrue( $this->sut->has( '\\' . ClassWithNestedDependencies::class . '\\' ) );
$this->assertTrue( $this->sut->has( '\Foo\Bar\\' ) );
}
/**
* @testdox 'has' returns false for classes not in the root WooCommerce namespace.
*/
public function test_has_returns_false_for_classes_not_in_the_root_namespace() {
$this->assertFalse( $this->sut->has( 'Fizz\Buzz' ) );
}
}

View File

@ -0,0 +1,174 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\DependencyManagement;
use Automattic\WooCommerce\Internal\DependencyManagement\RuntimeContainer;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Testing\Tools\DependencyManagement\MockableLegacyProxy;
use Automattic\WooCommerce\Testing\Tools\TestingContainer;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassWithNestedDependencies;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\ClassWithNoInterface;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\DependencyClass;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\DependencyClassWithInnerDependency;
/**
* Tests for TestingContainer.
*/
class TestingContainerTest extends \WC_Unit_Test_Case {
/**
* The system under test.
*
* @var TestingContainer
*/
private $sut;
/**
* Runs before each test.
*/
public function setUp(): void {
$base_container = new RuntimeContainer(
array( 'Foo\Bar' => $this )
);
$this->sut = new TestingContainer( $base_container );
}
/**
* @testdox 'get' can resolve classes passed to the constructor of the base container.
*/
public function test_can_get_instance_included_in_initial_resolved_cache() {
$this->assertSame( $this, $this->sut->get( 'Foo\Bar' ) );
}
/**
* @testdox 'get' returns an instance of MockableLegacyProxy when LegacyProxy is requested.
*/
public function test_get_retrieves_legacy_proxy_as_the_mockable_version() {
$this->assertInstanceOf( MockableLegacyProxy::class, $this->sut->get( LegacyProxy::class ) );
}
/**
* @testdox 'replace' allows supplying an object to be returned when a given class is requested with 'get'.
*/
public function test_replace_allows_replacing_direct_classes() {
$mock = new \stdClass();
$this->sut->replace( DependencyClass::class, $mock );
$this->assertSame( $mock, $this->sut->get( DependencyClass::class ) );
}
/**
* @testdox 'replace' continues replacing the appropriate class even after it has been resolved already.
*/
public function test_replace_works_with_already_resolved_direct_classes() {
$this->sut->get( DependencyClass::class );
$mock = new \stdClass();
$this->sut->replace( DependencyClass::class, $mock );
$this->assertSame( $mock, $this->sut->get( DependencyClass::class ) );
}
/**
* @testdox 'replace' allows supplying an object to be used in place of a dependency in an 'init' method.
*/
public function test_replace_allows_replacing_dependencies() {
$mock = new class() extends DependencyClassWithInnerDependency {};
$this->sut->replace( DependencyClassWithInnerDependency::class, $mock );
$this->assertEquals( $mock, $this->sut->get( ClassWithNestedDependencies::class )->dependency_class );
}
/**
* @testdox 'replace' for dependencies in 'init' methods works as expected for dependencies already resolved only after 'reset_all_resolved'.
*/
public function test_replace_works_with_already_resolved_dependencies_if_resolutions_are_reset() {
$this->sut->get( ClassWithNestedDependencies::class );
$mock = new class() extends DependencyClassWithInnerDependency {};
$this->sut->replace( DependencyClassWithInnerDependency::class, $mock );
$this->assertInstanceOf( DependencyClassWithInnerDependency::class, $this->sut->get( ClassWithNestedDependencies::class )->dependency_class );
$this->sut->reset_all_resolved();
$this->assertEquals( $mock, $this->sut->get( ClassWithNestedDependencies::class )->dependency_class );
}
/**
* @testdox 'reset_replacement' undoes one single replacement made with 'replace'.
*/
public function test_reset_replacement() {
$mock = new \stdClass();
$this->sut->replace( ClassWithNestedDependencies::class, $mock );
$this->sut->replace( ClassWithNoInterface::class, $mock );
$this->assertSame( $mock, $this->sut->get( ClassWithNestedDependencies::class ) );
$this->assertSame( $mock, $this->sut->get( ClassWithNoInterface::class ) );
$this->sut->reset_replacement( ClassWithNestedDependencies::class );
$this->assertInstanceOf( ClassWithNestedDependencies::class, $this->sut->get( ClassWithNestedDependencies::class ) );
$this->assertSame( $mock, $this->sut->get( ClassWithNoInterface::class ) );
}
/**
* @testdox 'reset_all_replacements' undoes all the replacements made with 'replace'.
*/
public function test_reset_all_replacement() {
$mock = new \stdClass();
$this->sut->replace( ClassWithNestedDependencies::class, $mock );
$this->sut->replace( ClassWithNoInterface::class, $mock );
$this->assertSame( $mock, $this->sut->get( ClassWithNestedDependencies::class ) );
$this->assertSame( $mock, $this->sut->get( ClassWithNoInterface::class ) );
$this->sut->reset_all_replacements();
$this->assertInstanceOf( ClassWithNestedDependencies::class, $this->sut->get( ClassWithNestedDependencies::class ) );
$this->assertInstanceOf( ClassWithNoInterface::class, $this->sut->get( ClassWithNoInterface::class ) );
}
/**
* @testdox 'get' still returns an instance of MockableLegacyProxy when LegacyProxy is requested after resetting all replacements.
*/
public function test_get_retrieves_legacy_proxy_as_the_mockable_version_even_after_resetting_replacements() {
$this->sut->reset_all_replacements();
$this->assertInstanceOf( MockableLegacyProxy::class, $this->sut->get( LegacyProxy::class ) );
}
/**
* @testdox 'reset_all_resolved' undoes all class resolutions, effectively reverting the container to its initial state.
*/
public function test_reset_all_resolved() {
ClassWithNestedDependencies::$instances_count = 0;
DependencyClassWithInnerDependency::$instances_count = 0;
$this->sut->get( ClassWithNestedDependencies::class );
$this->sut->get( ClassWithNestedDependencies::class );
$this->sut->get( ClassWithNestedDependencies::class );
$this->assertEquals( 1, ClassWithNestedDependencies::$instances_count );
$this->assertEquals( 1, DependencyClassWithInnerDependency::$instances_count );
$this->sut->reset_all_resolved();
$this->sut->get( ClassWithNestedDependencies::class );
$this->sut->get( ClassWithNestedDependencies::class );
$this->sut->get( ClassWithNestedDependencies::class );
$this->assertEquals( 2, ClassWithNestedDependencies::$instances_count );
$this->assertEquals( 2, DependencyClassWithInnerDependency::$instances_count );
}
/**
* @testdox 'reset_all_resolved' handles special cases (gives MockableLegacyProxy instead of LegacyProxy, and keeps the initial resolutions list passed).
*/
public function test_reset_all_resolved_handles_special_Cases() {
$this->sut->reset_all_resolved();
$this->assertInstanceOf( MockableLegacyProxy::class, $this->sut->get( LegacyProxy::class ) );
$this->assertSame( $this, $this->sut->get( 'Foo\Bar' ) );
}
}

View File

@ -5,6 +5,7 @@
namespace Automattic\WooCommerce\Tests\Proxies;
use Automattic\WooCommerce\Internal\DependencyManagement\ExtendedContainer;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Tests\Proxies\ExampleClasses\ClassThatDependsOnLegacyCode;
use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\DependencyClass;
@ -25,7 +26,12 @@ class ClassThatDependsOnLegacyCodeTest extends \WC_Unit_Test_Case {
*/
public function setUp(): void {
$container = wc_get_container();
$container->add( ClassThatDependsOnLegacyCode::class )->addArgument( LegacyProxy::class );
// TODO: Remove this in WooCommerce 10.0.
if ( $container instanceof ExtendedContainer ) {
$container->add( ClassThatDependsOnLegacyCode::class )->addArgument( LegacyProxy::class );
}
$this->sut = $container->get( ClassThatDependsOnLegacyCode::class );
}
@ -52,7 +58,7 @@ class ClassThatDependsOnLegacyCodeTest extends \WC_Unit_Test_Case {
public function test_function_mocks_can_be_used_via_injected_legacy_proxy_and_woocommerce_object( $method_to_use ) {
$this->register_legacy_proxy_function_mocks(
array(
'hexdec' => function( $hex_string ) {
'hexdec' => function ( $hex_string ) {
return "Mocked hexdec for $hex_string";
},
)
@ -85,7 +91,7 @@ class ClassThatDependsOnLegacyCodeTest extends \WC_Unit_Test_Case {
$this->register_legacy_proxy_static_mocks(
array(
DependencyClass::class => array(
'concat' => function( ...$parts ) {
'concat' => function ( ...$parts ) {
return "I'm returning concat of these parts: " . join( ' ', $parts );
},
),