I have been looking into how to add unit testing coverage to a large, existing codebase written in PHP. Many functions in both static and instantiable classes make a call to a library or instantiate an object in order to obtain connections to memcache and the database. They typically look something like this:
public function 开发者_运维技巧getSomeData() {
$key = "SomeMemcacheKey";
$cache = get_memcache();
$results = $cache->get($key);
if (!$results) {
$database = new DatabaseObject();
$sql = "SELECT * from someDatabase.someTable";
$results = $database->query($sql);
$cache->set($key, $results);
}
return $results;
}
My colleagues and I are currently trying to implement coverage via PHPUnit for a few of the new classes we are writing. I have attempted to find a way to create unit tests in an isolated manner for functions in our existing codebase that resemble the pseudo-code above, but have been unsuccessful.
The examples I've seen in the PHPUnit documentation all rely on having some method in the class by which a mock object can be attached to it, such as:
$objectBeingTested->attach($mockObject);
I looked at SimpleUnit, and saw the same thing there, the mock objects were being passed into the class via its constructor. This doesn't leave much room for functions which instantiate their own database objects.
Is there any way to mock out these sorts of calls? Is there another unit testing framework we can use? Or are we going to have to change the patterns we are using in the future in order to facilitate unit testing?
What I'd like to do is be able to swap out an entire class with a mock class when running tests. For instance, the DatabaseObject class could be replaced with a mock class, and any time it's instantiated during a test, it would actually be an instance of the mock version.
There has been talk in my team of refactoring our methods of accessing the database and memcache in new code, perhaps using singletons. I suppose that could help if we were to write the singleton in such a way that its own instance of itself could be replaced with a mock object...
This is my first foray into unit testing. If I'm doing it wrong, please say so. :)
Thanks.
Just to add on to @Ezku answer (+1, all what i would have said too) to final code could look something like this (using Dependency injection)
public function __construct(Memcached $mem, DatabaseObject $db) {
$this->mem = $mem;
$this->db = $db;
}
public function getSomeData() {
$key = "SomeMemcacheKey";
$cache = $this->mem;
$results = $cache->get($key);
if (!$results) {
$database = $this->db;
$sql = "SELECT * from someDatabase.someTable";
$results = $database->query($sql);
$cache->set($key, $results);
}
return $results;
}
With that it is really easy to create the mock objects and pass them into the code.
There are several reasons why you could want to do this (apart from creating testable code). For once it makes your code much more open to change (want different db? pass in a different db object instead of changeing the code in your DatabaseObject.
This Blog post tells you about why static methods are bad but using the "new" operator in your code is pretty much the same thing than saying $x = StaticStuff::getObject();
so it applies here too.
Another reference can be: Why singletons are bad for testable code because it touches on the same points.
If you already have some more code written there are some ways to work those idea in without changeing everything at once.
Optional dependency injection like this:
public function __construct(Memcached $mem = null, DatabaseObject $db = null) {
if($mem === null) { $mem = new DefaultCacheStuff(); }
if($db === null) { $db = new DefaultDbStuff(); }
$this->mem = $mem;
$this->db = $db;
}
public function getSomeData() {
$key = "SomeMemcacheKey";
$cache = $this->mem;
$results = $cache->get($key);
if (!$results) {
$database = $this->db;
$sql = "SELECT * from someDatabase.someTable";
$results = $database->query($sql);
$cache->set($key, $results);
}
return $results;
}
or using "setter injection":
public function __construct(Memcached $mem = null, DatabaseObject $db = null) {
$this->mem = new DefaultCacheStuff();
$this->db = new DefaultDbStuff();
}
public function setDatabaseObject(DatabaseObject $db) {
$this->db = $db;
}
public function setDatabaseObject(Memcached $mem) {
$this->mem = $mem;
}
public function getSomeData() {
$key = "SomeMemcacheKey";
$cache = $this->mem;
$results = $cache->get($key);
if (!$results) {
$database = $this->db;
$sql = "SELECT * from someDatabase.someTable";
$results = $database->query($sql);
$cache->set($key, $results);
}
return $results;
}
Additional there are things called dependency injection containers
that allow you to put all your objection creating away and pull everything out of that container, but since it makes testing a bit harder (imho) and it only helps you if done really well i wouldn't suggest starting with one but just using normal "dependency injection" to create testable code.
This doesn't leave much room for functions which instantiate their own database objects.
Precisely so. You're describing a style of programming that is considered one to avoid precisely because it leads into untestable code. If your code explicitly depends on some externalities and does not in any way abstract over them, you're only going to be able to test that code with those externalities intact. As you say, you can't mock things that functions create for themselves.
To make your code testable, it's preferable to apply dependency injection: pass the dependencies you wish to be mockable into the unit's context from the outside. This is usually seen as resulting in better class design in the first place.
That said, there are some things you can do to enable mockability without explicit injection: using PHPUnit's mock object facilities, you can override methods even in the unit under test. Consider a refactoring like this.
public function getSomeData() {
$key = "SomeMemcacheKey";
$cache = $this->getMemcache();
$results = $cache->get($key);
if (!$results) {
$database = $this->getDatabaseObject();
$sql = "SELECT * from someDatabase.someTable";
$results = $database->query($sql);
$cache->set($key, $results);
}
return $results;
}
public function getMemcache() {
return get_memcache();
}
public function getDatabaseObject() {
return new DatabaseObject();
}
Now, if you're testing getSomeData(), you can mock out getMemcache() and getDatabaseObject(). The next refactoring step would be to inject the memcache and database objects into the class so that it would have no explicit dependencies on get_memcache() or the DatabaseObject class. This would obviate the need for mocking methods in the unit under test itself.
In a perfect world, you'd have the time to refactor all your legacy code to use dependency injection or something similar. But in the real world, you often have to deal the hand you've been dealt.
Sebastian Bergmann, the author of PHPUnit, wrote a test helpers extension that allows you to override the new operator with a callback and rename functions. These will allow you to monkey patch your code during testing until you can refactor it to be more testable. Granted, the more tests you write using this, the more work you'll have undoing it.
Note: the Test-Helper extension is superseded by https://github.com/krakjoe/uopz
I would suggest a very simple dependency injector. They can be very very easy to use for new functions inside legacy code. Also you can easy refactore such code as you posted.
I suggest a simple one like I recently developed for a similar occasion: https://packagist.org/packages/tflori/dependency-injector
In some bootstrap file or configuration file you write something like this:
<?php
DI::set('database', function() { return new DatabaseObject(); });
DI::set('memcache', function() { return get_memcache(); });
And then your function can look like this:
<?php
function getSomeData() {
$key = "SomeMemcacheKey";
$cache = DI::get('memcache');
$results = $cache->get($key);
if (!$results) {
$database = DI::get('database');
$sql = "SELECT * from someDatabase.someTable";
$results = $database->query($sql);
$cache->set($key, $results);
}
return $results;
}
To test the code you can write a testClass like this:
<?php
use PHPUnit\Framework\TestCase;
class GetSomeDataTest extends TestCase {
public function tearDown() {
Mockery::close();
parent::tearDown();
}
public function testReturnsCached() {
$mock = Mockery::mock('memcache_class');
$mock->shouldReceive('get')->once()->with('SomeMemcacheKey')->andReturn('anyResult');
DI::set('memcache', $mock);
$result = getSomeData();
$this->assertSame('anyResult', $result);
}
public function testQueriesDatabase() {
$memcache = Mockery::mock('memcache_class');
$memcache->shouldReceive('get')->andReturn(null);
$memcache->shouldIgnoreMissing();
DI::set('memcache', $memcache);
$database = Mockery::mock(DatabaseObject::class);
$database->shouldReceive('query')->once()->andReturn('fooBar');
DI::set('database', $database);
$result = getSomeData();
$this->assertSame('fooBar', $result);
}
}
精彩评论