diff --git a/control/injector/Injector.php b/control/injector/Injector.php index af0552a4a..1888ce838 100644 --- a/control/injector/Injector.php +++ b/control/injector/Injector.php @@ -210,6 +210,13 @@ class Injector { } } + /** + * The injector instance this one was copied from when Injector::nest() was called. + * + * @var Injector + */ + protected $nestedFrom = null; + /** * If a user wants to use the injector as a static reference * @@ -227,9 +234,38 @@ class Injector { * Sets the default global injector instance. * * @param Injector $instance + * @return Injector Reference to new active Injector instance */ public static function set_inst(Injector $instance) { - self::$instance = $instance; + return self::$instance = $instance; + } + + /** + * Make the newly active {@link Injector} be a copy of the current active + * {@link Injector} instance. + * + * You can then make changes to the injector with methods such as + * {@link Injector::inst()->registerService()} which will be discarded + * upon a subsequent call to {@link Injector::unnest()} + * + * @return Injector Reference to new active Injector instance + */ + public static function nest() { + $current = self::$instance; + + $new = clone $current; + $new->nestedFrom = $current; + return self::set_inst($new); + } + + /** + * Change the active Injector back to the Injector instance the current active + * Injector object was copied from. + * + * @return Injector Reference to restored active Injector instance + */ + public static function unnest() { + return self::set_inst(self::$instance->nestedFrom); } /** diff --git a/core/Config.php b/core/Config.php index 752e73c27..f048d0fa1 100644 --- a/core/Config.php +++ b/core/Config.php @@ -201,13 +201,15 @@ class Config { * A use case for replacing the active configuration set would be for * creating an isolated environment for unit tests. * - * @return Config + * @param Config $instance New instance of Config to assign + * @return Config Reference to new active Config instance */ public static function set_instance($instance) { self::$instance = $instance; global $_SINGLETONS; $_SINGLETONS['Config'] = $instance; + return $instance; } /** @@ -215,23 +217,27 @@ class Config { * {@link Config} instance. * * You can then make changes to the configuration by calling update and - * remove on the new value returned by Config::inst(), and then discard + * remove on the new value returned by {@link Config::inst()}, and then discard * those changes later by calling unnest. + * + * @return Config Reference to new active Config instance */ public static function nest() { $current = self::$instance; $new = clone $current; $new->nestedFrom = $current; - self::set_instance($new); + return self::set_instance($new); } /** * Change the active Config back to the Config instance the current active * Config object was copied from. + * + * @return Config Reference to new active Config instance */ public static function unnest() { - self::set_instance(self::$instance->nestedFrom); + return self::set_instance(self::$instance->nestedFrom); } /** diff --git a/docs/en/changelogs/3.1.5.md b/docs/en/changelogs/3.1.5.md index 54647c1e8..83ddd2349 100644 --- a/docs/en/changelogs/3.1.5.md +++ b/docs/en/changelogs/3.1.5.md @@ -7,3 +7,5 @@ user login name between sessions, and disable browser auto-completion on the username field. Note that users of certain browsers who have previously autofilled and saved login credentials will need to clear their password autofill history before this setting is properly respected. + * Test cases that rely on updating and restoring `[api:Injector]` services may now take advantage + of the new `Injector::nest()` and `Injector::unnest()` methods to sandbox their alterations. \ No newline at end of file diff --git a/docs/en/reference/injector.md b/docs/en/reference/injector.md index 20b8b792b..42df40321 100644 --- a/docs/en/reference/injector.md +++ b/docs/en/reference/injector.md @@ -192,6 +192,30 @@ would * Create a MySQLDatabase class, passing dbusername and dbpassword as the parameters to the constructor +### Testing with Injector in a sandbox environment + +In situations where injector states must be temporarily overridden, it is possible +to create nested Injector instances which may be later discarded, reverting the +application to the original state. + +This is useful when writing test cases, as certain services may be necessary to +override for a single method call. + +For instance, a temporary service can be registered and unregistered as below: + + :::php + // Setup default service + Injector::inst()->registerService(new LiveService(), 'ServiceName'); + + // Test substitute service temporarily + Injector::nest(); + Injector::inst()->registerService(new TestingService(), 'ServiceName'); + $service = Injector::inst()->get('ServiceName'); + // ... do something with $service + Injector::unnest(); + + // ... future requests for 'ServiceName' will return the LiveService instance + ### What are Services? diff --git a/tests/control/DirectorTest.php b/tests/control/DirectorTest.php index 7f8d26a3e..1481dee16 100644 --- a/tests/control/DirectorTest.php +++ b/tests/control/DirectorTest.php @@ -13,6 +13,9 @@ class DirectorTest extends SapphireTest { public function setUp() { parent::setUp(); + + // Required for testRequestFilterInDirectorTest + Injector::nest(); // Hold the original request URI once so it doesn't get overwritten if(!self::$originalRequestURI) { @@ -42,7 +45,7 @@ class DirectorTest extends SapphireTest { // TODO Remove director rule, currently API doesnt allow this // Remove base URL override (setting to false reverts to default behaviour) - Director::setBaseURL(false); + Config::inst()->update('Director', 'alternate_base_url', false); // Reinstate the original REQUEST_URI after it was modified by some tests $_SERVER['REQUEST_URI'] = self::$originalRequestURI; @@ -52,6 +55,8 @@ class DirectorTest extends SapphireTest { $_SERVER[$header] = $value; } } + + Injector::unnest(); parent::tearDown(); } @@ -80,7 +85,7 @@ class DirectorTest extends SapphireTest { $rootURL = Director::protocolAndHost(); $_SERVER['REQUEST_URI'] = "$rootURL/mysite/sub-page/"; - Director::setBaseURL('/mysite/'); + Config::inst()->update('Director', 'alternate_base_url', '/mysite/'); // Test already absolute url $this->assertEquals($rootURL, Director::absoluteURL($rootURL)); @@ -387,8 +392,6 @@ class DirectorTest extends SapphireTest { $processor = new RequestProcessor(array($filter)); - $currentProcessor = Injector::inst()->get('RequestProcessor'); - Injector::inst()->registerService($processor, 'RequestProcessor'); $response = Director::test('some-dummy-url'); @@ -413,9 +416,6 @@ class DirectorTest extends SapphireTest { // preCall 'false' will trigger an exception and prevent post call execution $this->assertEquals(2, $filter->postCalls); - - // swap back otherwise our wrapping test execution request may fail in the post processing later - Injector::inst()->registerService($currentProcessor, 'RequestProcessor'); } } diff --git a/tests/core/ConfigTest.php b/tests/core/ConfigTest.php index 6a0edebdc..29dd5ef34 100644 --- a/tests/core/ConfigTest.php +++ b/tests/core/ConfigTest.php @@ -75,6 +75,28 @@ class ConfigStaticTest_Combined3 extends ConfigStaticTest_Combined2 { } class ConfigTest extends SapphireTest { + + public function testNest() { + + // Check basic config + $this->assertEquals(3, Config::inst()->get('ConfigTest_DefinesFooAndBar', 'foo')); + $this->assertEquals(3, Config::inst()->get('ConfigTest_DefinesFooAndBar', 'bar')); + + // Test nest copies data + Config::nest(); + $this->assertEquals(3, Config::inst()->get('ConfigTest_DefinesFooAndBar', 'foo')); + $this->assertEquals(3, Config::inst()->get('ConfigTest_DefinesFooAndBar', 'bar')); + + // Test nested data can be updated + Config::inst()->update('ConfigTest_DefinesFooAndBar', 'foo', 4); + $this->assertEquals(4, Config::inst()->get('ConfigTest_DefinesFooAndBar', 'foo')); + $this->assertEquals(3, Config::inst()->get('ConfigTest_DefinesFooAndBar', 'bar')); + + // Test unnest restores data + Config::unnest(); + $this->assertEquals(3, Config::inst()->get('ConfigTest_DefinesFooAndBar', 'foo')); + $this->assertEquals(3, Config::inst()->get('ConfigTest_DefinesFooAndBar', 'bar')); + } public function testUpdateStatic() { $this->assertEquals(Config::inst()->get('ConfigStaticTest_First', 'first', Config::FIRST_SET), diff --git a/tests/injector/InjectorTest.php b/tests/injector/InjectorTest.php index 06099779c..d5b626b88 100644 --- a/tests/injector/InjectorTest.php +++ b/tests/injector/InjectorTest.php @@ -12,6 +12,24 @@ define('TEST_SERVICES', dirname(__FILE__) . '/testservices'); */ class InjectorTest extends SapphireTest { + protected $nestingLevel = 0; + + public function setUp() { + parent::setUp(); + + $this->nestingLevel = 0; + } + + public function tearDown() { + + while($this->nestingLevel > 0) { + $this->nestingLevel--; + Config::unnest(); + } + + parent::tearDown(); + } + public function testCorrectlyInitialised() { $injector = Injector::inst(); $this->assertTrue($injector->getConfigLocator() instanceof SilverStripeServiceConfigurationLocator, @@ -562,6 +580,64 @@ class InjectorTest extends SapphireTest { $this->assertInstanceOf('TestObject', $injector->get('service')); } + + /** + * Test nesting of injector + */ + public function testNest() { + + // Outer nest to avoid interference with other + Injector::nest(); + $this->nestingLevel++; + + // Test services + $config = array( + 'NewRequirementsBackend', + ); + Injector::inst()->load($config); + $si = Injector::inst()->get('TestStaticInjections'); + $this->assertInstanceOf('TestStaticInjections', $si); + $this->assertInstanceOf('NewRequirementsBackend', $si->backend); + $this->assertInstanceOf('MyParentClass', Injector::inst()->get('MyParentClass')); + $this->assertInstanceOf('MyChildClass', Injector::inst()->get('MyChildClass')); + + // Test that nested injector values can be overridden + Injector::nest(); + $this->nestingLevel++; + Injector::inst()->unregisterAllObjects(); + $newsi = Injector::inst()->get('TestStaticInjections'); + $newsi->backend = new OriginalRequirementsBackend(); + Injector::inst()->registerService($newsi, 'TestStaticInjections'); + Injector::inst()->registerService(new MyChildClass(), 'MyParentClass'); + + // Check that these overridden values are retrievable + $si = Injector::inst()->get('TestStaticInjections'); + $this->assertInstanceOf('TestStaticInjections', $si); + $this->assertInstanceOf('OriginalRequirementsBackend', $si->backend); + $this->assertInstanceOf('MyParentClass', Injector::inst()->get('MyParentClass')); + $this->assertInstanceOf('MyParentClass', Injector::inst()->get('MyChildClass')); + + // Test that unnesting restores expected behaviour + Injector::unnest(); + $this->nestingLevel--; + $si = Injector::inst()->get('TestStaticInjections'); + $this->assertInstanceOf('TestStaticInjections', $si); + $this->assertInstanceOf('NewRequirementsBackend', $si->backend); + $this->assertInstanceOf('MyParentClass', Injector::inst()->get('MyParentClass')); + $this->assertInstanceOf('MyChildClass', Injector::inst()->get('MyChildClass')); + + // Test reset of cache + Injector::inst()->unregisterAllObjects(); + $si = Injector::inst()->get('TestStaticInjections'); + $this->assertInstanceOf('TestStaticInjections', $si); + $this->assertInstanceOf('NewRequirementsBackend', $si->backend); + $this->assertInstanceOf('MyParentClass', Injector::inst()->get('MyParentClass')); + $this->assertInstanceOf('MyChildClass', Injector::inst()->get('MyChildClass')); + + // Return to nestingLevel 0 + Injector::unnest(); + $this->nestingLevel--; + } }