Testing the Untestable
First published at Tuesday 2 May 2017
Warning: This blog post is more then 7 years old – read and use with care.
Testing the Untestable
A long time ago I wrote a blog post about Testing file uploads with PHP where I have used a CGI PHP binary and the PHP Testing Framework (short PHPT), which is still used to test PHP itself and PHP extensions.
Since the whole topic appears to be still up-to-date, I would like to show a different approach how to test a fileupload in PHP in this post. This time we will use PHP's namespaces instead of a special PHP version to test code that utilizes internal functions like is_uploaded_file()
or move_uploaded_file()
. So let's start with some code under test example source:
namespace Qafoo\Blog;
class UploadExample
{
protected $target;
public function __construct(string $target)
{
$this->target = rtrim($target, '/') . '/';
}
public function handle(string $name): void
{
if (false === is_uploaded_file($_FILES[$name]['tmp_name'])) {
throw new FileNotFoundException();
}
$moved = move_uploaded_file(
$_FILES[$name]['tmp_name'],
$this->target . $_FILES[$name]['name']
);
if (false === $moved) {
throw new FileNotMovedException();
}
}
}
Even if we can mockout the magic $_FILES
super global variable that we use here::
public function handle(array $files, string $name): void
{
if (false === is_uploaded_file($files[$name]['tmp_name'])) {
throw new FileNotFoundException();
}
$moved = move_uploaded_file(
$files[$name]['tmp_name'],
$this->target . $files[$name]['name']
);
if (false === $moved) {
throw new FileNotMovedException();
}
}
we use the internal functions is_uploaded_file()
and move_uploaded_file()
, which work one some internal request data structure that we cannot access nor modify. Despite this internal handling we still can at least test the negative path:
namespace Qafoo\Blog;
use PHPUnit\Framework\TestCase;
class UploadExampleTest extends TestCase
{
/**
* @expectedException \Qafoo\Blog\FileNotFoundException
*/
public function testHandleThrowsFileNotFound(): void
{
$files = [
'file_invalid' => [
'name' => 'foo.txt',
'tmp_name' => '/tmp/php42up23',
'type' => 'text/plain',
'size' => 42,
'error' => 0
]
];
$upload = new UploadExample(sys_get_temp_dir());
$upload->handle($files, 'file_invalid');
}
}
But it's impossible to write tests for the happy path of the handle()
method.
So What Can We Do?
We can use a small trick that utilizes namespaces and PHP's lookup behavior for functions to inject/mock our own implementations of the two functions during the tests.
Let's have a look at how PHP resolves functions within namespaced source code. In the following example both calls will invoke the same internal function is_uploaded_file()
, …
namespace Qafoo\Blog {
var_dump(is_uploaded_file('test'));
var_dump(\is_uploaded_file('test'));
}
… while in this example the first call will call our own implementation of is_uploaded_file()
and the second call still invokes the internal function:
namespace Qafoo\Blog {
function is_uploaded_file($name) {
return ('awesome' === $name);
}
var_dump(is_uploaded_file('test'));
var_dump(\is_uploaded_file('test'));
}
This happens because PHP first makes a function lookup in the local namespace for all function calls that don't have a leading \ and only if no local declaration exists it makes a lookup in the global namespace. For us that means we have now found an approach to mock out the internal functions in our test case, because we can overwrite the two upload functions in the namespace:
namespace Qafoo\Blog;
function is_uploaded_file($tmpName): bool
{
return in_array($tmpName, ['/tmp/php42up23', '/tmp/php23up17']);
}
function move_uploaded_file($tmpName, $to): bool
{
return in_array($tmpName, ['/tmp/php42up23']);
}
And our final test case that tests all execution paths will look like:
namespace Qafoo\Blog;
require __DIR__ . '/UploadExample.php';
use PHPUnit\Framework\TestCase;
class UploadExampleTest extends TestCase
{
private $files = [
'valid' => [
'name' => 'foo.txt',
'tmp_name' => '/tmp/php42up23',
],
'invalid' => [
'name' => 'bar.txt',
'tmp_name' => '/tmp/php42up17',
],
'move_fail' => [
'name' => 'baz.txt',
'tmp_name' => '/tmp/php23up17',
],
];
/**
* @expectedException \Qafoo\Blog\FileNotFoundException
*/
public function testHandleThrowsFileNotFound(): void
{
$upload = new UploadExample(sys_get_temp_dir());
$upload->handle($this->files, 'invalid');
}
/**
* @expectedException \Qafoo\Blog\FileNotMovedException
*/
public function testHandleThrowsFileNotMoved(): void
{
$upload = new UploadExample(sys_get_temp_dir());
$upload->handle($this->files, 'move_fail');
}
/**
*
*/
public function testHappyPath(): void
{
$upload = new UploadExample(sys_get_temp_dir());
$upload->handle($this->files, 'valid');
$this->addToAssertionCount(1);
}
}
That's it, now you know how to write fast and reliable test for code that handles file uploads.
But wait, why have I titled this post with "Testing The Untestable"? Because this provides you much much more than just testing file uploads: It gives you a new and powerful testing toolbox. Imagine you are using ext/filter or you are using any of the file functions to access an external service. All this can be mocked out with this technique, like here:
namespace Acme\Services;
class ExternalDataProvider
{
private $apiUrl = 'http://api.example.com/v/2.1/';
public function getItems(): array
{
// …
$data = file_get_contents($this->apiUrl);
// …
}
}
namespace Acme\Services;
use PHPUnit\Framework\TestCase
class ExternalDataProviderTest extends TestCase
{
public function testGetItems(): void
{
// …
}
}
function file_get_contents($path) {
if (preg_match('~^https?://~', $path) {
// Load some fixture here
}
// Call the original here
return \file_get_contents($path);
}
This isn't something new and was already possible in 2010 when I wrote the original post, but I hope this gives you a powerful tool.
Subscribe to updates
There are multiple ways to stay updated with new posts on my blog: