Introduction
Let’s face it! In some situations, no matter how hard you try to mock everything, you have to unit test the file system in order to ensure that your application works correctly.
Testing directly the file system is a pain in the ass due to the many possible things that can go wrong. Remember: unit tests must be performed in isolation, therefore we need to make sure that each test case doesn’t affect the outcome of the others.
The old-fashioned way to avoid possible pitfalls relies on the setUp
and tearDown
methods, to clear the stage between each test:
<?php
public function setUp()
{
if (file_exists(dirname(__FILE__) . '/test_folder') === true) {
rmdir(dirname(__FILE__) . '/test_folder');
}
}
public function tearDown()
{
if (file_exists(dirname(__FILE__) . '/test_folder') === true) {
rmdir(dirname(__FILE__) . '/test_folder');
}
}
?>
However if our test dies before the tearDown
method is called the directory will stay in the file system. In this case it may not be a big deal, but most of the times if we don’t clean up the stage we could possibly end with inconsistent tests.
Luckily there’s a simple solution: abstract the file system in the same way as we abstract the database.
vfsStream is a stream wrapper for a virtual file system that may be helpful in unit tests to mock the real file system. It can be used with any unit test framework, like SimpleTest or PHPUnit. We will use the latter, so I’m assuming you already have a basic knowledge of the tool.
Build a CSV Handler
For this example I’m going to build a simple class responsible for listing, reading, writing and deleting CSV files. Next we will use vfsStream to test it.
<?php
class CSVHandler
{
protected $pattern = '*.csv';
public function __construct() {}
}
?>
We are using the '*.csv'
pattern to ensure that our class doesn’t affect different type of files, except of CSVs.
The function to list the files is the following:
<?php
public function list($path)
{
$files = scandir($path);
$found = array();
foreach ($files as $filename) {
if (fnmatch($this->pattern, $filename)) {
$found[] = $path . '/' . $filename;
}
}
return $found;
}
?>
Unfortunately due to an annoying issue with vfsStream we cannot use the PHP glob function, therefore we have to scan the folder and later filter the files accordingly to our pattern.
The function, which is not recursive, returns an array that contains the paths of our CSV files.
Now it’s time for the function that reads a CSV file and returns its content in a multidimensional array:
<?php
public function read($path, $stripFirstRow = false)
{
$results = [];
if (($handle = fopen($path, "r")) !== FALSE)
{
while (($data = fgetcsv($handle, 0, $delimiter)) !== FALSE) {
$results[] = $data;
}
fclose($handle);
if($stripFirstRow) array_shift($results);
}
return $results;
}
?>
This function takes in input a CSV file path and a boolean that, if set to true, forces the function to get rid of the first line of the CSV which in some cases may contain the header of the fields.
Now we have to code the function that writes a CSV file from a multidimensional array. We have to make sure that the destination directory exists and has 0755
permissions before proceeding.
<?php
public function write(array $input, $path, $filename)
{
if(is_dir($path)) {
chmod($path, 0755);
} else {
mkdir($path, 0755, true);
}
if (($handle = fopen($path . DIRECTORY_SEPARATOR . $filename, "w")) !== FALSE) {
foreach ($input as $row) {
fputcsv($handle, $row);
}
fclose($handle);
return true;
}
return false;
}
?>
Finally the function that deletes a CSV file is the following:
<?php
public function deleteFile($path, $filename)
{
if(file_exists($path . DIRECTORY_SEPARATOR . $filename) && fnmatch($this->pattern, $filename)) {
return unlink($path . DIRECTORY_SEPARATOR . $filename);
}
return false;
}
?>
We check that the file exists and matches our pattern before deleting it.
Let’s test!
First we have to install vfsStrem. If you have Composer it’s just a matter of typing the following
composer require-dev mikey179/vfsStream
otherwise check the official documentation for installing it with PEAR.
Now we can create our CSVHandlerTest
class and start writing some tests.
<?php
use org\bovigo\vfs\vfsStream;
class CSVHandler extends PHPUnit_Framework_TestCase
{
}
?>
Our first test will be used as an introduction on how to setup a valid file system using vfsStream. The easiest and more intuitive way is explained in the section “Create complex directory structures” in the docs.
We want to create a simple directory structure that matches the following:
root/
\- csv/
|- input.csv
This is the code for the purpose:
<?php
$structure = [
'csv' => [
'input.csv' => "first1,first2,first3\nsecond1,second2,second3\nthird1,third2,third3"
]
];
$root = vfsStream::setup('root',null,$structure);
?>
Pretty straightforward, as the documentation points out:
“Arrays will become directories with their key as directory name, and strings becomes files with their key as file name and their value as file content. Mind that defining a value for the files is mandatory or the file will not be created.”
In this case we are creating a single CSV file that contains 3 rows, each one with 3 elements.
|first1|first2|first3|
|second1|second2|second3|
|third1|third2|third3|
If you feel confident enough you can extract the previous code in a separate private method to avoid repetition throughout our tests.
Now we can write our initial test to check if our virtual file system contains the path 'csv/input.csv'
:
<?php
/** @test */
public function it_should_set_up_a_valid_filesystem()
{
$structure = [
'csv' => [
'input.csv' => "first1,first2,first3\nsecond1,second2,second3\nthird1,third2,third3"
]
];
$root = vfsStream::setup('root',null,$structure);
$this->assertTrue($root->hasChild('csv/input.csv'));
}
?>
If everything is correct our first test should pass with green!
Notice: we are using the “Arrange-Act-Assert” pattern which separates what is being tested from the setup and verification steps.
Now we can actually start to write the tests for our functions. First we want to test the list
function, to ensure that it returns the correct number of files in the form of an array:
<?php
/** @test */
public function it_should_list_one_csv_file()
{
// Arrange: build the file system as in the previous test
$root = $this->buildValidFileSystem();
// Act
$csvHandler = new CSVHandler();
$result = $csvHandler->list($root->url() . '/csv');
// Assert
$this->assertCount(1, $result);
}
?>
The PHPUnit assertCount(1, $result)
method is a shortcode for assertEquals(1, count($result))
.
Now it’s time to test the read
function. We are not really interested in the content of the file, instead we just want to check if the returned array contains 3 arrays, each one containing 3 elements. It couldn’t be more simple than this:
<?php
/** @test */
public function it_should_read_a_valid_csv_file()
{
$root = $this->buildValidFileSystem();
$csvHandler = new CSVHandler();
$result = $csvHandler->read($root->url() . '/csv/input.csv');
$this->assertCount(3, $result);
$this->assertCount(3, $result[0]);
$this->assertCount(3, $result[1]);
$this->assertCount(3, $result[2]);
}
?>
And what about the situation in which the CSV file doesn’t exists? How can we test this scenario?
Luckily for us PHPUnit got us covered. We can say to our test that we expect a particular exception (fopen
will throw an ErrorException
if the file doesn’t exists) and if it is thrown the test will pass.
<?php
/** @test */
public function it_should_throw_an_exception_if_the_csv_file_does_not_exists()
{
$this->setExpectedException('ErrorException');
// an empty array means an empty folder
$structure = [
'csv' => []
];
$root = vfsStream::setup('root',null,$structure);
$csvHandler = new CSVHandler();
$csvHandler->read($root->url() . '/csv/input.csv');
}
?>
Now we can test the write
function, which takes a multidimensional array and writes a valid CSV file on disk:
<?php
/** @test */
public function it_should_write_an_array_to_a_valid_csv_file()
{
$structure = [
'csv' => []
];
$root = vfsStream::setup('root',null,$structure);
$input = [
['first1','first2','first3','first4'],
['second1','second2','second3','second4'],
['third1','third2','third3','third4']
];
$csvHandler = new CSVHandler();
$result = $csvHandler->write($input, $root->url() . '/csv', 'input.csv');
$this->assertEquals(0755, $root->getChild('csv')->getPermissions());
$this->assertTrue($root->hasChild('csv/input.csv'));
$this->assertEquals(
"first1,first2,first3,first4\nsecond1,second2,second3,second4\nthird1,third2,third3,third4\n",
$root->getChild('csv/input.csv')->getContent()
);
}
?>
This test is a little more complex, but still easy to understand. We first create the CSV file on the file system, then we verify that the folder has the right permissions and the CSV file exists. Finally we check that the content of the file matches our expectations.
The last element to test is the delete
function, but it couldn’t be easier:
<?php
/** @test */
public function it_should_delete_an_existing_file()
{
$root = $this->buildValidFileSystem();
$csvHandler = new CSVHandler();
$result = $csvHandler->deleteFile($root->url() . '/csv', 'input.csv');
$this->assertTrue($result);
$this->assertFalse($root->hasChild('csv/input.csv'));
}
?>
That’s it! I really hope you got more confident on how to write simple tests for the file system. I suggest you to check out the vfsStream library because it offers a lot more and it really speeds up and simplifies the testing process.