By Manuel Pichler, first published at Tue, 02 May 2017 09:45:01 +0200
Download our free e-book "Crafting Quality Software" with a selection of the finest blog posts as PDF or EPub.
You can also buy a printed version of the book on Amazon or on epubli.
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.
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.
If you want help finding the correct testing strategy for you and get a kickstart in testing – book us for an on-site workshop.
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.
Stay up to date with regular new technological insights by subscribing to our newsletter. We will send you articles to improve your developments skills.
Philipp Rieber on Sat, 20 May 2017 22:25:46 +0200
Hi Manuel, thanks for this article, very good read.
Link to commentQuestion: How do you handle the side-effect that "overwriting" an internal PHP function also affects other test classes in the same namespace? For example, when overwriting the "time()" function in "Foo\BarTest", this definition is also valid (and not possible to further overwrite) in "Foo\BazTest". Any suggestions?
Greg Bell on Fri, 15 Sep 2017 12:08:20 +0200
Great tip, this just saved my butt! Thanks so much. I'm basically just jacking something into the namespace of the framework I'm using, and overriding the built-in is_uploaded_file() function.
Link to comment