Is object injection really necessary?
To be fully testable in isolation, code should avoid to instantiate classes directly, and inject any object that is needed inside objects.
However, there are at least two exceptions to this rule:
- The class is a language class, e.g.
ArrayObject
- The class is a proper "value object".
No need to force injection for core objects
The first case is easy to explain: it is something embedded in the language so you just assume it works. Otherwise you should also tests any PHP core functions or statement like return
…
No need to force injection for value object
The second case, is related to the nature of a value object. In facts:
- it is immutable
- it has no any possible polymorphic alternative
- by definition, a value object instance is indistinguishable from another that has same constructor arguments
It means that a value object can be seen as an immutable type on its own, just like, for example, a string.
If some code does $myEmail = \'[email protected]\'
no one is concerned about mocking that string, and in same way, no one should be concerned about mocking a line like new Email(\'[email protected]\')
(assuming that Email
is immutable and possibly final
).
For what I can guess from your code, My_Admin_Notice_Action
is a good candidate to be / become a value object. But, can\'t be sure without seeing the code.
I can\'t say the same of My_Notice
, but that\'s just another guess.
If inject is necessary…
In case the class that is instantiated in another class is not one of the two cases above, that is surely better to inject it.
However, like example in OP, the class to be constructed needs arguments that depends on context.
There\'s no "one" answer in this case, but different approaches that may be all valid depending on the case.
Instantiation in client code
A simple approach is to separate "objects code" from "client code". Where client code is the code that makes use of objects.
In this way you could test objects code using unit tests, and leave client code testing to functional / integration tests, where you don\'t have to worry about isoltation.
In your case it will be something like:
add_action( \'activate_plugin\', function( $plugin, $network_wide ) {
$message = My_Admin_Notices_Message( \'plugin\', \'activated\' );
if ( $message->has_text() ) {
$notice = new My_Notice( $plugin, $message, \'wpml-st-string-scan\' );
$notice->add_action( new My_Admin_Notice_Action( \'Scan now\', \'#\' ) );
$notice->add_action( new My_Admin_Notice_Action( \'Skip\', \'#\', true ) );
$notices = new My_Notices();
$notices->add_notice( $notice );
$handler = new My_Admin_Notices_Handler( $notices );
$handler->handle_notices();
}
}, 10, 2);
I made some guesses on your code and write methods and classes that may not exists (like My_Admin_Notices_Message
), but the point here is that the closure above contains all client code needed to instantiate and "use" the objects. You can then test your objects in isolation because no one of those objects needs to instantiate other objects, but they all receive necessary instances in constructor or as methods params.
Simplify client code with factories
The approach above may work well for small plugins (or small part of a plugin that can be isolated from the rest of it), but for bigger code bases, using only that approach you may end in big client code in closures which, among other things, is very hard to test and maintain.
In those cases, factories may help you. Factories are objects with the sole scope of creting other objects. Most of the time is good to have specific factories for object of the same type (implementing same interface).
With factories, the code above might look like this:
add_action( \'activate_plugin\', function( $plugin, $network_wide ) {
$notice = $notice_factory->create_for( \'plugin\', \'activated\' );
if ( $notice instanceof My_Notice_Interface ) {
$handler_factory = new My_Admin_Notices_Handler_Factory();
$handler = $handler_factory->build_for_notices( [ $notice ] );
$handler->handle_notices();
}
}, 10, 2);
All the instantiation code is in factories. You can still test factories in isolation, because you need to test that given proper arguments they produce expected classes (or that given wrong arguments they produce expected errors).
And still you can test all the other objects in isolation because no objects needs to create instances, in facts instantiation code is all in factories.
Of course, remember that value objects don\'t need factories... it would be like create factories for strings...
What if I can\'t change the code?
Stubs
Sometimes it is not possible to change the code that instantiates other objects, for different reasons. E.g. code is 3rd party, backward compatiblity, and so on.
In those cases, if is possible to run the tests without loading the classes being instantiated, then you can write some stubs.
Let\'s assume you have a code that does:
class Foo {
public function run_something() {
$something = new Something();
$something->run();
}
}
If you are able to run tests without loading the Something
class, you can write a custom Something
class just for the purpose of testing (a "stub").
It is always better to keep stubs very simple, e.g.:
class Something{
public function run() {
return \'I ran\'.
}
}
When tests run, you can then load the file that contains this stub for Something
class (e.g. from tests setUp()
) and when the class under tests will instantiate a new Something
, you will tests it in isolation, since the setup is very simple and you can create it in a way that by design it does what you expect.
Of course this is not very simple to maintain, but considering that normally you don\'t unit tests 3rd party code, rarely you need to do this.
Sometimes, though, this is helpful for testing in isolation plugins / themes code that instantiate WordPress objects (e.g. WP_Post
).
Mockery overload
Using Mockery
(a library to that provides tools for PHP unit tests) you can even avoid to write those stubs. With Mockery "instance mock" (aka "overload") is possible to intercept new instances creation and replace with a mock. This article explains pretty well how to do it.
When class is loaded...
If the code to test has hard dependencies (instantiate classes using new
) and there\'s no possibility to load tests without loading the class that is going to be instantiated there\'re very small chances to test it in isolation without touching the code (or writing an abstraction layer around it).
However, note that test bootstrap file is often loaded as absolute first thing. So there are, at least, two cases in you can force the loading of your stubs:
Code uses an autoloader. In this case, if you load the stubs before loading the autoloader, then the "real" class is never loaded, because when new
is used, the class is already found and autoloader not triggered.
Code checks for class_exists
before defining / loading the class. In that case to load the stubs as first thing, will prevent the "real" class to be loaded.
The tricky last resort
When everything else fails, there\'s another thing you could do to test hard dependencies.
Very often hard dependencies are stored as private variables.
class Foo {
public function __construct() {
$this->something = new Something();
}
public function run_something() {
return $this->something->run();
}
}
in cases like this, you could replace the hard dependencies with a mock / stub after the instance is created.
This because even private
properties can be (quite) easily replaced in PHP. In more than one way, actually.
Without digging into details, I can say that closure binding can be used to do that. I even wrote a library called "Andrew" that can be used for the scope.
Using Andrew (and Mockery) to test the class Foo
above, you can do:
public function test_run_something() {
$foo = new Foo();
$something_mock = Mockery::mock( \'Something\' );
$something_mock
->shouldReceive(\'run\')
->once()
->withNoArgs()
->andReturn(\'I ran.\');
// changing proxy properties will change private properties of proxied object
$proxy = new Andrew\\Proxy( $foo );
$proxy->something = $something_mock;
$this->assertSame( \'I ran.\', $foo->run_something() );
}