Hello everyone. This is my first post and my starting point to giving back to the developer community. For a long time I wanted to do this, so this is maybe the best moment, by showing a step-by-step build of a library I am developing.
I would like to show everyone how developing works for me, so, at first glance it might look ugly so bare with me. For the next few post I would like to cover issues like unit testing, design principles and refactoring and how do I approach this issues.
Motivation: I need a simple File System handler for a personal project and none of the ones publicly available are not suited for my needs.
So, at first I would “draw” a “sketch” of the library requirement. I would like to load/create a file, write content to it and verify it exists, is readable/writable and the ability to delete it. So, a rudimentary requirement can be caught by the following class:
<?php
class File
{
public function __construct(string $filename)
{
}
public function exists(): bool
{
}
public function isReadable(): bool
{
}
public function isWritable(): bool
{
}
public function write(/* Content */$content): File
{
}
public function delete()
{
}
}
So, we have our sketch, let’s start coding.
All the progress will be available to see on github.
Note: There are many ways in how to plan a project/library:
- you can plan the design intensively before writing any line of code and establish every approach for each of the components
- you can sketch “big picture” and start developing small components (you end up delivering small functionalities quick but later you must refactor).
Both ways are good, you should pick the one that suits you best. I usually use the latter one (If you plan intensively you might end up loosing your enthusiasm and also, by the end you actually deliver something that the initial planned design may not apply anymore).
Let’s start!
First off all, I will create the minimum necessary for the library to be “alive”:
- I will create a composer file where I would add the library description and the requirements for the library. The library will be written exclusively for PHP 7 so the requirement will be that the platform is PHP 7 or greater.
- I will create the src/ and test/.
- I will create the Licence and Readme files
- I will create the phpunit’s schema file (phpunit.xml) – More info can be found here
- Finally, I will create the File.php inside src directory and also FileTest.php file inside test directory with exactly 1 failing test.
After all this I have my “initial commit” version here.
So, let’s see what and why. I will skip the composer.json, phpunit.xml and go directly on to the code.
So what does the “File.php” contains? Actually, kind of nothing. It is just a “Dummy” version of what it should end up to be.
<?php
namespace NeedleProject\FileIo;
class File
{
private $filename = null;
/**
* File constructor.
*
* @param string $filename
*/
public function __construct(string $filename)
{
$this->filename = $filename;
}
/**
* States whether the file actually exists on disk
* @return bool
*/
public function exists(): bool
{
return false;
}
/**
* States whether the file is readable
* @return bool
*/
public function isReadable(): bool
{
return false;
}
/**
* @return bool
*/
public function isWritable(): bool
{
return false;
}
/**
* Write content to the current file
* @param mixed $content
* @return \NeedleProject\FileIo\File
*/
public function write(/* Content */$content): File
{
return $this;
}
/**
* Deletes the current file
*/
public function delete()
{
// do nothing at the moment
}
}
And the TestFile.php:
<?php
namespace NeedleProject\FileIo;
class FileTest extends \PHPUnit_Framework_TestCase
{
/**
* Test ::exists method
* @dataProvider provisionRealFiles
* @param $providedFile
*/
public function testExistsTrue($providedFile)
{
$file = new File($providedFile);
$this->assertTrue($file->exists(), sprintf("%s actually exists on disk!", $providedFile));
}
/**
* Provide real files useful for test scenarios
* @return array
*/
public function provisionRealFiles(): array
{
return [
[__FILE__]
];
}
}
By the look of things, the test will actually fail:
PHPUnit 5.7.5 by Sebastian Bergmann and contributors.
#[...]
FAILURES!
Tests: 1, Assertions: 1, Failures: 1.
We ended up having kind of nothing, so let’s work on having something.
First, we should make test test pass by implementing logic in File::exists() method:
<?php
class File
{
// [...]
/**
* States whether the file actually exists on disk
* @return bool
*/
public function exists(): bool
{
return file_exists($this->filename);
}
// [...]
}
At the moment we are adding to our development debt.
Ok, now we have implemented the method, we run the test and voilà, the test suite passes:
PHPUnit 5.7.5 by Sebastian Bergmann and contributors.
#[...]
OK (1 test, 1 assertion)
So, this is kind of going somewhere. I must confess that I don’t always use TDD, but in this case I will and I really hope I will end up in a point where this development approach will show it’s purpose.
So, following the TDD’s process, we create the tests for the File.
Note: Current tests only work on linux environment due to php’s chmod function. For tests, this is not an issue, but when we will implement file permission handling in the library we should handle this situation cross-platform.
<?php
namespace NeedleProject\FileIo;
class FileTest extends \PHPUnit_Framework_TestCase
{
/**
* @const string Fixture default directory
*/
const FIXTURE_PATH = __DIR__ . DIRECTORY_SEPARATOR . 'fixture' . DIRECTORY_SEPARATOR . 'files' . DIRECTORY_SEPARATOR;
/**
* Test setUp
*/
public function setUp()
{
// create fixture
touch(static::FIXTURE_PATH . 'readable.file');
touch(static::FIXTURE_PATH . 'unreadable.file');
chmod(static::FIXTURE_PATH . 'unreadable.file', 0333);
touch(static::FIXTURE_PATH . 'unwritable.file');
chmod(static::FIXTURE_PATH . 'unwritable.file', 555);
touch(static::FIXTURE_PATH . 'delete.file');
}
/**
* Test tearDown
*/
public function tearDown()
{
unlink(static::FIXTURE_PATH . 'readable.file');
unlink(static::FIXTURE_PATH . 'unreadable.file');
unlink(static::FIXTURE_PATH . 'unwritable.file');
if (file_exists(static::FIXTURE_PATH . 'delete.file')) {
unlink(static::FIXTURE_PATH . 'delete.file');
}
}
/**
* Test ::exists method
* @dataProvider provisionRealFiles
* @param $providedFile
*/
public function testExistsTrue($providedFile)
{
$file = new File($providedFile);
$this->assertTrue($file->exists(), sprintf("%s actually exists on disk!", $providedFile));
}
/**
* Test ::exists method
* @dataProvider provisionFakeFiles
* @param $providedFile
*/
public function testExistsFalse($providedFile)
{
$file = new File($providedFile);
$this->assertFalse($file->exists(), sprintf("%s should not exist!", $providedFile));
}
/**
* @param $providedFile
* @dataProvider provisionReadableFiles
*/
public function testIsReadableTrue($providedFile)
{
$file = new File($providedFile);
$this->assertTrue($file->isReadable(), sprintf("%s should have permissions for read!", $providedFile));
}
/**
* @param $providedFile
* @dataProvider provisionUnreadableFiles
*/
public function testIsReadableFalse($providedFile)
{
$file = new File($providedFile);
$this->assertFalse($file->isReadable(), sprintf("%s should not have permissions for read!", $providedFile));
}
/**
* @param $providedFile
* @dataProvider provisionReadableFiles
*/
public function testIsWritableTrue($providedFile)
{
$file = new File($providedFile);
$this->assertTrue($file->isWritable(), sprintf("%s should be writable!", $providedFile));
}
/**
* @param $providedFile
* @dataProvider provisionUnwritableFiles
*/
public function testIsWritableFalse($providedFile)
{
$file = new File($providedFile);
$this->assertFalse($file->isWritable(), sprintf("%s should not be writable!", $providedFile));
}
/**
* @param $providedFile
* @dataProvider providionFilesToDelete
*/
public function testDelete($providedFile)
{
$file = new File($providedFile);
$this->assertTrue($file->exists(), sprintf("%s should exists!", $providedFile));
$file->delete();
$this->assertFalse($file->exists(), sprintf("%s should be deleted!", $providedFile));
}
/**
* Provide real files useful for test scenarios
* @return array
*/
public function provisionRealFiles(): array
{
return [
[__FILE__]
];
}
/**
* Provide fake files useful for test scenarios
* @return array
*/
public function provisionFakeFiles(): array
{
return [
[__DIR__ . DIRECTORY_SEPARATOR . 'foo.bar']
];
}
/**
* Provide real readable file
* @return array
*/
public function provisionReadableFiles(): array
{
return [
[__FILE__]
];
}
/**
* Provide unreadable file
* @return array
*/
public function provisionUnreadableFiles(): array
{
return [
[static::FIXTURE_PATH . 'unreadable.file']
];
}
/**
* Provide file that cannot be written
* @return array
*/
public function provisionUnwritableFiles(): array
{
return [
[static::FIXTURE_PATH . 'unwritable.file']
];
}
public function providionFilesToDelete(): array
{
return [
[static::FIXTURE_PATH . 'delete.file']
];
}
}
I left out ::write intentionally. I would get back to it later.
So, by running this test, the output should be sad:
PHPUnit 5.7.5 by Sebastian Bergmann and contributors.
#[...]
FAILURES!
Tests: 7, Assertions: 7, Failures: 4.
So let’s start giving correct responses for the tests:
<?php
namespace NeedleProject\FileIo;
class File
{
private $filename = null;
/**
* File constructor.
*
* @param string $filename
*/
public function __construct(string $filename)
{
$this->filename = $filename;
}
/**
* States whether the file actually exists on disk
* @return bool
*/
public function exists(): bool
{
return file_exists($this->filename);
}
/**
* States whether the file is readable
* @return bool
*/
public function isReadable(): bool
{
return is_readable($this->filename);
}
/**
* @return bool
*/
public function isWritable(): bool
{
return is_writable($this->filename);
}
/**
* Write content to the current file
* @param mixed $content
* @return \NeedleProject\FileIo\File
*/
public function write(/* Content */$content): File
{
return $this;
}
/**
* Deletes the current file
*/
public function delete()
{
unlink($this->filename);
}
}
Now, after running the tests, the result show me that the implementation handles the given scenarios (that does not means that the test actually covers desired functionality. I will later show you why).
So, we moved from nothing to something but still not enough. So let’s give the “File” the possibility to write some content.
First I will create a interface named ContentInterface in a new directory called Content. I’m doing so because I will expect to have more than one content type and also because it organizes the library.
Info: Naming conventions
In the OOP world there is a naming convention that states that all interfaces should be suffixed with the “Interface” keyword and all abstract classes should be prefixed with the “Abstract” keyword.
<?php
namespace NeedleProject\FileIo\Content;
interface ContentInterface
{
/**
* Returns the content in one string
* @return string
*/
public function get(): string;
}
After this, we can create an implementation to the interface called simple Content:
<?php
namespace NeedleProject\FileIo\Content;
class Content implements ContentInterface
{
/**
* @var null|string
*/
private $content = null;
/**
* Content constructor.
*
* @param string $content
*/
public function __construct(string $content)
{
$this->content = $content;
}
/**
* @return string
*/
public function get(): string
{
return $this->content;
}
}
So, we didn’t started with a test, we started directly with the implementation. This is not a bad thing, there are times when the implementation is so small that you actually do TAD (Test after development) but never exclude test even for this kind of implementations.
So let’s create the test:
<?php
namespace NeedleProject\FileIo\Content;
class ContentTest extends \PHPUnit_Framework_TestCase
{
/**
* @dataProvider contentProvider
* @param $providedContent
*/
public function testGet($providedContent)
{
$content = new Content($providedContent);
$this->assertEquals(
$providedContent,
$content->get(),
sprintf("Provided content does not equals to the one returned!")
);
}
/**
* @return array
*/
public function contentProvider(): array
{
return [
['foo'],
['bar'],
[1],
[0xFF]
];
}
}
As you can see, we provide with also “stupid” content like an integer. Well, PHP cast it to string, and, for me, it works. We do not provide with data like arrays or objects because it will throw a TypeError because of the type hint on the Content constructor.
So, we now have a Content that we can write using the File. Now let’s create the test for the ::write method inside the File class:
<?php
namespace NeedleProject\FileIo;
use NeedleProject\FileIo\Content\Content;
class FileTest extends \PHPUnit_Framework_TestCase
{
// [...]
/**
* @param $providedContent
* @dataProvider provideContent
*/
public function testWrite($providedContent)
{
$filename = static::FIXTURE_PATH . 'file_with_content';
touch($filename);
$file = new File($filename);
$file->write($providedContent);
$content = file_get_contents($filename);
$this->assertEquals($providedContent->get(), $content, "Content written is not equal to the one expected!");
unlink($filename);
}
// [...]
/**
* Provide Content
* @return array
*/
public function provideContent(): array
{
return [
[new Content('foo')],
[new Content('bar')]
];
}
}
The test is done (It’s kind of ugly, we have made our first “mess” but will get back to that later) but it fails because the “File” doesn’t contain any implementation yet.
We move on to implementing in File.
<?php
namespace NeedleProject\FileIo;
use NeedleProject\FileIo\Content\ContentInterface;
class File
{
// [...]
/**
* Write content to the current file
* @param ContentInterface $content
* @return \NeedleProject\FileIo\File
*/
public function write(ContentInterface $content): File
{
file_put_contents($this->filename, $content->get());
return $this;
}
// [...]
}
Good to know
It is a good practice do design using a “contract” (establish a specification on what we expect). We do so by telling the client we expect a specific kind of object (in this case, an object that implements ContentInterface). We do not couple ::write method to a concrete class (like Content class) instead we couple it to an interface leaving the possibility to handle any type of Content (ex: YamlContent, JsonContent, ImageContent) as long as it is an implementation of the ContentInterface. For more information read about “Dependency inversion principle“.
So, we kind of got somewhere now. The new additions can be viewed here.
We now can actually say that we finished what we have planned. That does not means that the library is fully developed.
To conclude:
We have a library that can tell us that a file exists, is writable and readable and can write content to it. We have developed TDD and applied DbC and Dependency Inversion Principle.
But the library is far from a version that can be released publicly.
So let’s see what we can cover next:
- Improve test scenarios, fix our current “mess”
- Read content from a file
- Handle actions more accurate – what does delete tells us at the moment?