├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── phpstan-baseline.neon ├── phpstan.neon.dist ├── psalm.baseline.xml ├── psalm.xml └── src ├── DependencyInjection ├── CompilerPass │ └── ProxyServiceWithMockPass.php ├── Configuration.php └── HappyrServiceMockingExtension.php ├── Generator ├── Constructor.php ├── GeneratorFactory.php ├── LazyLoadingValueHolderGenerator.php └── StaticConstructor.php ├── HappyrServiceMockingBundle.php ├── Proxy ├── Proxy.php └── ProxyDefinition.php ├── ServiceMock.php └── Test └── RestoreServiceContainer.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | The change log describes what is "Added", "Removed", "Changed" or "Fixed" between each release. 4 | 5 | ## 1.0.0 6 | 7 | ### Removed 8 | 9 | - Removed support for < PHP 8.1 10 | 11 | ### Added 12 | 13 | - Support for Symfony 7 14 | 15 | ## 0.3.1 16 | 17 | ### Fixed 18 | 19 | - Added support for mock classes with destructor 20 | 21 | ## 0.3.0 22 | 23 | ### Fixed 24 | 25 | - Make services non-lazy to avoid "A method by name setProxyInitializer already exists in this class." 26 | 27 | ## 0.2.0 28 | 29 | ### Added 30 | 31 | - Support for services created with factories 32 | - Support for Symfony 6 33 | 34 | ### Changed 35 | 36 | - All proxied services are lazy 37 | 38 | ## 0.1.3 39 | 40 | ### Changed 41 | 42 | - The real object is updated whenever a proxy is initialized. 43 | 44 | ## 0.1.2 45 | 46 | ### Changed 47 | 48 | - All proxied services are made public 49 | 50 | ## 0.1.1 51 | 52 | ### Added 53 | 54 | - Trait `RestoreServiceContainer` 55 | - Support for `ServiceMock::swap()` 56 | 57 | ## 0.1.0 58 | 59 | First version 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Tobias Nyholm 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Happyr Service Mocking 2 | 3 | [![Latest Version](https://img.shields.io/github/release/Happyr/service-mocking.svg?style=flat-square)](https://github.com/Happyr/service-mocking/releases) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/happyr/service-mocking.svg?style=flat-square)](https://packagist.org/packages/happyr/service-mocking) 5 | 6 | You want your tests to run as quick as possible, so you build your container once 7 | and let all your tests run on that built container. That is great! 8 | 9 | However, when your service container is built, it is immutable. This causes problems 10 | when you want to mock a service during a functional test. There is no way for you 11 | to change the object in the service container. 12 | 13 | Using this bundle, you can mark some services as "mockable", that will allow you 14 | to define a new custom behavior for a method in that service. If no custom behavior 15 | is defined, the service works as normal. 16 | 17 | ## Install 18 | 19 | ```cli 20 | composer require --dev happyr/service-mocking 21 | ``` 22 | 23 | Make sure to enable the bundle for your test environment only: 24 | 25 | ```php 26 | // config/bundles.php 27 | 28 | ['test' => true], 33 | ]; 34 | ``` 35 | 36 | ## Configure services 37 | 38 | You need to tell the bundle what services you want to mock. That could be done with 39 | the "`happyr_service_mock`" service tag or by defining a list of service ids: 40 | 41 |
42 | PHP config (Symfony 5.3) 43 |
44 | 45 | ```php 46 | services([ 53 | \App\AcmeApiClient::class, 54 | \App\Some\OtherService::class, 55 | ]); 56 | }; 57 | 58 | ``` 59 | 60 |
61 |
62 | Yaml config 63 |
64 | 65 | ```yaml 66 | # config/packages/test/happyr_service_mocking.yaml 67 | 68 | happyr_service_mocking: 69 | services: 70 | - 'App\AcmeApiClient' 71 | - 'App\Some\OtherService' 72 | ``` 73 | 74 |
75 | 76 | ## Usage 77 | 78 | ```php 79 | use App\AcmeApiClient; 80 | use App\Some\OtherService; 81 | use Happyr\ServiceMocking\ServiceMock; 82 | use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; 83 | 84 | class MyTest extends WebTestCase 85 | { 86 | public function testFoo() 87 | { 88 | // ... 89 | 90 | $apiClient = self::getContainer()->get(AcmeApiClient::class); 91 | 92 | // For all calls to $apiClient->show() 93 | ServiceMock::all($apiClient, 'show', function ($id) { 94 | // $id here is the same that is passed to $apiClient->show('123') 95 | return ['id'=>$id, 'name'=>'Foobar']; 96 | }); 97 | 98 | // For only the next call to $apiClient->delete() 99 | ServiceMock::next($apiClient, 'delete', function () { 100 | return true; 101 | }); 102 | 103 | // This will queue a new callable for $apiClient->delete() 104 | ServiceMock::next($apiClient, 'delete', function () { 105 | throw new \InvalidArgument('Item cannot be deleted again'); 106 | }); 107 | 108 | $mock = // create a PHPUnit mock or any other mock you want. 109 | ServiceMock::swap(self::getContainer()->get(OtherService::class), $mock); 110 | 111 | // ... 112 | self::$client->request(...); 113 | } 114 | 115 | protected function tearDown(): void 116 | { 117 | // To make sure we don't affect other tests 118 | ServiceMock::resetAll(); 119 | // You can include the RestoreServiceContainer trait to automatically reset services 120 | } 121 | } 122 | ``` 123 | 124 | ## Internal 125 | 126 | So how is this magic working? 127 | 128 | When the container is built a new proxy class is generated from your service definition. 129 | The proxy class acts and behaves just as the original. But on each method call it 130 | checks the `ProxyDefinition` if a custom behavior have been added. 131 | 132 | With help from static properties, the `ProxyDefinition` will be remembered even if 133 | the Kernel is rebooted. 134 | 135 | ## Limitations 136 | 137 | This trick will not work if you have two different PHP processes, i.e. you are running 138 | your tests with Panther, Selenium etc. 139 | 140 | We can also not create a proxy if your service is final. 141 | 142 | We are only able to mock direct access to a service. Indirect method calls are not mocked. 143 | Example: 144 | 145 | ```php 146 | class MyService { 147 | public function foo() 148 | { 149 | return $this->bar(); 150 | } 151 | 152 | public function bar() 153 | { 154 | return 'original'; 155 | } 156 | } 157 | ``` 158 | 159 | If we mock `MyService::bar()` to return `"mocked"`. You will still get `"orignal"` 160 | when you call `MyService::foo()`. The workaround is to mock `MyService::foo()` too. 161 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "happyr/service-mocking", 3 | "description": "Make it easy to mock services in a built container", 4 | "license": "MIT", 5 | "type": "symfony-bundle", 6 | "keywords": [ 7 | "Symfony", 8 | "testing", 9 | "mock" 10 | ], 11 | "authors": [ 12 | { 13 | "name": "Tobias Nyholm", 14 | "email": "tobias.nyholm@gmail.com" 15 | } 16 | ], 17 | "require": { 18 | "php": ">=8.1", 19 | "friendsofphp/proxy-manager-lts": "^1.0", 20 | "symfony/config": "^5.4 || ^6.0 || ^7.0", 21 | "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", 22 | "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0" 23 | }, 24 | "require-dev": { 25 | "nyholm/symfony-bundle-test": "^2.0 || ^3.0", 26 | "symfony/phpunit-bridge": "^6.4.11 || ^7.1.4" 27 | }, 28 | "minimum-stability": "dev", 29 | "prefer-stable": true, 30 | "autoload": { 31 | "psr-4": { 32 | "Happyr\\ServiceMocking\\": "src/" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "psr-4": { 37 | "Happyr\\ServiceMocking\\Tests\\": "tests/" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Parameter \\#1 \\$callback of function spl_autoload_register expects \\(callable\\(string\\)\\: void\\)\\|null, ProxyManager\\\\Autoloader\\\\AutoloaderInterface given\\.$#" 5 | count: 1 6 | path: src/DependencyInjection/CompilerPass/ProxyServiceWithMockPass.php 7 | 8 | - 9 | message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" 10 | count: 1 11 | path: src/DependencyInjection/Configuration.php 12 | 13 | - 14 | message: "#^PHPDoc tag @param references unknown parameter\\: \\$proxyOptions$#" 15 | count: 1 16 | path: src/Generator/LazyLoadingValueHolderGenerator.php 17 | 18 | - 19 | message: "#^Call to an undefined method ProxyManager\\\\Proxy\\\\LazyLoadingInterface\\:\\:getWrappedValueHolderValue\\(\\)\\.$#" 20 | count: 1 21 | path: src/ServiceMock.php 22 | 23 | - 24 | message: "#^Parameter \\#1 \\$initializer of method ProxyManager\\\\Proxy\\\\LazyLoadingInterface\\\\:\\:setProxyInitializer\\(\\) expects \\(Closure\\(object\\|null\\=, ProxyManager\\\\Proxy\\\\LazyLoadingInterface\\\\=, string\\=, array\\\\=, Closure\\|null\\=, array\\\\=\\)\\: bool\\)\\|null, Closure\\(mixed, ProxyManager\\\\Proxy\\\\LazyLoadingInterface, mixed, array, mixed\\)\\: true given\\.$#" 25 | count: 1 26 | path: src/ServiceMock.php 27 | 28 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | 4 | parameters: 5 | level: 5 6 | reportUnmatchedIgnoredErrors: false 7 | paths: 8 | - src 9 | -------------------------------------------------------------------------------- /psalm.baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/DependencyInjection/CompilerPass/ProxyServiceWithMockPass.php: -------------------------------------------------------------------------------- 1 | getParameter('happyr_service_mock.services'); 18 | 19 | foreach ($container->findTaggedServiceIds('happyr_service_mock') as $id => $tags) { 20 | $serviceIds[] = $id; 21 | } 22 | 23 | $proxiesDirectory = $container->getParameter('kernel.cache_dir').'/happyr_service_mock'; 24 | @mkdir($proxiesDirectory); 25 | 26 | $config = new Configuration(); 27 | $config->setGeneratorStrategy(new FileWriterGeneratorStrategy(new FileLocator($proxiesDirectory))); 28 | $config->setProxiesTargetDir($proxiesDirectory); 29 | \spl_autoload_register($config->getProxyAutoloader()); 30 | $factory = new GeneratorFactory($config); 31 | 32 | foreach (array_unique($serviceIds) as $serviceId) { 33 | if ($container->hasDefinition($serviceId)) { 34 | $definition = $container->getDefinition($serviceId); 35 | } elseif ($container->hasAlias($serviceId)) { 36 | $definition = $container->getDefinition($container->getAlias($serviceId)->__toString()); 37 | } else { 38 | throw new \LogicException(sprintf('[HappyrServiceMocking] Service or alias with id "%s" does not exist.', $serviceId)); 39 | } 40 | 41 | $initializer = function () { 42 | return true; 43 | }; 44 | 45 | $proxy = $factory->createProxy($definition->getClass(), $initializer, ['skipDestructor' => true]); 46 | $definition->setClass($proxyClass = get_class($proxy)); 47 | $definition->setPublic(true); 48 | $definition->setLazy(false); 49 | 50 | if (null !== $definition->getFactory()) { 51 | $factoryMethod = $definition->getFactory(); 52 | $arguments = $definition->getArguments(); 53 | array_unshift($arguments, $factoryMethod); 54 | $definition->setFactory([$proxyClass, '__construct_with_factory']); 55 | $definition->setArguments($arguments); 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode() 15 | ->children() 16 | ->variableNode('services')->defaultValue([])->end() 17 | ->end(); 18 | 19 | return $treeBuilder; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/DependencyInjection/HappyrServiceMockingExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, $configs); 14 | 15 | $container->setParameter('happyr_service_mock.services', array_values($config['services'])); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Generator/Constructor.php: -------------------------------------------------------------------------------- 1 | setBody( 34 | 'static $reflection;'."\n\n" 35 | .'if (! $this->'.$valueHolder->getName().') {'."\n" 36 | .' $reflection = $reflection ?? new \ReflectionClass(' 37 | .\var_export($originalClass->getName(), true) 38 | .");\n" 39 | .' $this->'.$valueHolder->getName().' = $reflection->newInstanceWithoutConstructor();'."\n" 40 | .UnsetPropertiesGenerator::generateSnippet(Properties::fromReflectionClass($originalClass), 'this') 41 | .'}' 42 | .($originalConstructor ? self::generateOriginalConstructorCall($originalConstructor, $valueHolder) : '') 43 | ."\n" 44 | .'\Happyr\ServiceMocking\ServiceMock::initializeProxy($this);' 45 | ); 46 | 47 | return $constructor; 48 | } 49 | 50 | private static function generateOriginalConstructorCall( 51 | MethodReflection $originalConstructor, 52 | PropertyGenerator $valueHolder, 53 | ): string { 54 | return "\n\n" 55 | .'$this->'.$valueHolder->getName().'->'.$originalConstructor->getName().'(' 56 | .\implode( 57 | ', ', 58 | \array_map( 59 | static function (ParameterReflection $parameter): string { 60 | return ($parameter->isVariadic() ? '...' : '').'$'.$parameter->getName(); 61 | }, 62 | $originalConstructor->getParameters() 63 | ) 64 | ) 65 | .');'; 66 | } 67 | 68 | private static function getConstructor(\ReflectionClass $class): ?MethodReflection 69 | { 70 | $constructors = \array_map( 71 | static function (\ReflectionMethod $method): MethodReflection { 72 | return new MethodReflection( 73 | $method->getDeclaringClass()->getName(), 74 | $method->getName() 75 | ); 76 | }, 77 | \array_filter( 78 | $class->getMethods(), 79 | static function (\ReflectionMethod $method): bool { 80 | return $method->isConstructor(); 81 | } 82 | ) 83 | ); 84 | 85 | return \reset($constructors) ?: null; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Generator/GeneratorFactory.php: -------------------------------------------------------------------------------- 1 | generator = new LazyLoadingValueHolderGenerator(); 25 | } 26 | 27 | protected function getGenerator(): ProxyGeneratorInterface 28 | { 29 | return $this->generator; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Generator/LazyLoadingValueHolderGenerator.php: -------------------------------------------------------------------------------- 1 | = 3 ? \func_get_arg(2) : []; 60 | 61 | CanProxyAssertion::assertClassCanBeProxied($originalClass); 62 | 63 | $interfaces = [VirtualProxyInterface::class]; 64 | $publicProperties = new PublicPropertiesMap(Properties::fromReflectionClass($originalClass)); 65 | 66 | if ($originalClass->isInterface()) { 67 | $interfaces[] = $originalClass->getName(); 68 | } else { 69 | $classGenerator->setExtendedClass($originalClass->getName()); 70 | } 71 | 72 | $classGenerator->setImplementedInterfaces($interfaces); 73 | $classGenerator->addPropertyFromGenerator($valueHolder = new ValueHolderProperty($originalClass)); 74 | $classGenerator->addPropertyFromGenerator($initializer = new InitializerProperty()); 75 | $classGenerator->addPropertyFromGenerator($publicProperties); 76 | 77 | $skipDestructor = ($proxyOptions['skipDestructor'] ?? false) && $originalClass->hasMethod('__destruct'); 78 | $excludedMethods = ProxiedMethodsFilter::DEFAULT_EXCLUDED; 79 | 80 | if ($skipDestructor) { 81 | $excludedMethods[] = '__destruct'; 82 | } 83 | 84 | \array_map( 85 | static function (MethodGenerator $generatedMethod) use ($originalClass, $classGenerator): void { 86 | ClassGeneratorUtils::addMethodIfNotFinal($originalClass, $classGenerator, $generatedMethod); 87 | }, 88 | \array_merge( 89 | \array_map( 90 | $this->buildLazyLoadingMethodInterceptor($initializer, $valueHolder, $proxyOptions['fluentSafe'] ?? false), 91 | ProxiedMethodsFilter::getProxiedMethods($originalClass, $excludedMethods) 92 | ), 93 | [ 94 | new StaticProxyConstructor($initializer, Properties::fromReflectionClass($originalClass)), 95 | Constructor::generateMethod($originalClass, $valueHolder), // Not a standard constructor 96 | StaticConstructor::generateMethod($valueHolder), // Not a standard constructor 97 | new MagicGet($originalClass, $initializer, $valueHolder, $publicProperties), 98 | new MagicSet($originalClass, $initializer, $valueHolder, $publicProperties), 99 | new MagicIsset($originalClass, $initializer, $valueHolder, $publicProperties), 100 | new MagicUnset($originalClass, $initializer, $valueHolder, $publicProperties), 101 | new MagicClone($originalClass, $initializer, $valueHolder), 102 | new MagicSleep($originalClass, $initializer, $valueHolder), 103 | new MagicWakeup($originalClass), 104 | new SetProxyInitializer($initializer), 105 | new GetProxyInitializer($initializer), 106 | new InitializeProxy($initializer, $valueHolder), 107 | new IsProxyInitialized($valueHolder), 108 | new GetWrappedValueHolderValue($valueHolder), 109 | ], 110 | $skipDestructor ? [new SkipDestructor($initializer, $valueHolder)] : [] 111 | ) 112 | ); 113 | } 114 | 115 | private function buildLazyLoadingMethodInterceptor( 116 | InitializerProperty $initializer, 117 | ValueHolderProperty $valueHolder, 118 | bool $fluentSafe, 119 | ): callable { 120 | return static function (\ReflectionMethod $method) use ($initializer, $valueHolder, $fluentSafe): LazyLoadingMethodInterceptor { 121 | $byRef = $method->returnsReference() ? '& ' : ''; 122 | $method = LazyLoadingMethodInterceptor::generateMethod( 123 | new MethodReflection($method->getDeclaringClass()->getName(), $method->getName()), 124 | $initializer, 125 | $valueHolder 126 | ); 127 | 128 | if ($fluentSafe) { 129 | $valueHolderName = '$this->'.$valueHolder->getName(); 130 | $body = $method->getBody(); 131 | $newBody = \str_replace('return '.$valueHolderName, 'if ('.$valueHolderName.' === $returnValue = '.$byRef.$valueHolderName, $body); 132 | 133 | if ($newBody !== $body) { 134 | $method->setBody( 135 | \substr($newBody, 0, -1).') {'."\n" 136 | .' return $this;'."\n" 137 | .'}'."\n\n" 138 | .'return $returnValue;' 139 | ); 140 | } 141 | } 142 | 143 | return $method; 144 | }; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/Generator/StaticConstructor.php: -------------------------------------------------------------------------------- 1 | setStatic(true); 27 | $constructor->setParameter(new ParameterGenerator('factory', 'callable')); 28 | $parameter = new ParameterGenerator('arguments'); 29 | $parameter->setVariadic(true); 30 | $constructor->setParameter($parameter); 31 | 32 | $constructor->setBody( 33 | 'static $reflection;'."\n\n" 34 | .'$reflection = $reflection ?? new \ReflectionClass(self::class);'."\n" 35 | .'$model = $reflection->newInstanceWithoutConstructor();'."\n" 36 | .'$model->'.$valueHolder->getName().' = \Closure::fromCallable($factory)->__invoke(...$arguments);'."\n" 37 | .'\Happyr\ServiceMocking\ServiceMock::initializeProxy($model);'."\n\n" 38 | .'return $model;' 39 | ); 40 | 41 | return $constructor; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/HappyrServiceMockingBundle.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class HappyrServiceMockingBundle extends Bundle 15 | { 16 | public function build(ContainerBuilder $container): void 17 | { 18 | parent::build($container); 19 | 20 | $container->addCompilerPass(new ProxyServiceWithMockPass()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Proxy/Proxy.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @internal 11 | */ 12 | class Proxy 13 | { 14 | private $definition; 15 | 16 | public function __construct(ProxyDefinition $definition) 17 | { 18 | $this->definition = $definition; 19 | } 20 | 21 | public function __call($method, $args) 22 | { 23 | $func = $this->definition->getMethodCallable($method); 24 | if (null === $func) { 25 | return $this->definition->getObject()->{$method}(...$args); 26 | } else { 27 | return call_user_func_array($func, $args); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Proxy/ProxyDefinition.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @internal 11 | */ 12 | class ProxyDefinition 13 | { 14 | private $originalObject; 15 | private $methods = []; 16 | private $methodsQueue = []; 17 | private $replacement; 18 | 19 | public function __construct(object $originalObject) 20 | { 21 | $this->originalObject = $originalObject; 22 | } 23 | 24 | public function clear(): void 25 | { 26 | $this->methods = []; 27 | $this->methodsQueue = []; 28 | $this->replacement = null; 29 | } 30 | 31 | public function swap(object $replacement): void 32 | { 33 | $this->clear(); 34 | $this->replacement = $replacement; 35 | } 36 | 37 | /** 38 | * Get an object to execute a method on. 39 | */ 40 | public function getObject(): object 41 | { 42 | return $this->replacement ?? $this->originalObject; 43 | } 44 | 45 | public function getOriginalObject(): object 46 | { 47 | return $this->originalObject; 48 | } 49 | 50 | /** 51 | * @internal 52 | */ 53 | public function setOriginalObject($originalObject): void 54 | { 55 | $this->originalObject = $originalObject; 56 | } 57 | 58 | public function getMethodCallable(string $method): ?callable 59 | { 60 | if (isset($this->methodsQueue[$method])) { 61 | $key = array_key_first($this->methodsQueue[$method]); 62 | if (null !== $key) { 63 | $func = $this->methodsQueue[$method][$key]; 64 | unset($this->methodsQueue[$method][$key]); 65 | 66 | return $func; 67 | } 68 | } 69 | 70 | if (isset($this->methods[$method])) { 71 | return $this->methods[$method]; 72 | } 73 | 74 | return null; 75 | } 76 | 77 | public function addMethod(string $method, callable $func): void 78 | { 79 | if ($this->replacement) { 80 | throw new \LogicException('Cannot add a method after added a replacement'); 81 | } 82 | 83 | $this->methods[$method] = $func; 84 | } 85 | 86 | public function removeMethod(string $method): void 87 | { 88 | unset($this->methods[$method]); 89 | } 90 | 91 | public function appendMethodsQueue(string $method, callable $func): void 92 | { 93 | if ($this->replacement) { 94 | throw new \LogicException('Cannot add a method after added a replacement'); 95 | } 96 | 97 | $this->methodsQueue[$method][] = $func; 98 | } 99 | 100 | public function clearMethodsQueue(string $method): void 101 | { 102 | unset($this->methodsQueue[$method]); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/ServiceMock.php: -------------------------------------------------------------------------------- 1 | swap($replacement); 22 | 23 | // Initialize now so we can use it directly. 24 | self::addInitializer($proxy); 25 | } 26 | 27 | /** 28 | * Make the next call to $method name execute the $func. 29 | */ 30 | public static function next($proxy, string $methodName, callable ...$func): void 31 | { 32 | $definition = self::getDefinition($proxy); 33 | foreach ($func as $f) { 34 | $definition->appendMethodsQueue($methodName, $f); 35 | } 36 | 37 | // Initialize now so we can use it directly. 38 | self::addInitializer($proxy); 39 | } 40 | 41 | /** 42 | * All folloing calls $methodName will execute $func. 43 | */ 44 | public static function all($proxy, string $methodName, callable $func): void 45 | { 46 | $definition = self::getDefinition($proxy); 47 | $definition->addMethod($methodName, $func); 48 | 49 | // Initialize now so we can use it directly. 50 | self::addInitializer($proxy); 51 | } 52 | 53 | /** 54 | * Reset all services. 55 | */ 56 | public static function resetAll(): void 57 | { 58 | foreach (self::$definitions as $definition) { 59 | $definition->clear(); 60 | } 61 | } 62 | 63 | /** 64 | * Reset this service. 65 | */ 66 | public static function reset($proxy): void 67 | { 68 | $definition = self::getDefinition($proxy); 69 | $definition->clear(); 70 | } 71 | 72 | /** 73 | * Remove all functions related to $methodName. 74 | */ 75 | public static function resetMethod($proxy, string $methodName): void 76 | { 77 | $definition = self::getDefinition($proxy); 78 | $definition->removeMethod($methodName); 79 | $definition->clearMethodsQueue($methodName); 80 | } 81 | 82 | /** 83 | * This method is called in the proxy's constructor. 84 | * 85 | * @internal 86 | */ 87 | public static function initializeProxy(LazyLoadingInterface $proxy): void 88 | { 89 | $definition = self::getDefinition($proxy); 90 | // Make sure the definition always have the latest original object. 91 | $definition->setOriginalObject($proxy->getWrappedValueHolderValue()); 92 | self::addInitializer($proxy); 93 | } 94 | 95 | private static function addInitializer(LazyLoadingInterface $proxy): void 96 | { 97 | $initializer = function (&$wrappedObject, LazyLoadingInterface $proxy, $calledMethod, array $parameters, &$nextInitializer) { 98 | $nextInitializer = null; 99 | $wrappedObject = new Proxy(self::getDefinition($proxy)); 100 | 101 | return true; 102 | }; 103 | 104 | $proxy->setProxyInitializer($initializer); 105 | } 106 | 107 | /** 108 | * @param LazyLoadingInterface $proxy 109 | */ 110 | private static function getDefinition($proxy): ProxyDefinition 111 | { 112 | if (!$proxy instanceof LazyLoadingInterface || !method_exists($proxy, 'getWrappedValueHolderValue')) { 113 | throw new \InvalidArgumentException(\sprintf('Object of class "%s" is not a proxy. Did you mark this service correctly?', get_class($proxy))); 114 | } 115 | 116 | $key = sha1(get_class($proxy)); 117 | if (!isset(self::$definitions[$key])) { 118 | self::$definitions[$key] = new ProxyDefinition($proxy->getWrappedValueHolderValue()); 119 | } 120 | 121 | return self::$definitions[$key]; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Test/RestoreServiceContainer.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | trait RestoreServiceContainer 14 | { 15 | /** 16 | * @internal 17 | * 18 | * @after 19 | */ 20 | public static function _restoreContainer(): void 21 | { 22 | ServiceMock::resetAll(); 23 | } 24 | } 25 | --------------------------------------------------------------------------------