Getting Started With PHPUnit
What's that? You don't have an automated testing suite? Shame on you!
Having an automated testing suite is incredibly important. It helps you check if a new deployment is going to break your application or not and helps you check if new versions of infrastructure will break things as well (for example, upgrading PHP).
Without one, you have to do these checks yourself and hope that you covered all the bases. This is probably going to take quite a bit of time as well.
There are many different testing strategies and many different testing frameworks out there but in this post I am go over the very basics of PHPUnit.
PHPUnit is a popular PHP testing framework that is focuses around functional testing. This a type of black-box testing that involves testing the outputs of specific functions in the code. The more functions you have tested, the higher the code coverage and the more confidence you can have in your deployments.
Step 1 - Install PHPUnit
To run PHPUnit tests, you need to have it installed on the machine that you want to test from. This is very easy:
First, get the phar file -
wget https://phar.phpunit.de/phpunit.phar
Next, set the permission to make the file executable -
chmod +x phpunit.phar
Finally, move it to your
bin
folder so you can run it from anywhere -sudo mv phpunit.phar /usr/local/bin/phpunit
That's it! Now confirm it has worked by running phpunit --version
to see the version that you have.
Step 2 - Writing Your First Test
PHPUnit has the ability to do the most complex of tests. You can find all of the documentation here. However I going to go through a basic example to get you started.
I want to build a basic class that simple mathematical functions in it. I will call it SimpleCalc
. One function that I want to make in that class will be called add10Percent
, which will take an integer parameter and return the value with 10% added to it.
Writing the test first before building the test is the main principle of "Test Driven Development" (TDD). The benefit of doing it this way around is that it really makes you think about what you want your code to do. Also, doing the tests first means that you are definitely going to have a test - if you leave it until after the task, you are less likely to write the test and would much prefer to move on to the next task.
Configurations
Shall we begin?
Let's start with some basic configuration. The basic way of doing these tests is to have two folders: src
and test
. src
holds all of your classes and should be making use of namespaces. tests
duplicates the folder structure inside of src
exactly the same, but has tests for classes.
We also need to make a bootstrap. This will help with automatically loading the classes that we use inside of the tests. The concepts of autoloading would need a blog post to itself, so we won't go into that.
I am going to use Symfony's Class Loader to make things easier for me. I installed this with composer and so my bootstrap file looks like this:
<?php
require_once 'vendor/symfony/class-loader/Psr4ClassLoader.php';
require_once 'vendor/autoload.php';
use Symfony\Component\ClassLoader\Psr4ClassLoader;
$loader = new Psr4ClassLoader();
$loader->addPrefix('SimpleCalc', dirname(__FILE__).'/src');
$loader->register();
This is saved as bootstrap.php
and is in the root. This is pretty boiler plate stuff, but the key take-away is that everything inside of the src
folder should have a namespace that starts with SimpleCalc
. This means, the test suite will know where to look when I use
one these classes in a test.
Next, we will put together a configuration file called phpunit.xml
in the root. This holds all of the settings for the test suite. For this example, it is pretty basic:
<phpunit bootstrap="bootstrap.php">
<testsuites>
<testsuite name="main">
<directory>test</directory>
</testsuite>
</testsuites>
</phpunit>
The two points of interest are the bootstrap
setting which points to the file we made that autoloads all of the classes that we use
; and the defining of a test suite and the location of the tests. You can probably see that you can have more than one testsuite
to organise your tests.
When you run phpunit
, it will automatically search for this configuration file in the directory that you run the command. If you put in a different location, or call it something other that phpunit.xml
or phpunit.xml.dist
, then you can tell PHPUnit where to find it using the --configuration
option. It is also entirely possible to run the tests without a configuration file and just use the options when running the command - but we won't go into that here.
Now, lets write some tests!
The First Test
So as I said earlier, I am going to write the test before any code. Inside the tests
folder, I create a new class called SimpleCalcTest.php
.
The general convention is that PHPUnit will run anything that ends in "Test.php", and you prepend each class with the name of the actual class that you are testing, in this case "SimpleCalc".
Inside, we set the class up:
<?php
use PHPUnit\Framework\TestCase;
use SimpleCalc\SimpleCalc;
class SimpleCalcTest extends TestCase
{
}
Each test uses and extends TestCase
which has all of the code for running the tests. You can see here I have also added use
for the class that does not yet exist. That is intentional for TDD. The idea is to write the code as if it is there so you can see how you intend to use it.
The function we want to make will be called add10Percent
and so the test testing this should be called testAdd10Percent
. Again, PHPUnit will automatically find and run functions that start with "test".
public function testAdd10Percent($input, $expected)
{
$calc = new SimpleCalc;
$output = $calc->add10Percent($input);
$this->assertEquals($expected, $output);
}
So our test takes two parameters: an input and an expected output. Inside the test, we instantiate a class and call the function add10Percent
and store the output. This is how we expect to use this class and function and so even though we haven't coded them yet, we make it clear in our mind how it should be.
The assertEquals
is one of many assert
functions given to us by PHPUnit and the most basic. It takes the expected output and the actual output and returns true if they are equal or false. This way, we can see if the function runs as expected or not.
Now we have to give some data to this test.
A data provider gives an array of data that will be used for the test, like so:
public function add10PercentProvider()
{
return [
[
100,
110
],
[
0,
0
],
[
1,
1.1,
10
],
[
1.1,
1.21
],
[
-20,
-18
],
];
}
Each item of the array is another array of items that correspond to the parameters of the test that the data provider is attached to.
In this case, we have just two for each one: one input and one expected output.
There is no right or wrong way for choosing your test data. The idea of tests, is to give confidence that the code you are writing works and so you will want to pick data that pushes your code to its limits. I tend to start with boundary values to begin with as these would be the cases that would likely fail if I didn't write a test first. I add one or two easy cases on top of that just so that I know I am on the right lines.
To attach the data provider to the test that we made, we add an annotation above the test:
/**
* @dataProvider add10PercentProvider
*/
public function testAdd10Percent($input, $expected)
{
Now we can run the tests and see what happens. In the console, simply run phpunit
in the root directory.
As you can see, it ran 5 tests (1 for each of the data arrays we provided) and had an error for each one. This is because we haven't created the class yet. But that is the idea, write the test, see it fail, then code until it passes.
So inside of the src
folder. I create a new class, SimpleCalc.php
and give it the function that I expect:
<?php
namespace SimpleCalc;
class SimpleCalc
{
public function add10Percent($value)
{
}
}
If I run the test again, I should see that there are no Errors
, but 5 Failures
, i.e. the tests compiled and ran correctly, but failed.
As you can see, there are 4 failures. One of the tests unexpectedly passed. This is why it is important to see the tests all fail before you write any code, as this shows that the test is not accurate.
The test unexpectedly passing is the one passing and expecting 0:
[
0,
0
]
Why? Because assertEquals
is currently casting (or should I say, PHP is) so that the "nothing" that is returned is equal to 0. So we need to alter our test as this is obviously not right:
$this->assertSame($expected, $output);
We use assertSame
instead. This checks the type as well as the value (like ===
) and so if we now run the tests again:
5 expected failures. Again, I can't stress enough that this is a 2 way process. We have the tests to give us confidence in our code, but we have to have confidence in the tests themselves. This is why it is so important to see if their behaviour matches what we expect.
Now for some code.
Inside the function, I add one line:
return $value + ($value * 0.1);
Simple enough? I take 10% of the input $value
and add it to itself, and return it.
Let's see if they satisfy the tests:
It passes a couple of them. But not all of them. So now, we go through and change our code accordingly.
The first thing that stands out to me is that the last test with minus numbers became -22 instead of -18. From the code I have written, this makes sense, 10% of -20 is -2. -20 + -2 is -22. But, for whatever reason, I want it to properly add the 10% value even if it is negative, and so I expect -18.
So I make a tweak to my code:
return $value + (abs($value) * 0.1);
I surround the $value with abs
before its percentage is taken. This makes sure that it is always positive.
Run the tests again:
Still 3 failures, but at least I am getting -18.
Now, these are all failing for the same reason. The function is returning a float
even though it is a whole number. My expected values are int
s and the assertSame
matches their type and so is failing the tests.
We could approach this in different ways. We could realise that we want floats
to be returned and so change the tests to reflect this. We could also be lazy and revert back to assertEqual
, but knowing that we have a test that passes when it should fail (even though now it will pass) is enough to lose confidence in them. I am going for the final option: change the code so that it returns int
s:
$result = $value + (abs($value) * 0.1);
if ((int)$result == $result) {
$result = (int)$result;
}
return $result;
I don't want to cast actual decimal numbers to int
s, so I first check if the integer value is equal to the value returned (i.e. does n == n.0
). If it is, I override the result with the int
value.
Run the tests again:
Horray! We have all the tests passing. Now we can pat ourselves on the back and use this function with the confidence that it does what we expect.
Step 3 - Changing the Functionality
This post is already getting a bit long, but I also wanted to demonstrate another reason why having tests is great.
Let's say we want to change the function, so that it calculates any percentage. We start by changing our test:
public function testAddPercentage($input, $expected, $percentage)
{
$calc = new SimpleCalc;
$output = $calc->addPercentage($input, $percentage);
$this->assertSame($expected, $output);
}
So I have changed the name of the test and the function we use. The test now has a 3rd parameter: a percentage that will be input into our function.
I now need to change the data provider to have this 3rd parameter:
return [
[
100,
110,
10
],
[
0,
0,
10
],
[
1,
1.1,
10
],
[
1.1,
1.21,
10
],
[
-20,
-18,
10
],
[
100,
120,
20,
],
[
10,
11.25,
12.5
],
];
As you can see, I have left the percentage as 10% for the first few tests.
If I run the tests now (by the way, you will notice that the test name in these screenshots is still the old test name - oops!):
7 Errors. Because the function addPercentage
does not yet exist.
So now I change the code to have the expected parameters and outputs:
public function addPercentage($value, $percentage)
{
$result = $value + (abs($value) * ($percentage / 100));
if ((int)$result == $result) {
$result = (int)$result;
}
return $result.
}
Very easy. The name was changed, the extra parameter was added, and instead of '0.1' being hardcoded, it uses the extra parameter. Running the tests again:
All pass. So again, we have confidence that my changes to the code now works. If I was worried about backwards compatibility, I could have made the second parameter optional and built my test around that and then I would see that the old tests still passed, giving me confidence that I haven't broken the function where it is used.
One final little example.
Testing for Errors
PHPUnit provides numerous ways to test for expected exceptions. But here is a quick and dirty cheap way of doing it. I want an exception to be thrown if I give it an invalid percentage. So I add some more values to my data provider:
[
10,
0,
-5
],
[
10,
0,
"abc"
]
As you can see, I am defining an "invalid percentage" as a negative number or a string. I have set the expected value as 0, although I don't expect to test this as I want an exception to be thrown.
In my test, I surround the code in a try-catch
block:
try {
$calc = new SimpleCalc;
$output = $calc->addPercentage($input, $percentage);
$this->assertSame($expected, $output);
} catch (\Exception $e) {
$this->assertEquals('Invalid Percentage', $e->getMessage());
}
As you can see, I am just catching the Exception
and testing the contents of its message. If I wanted to implement different Exception
types (for example, I could implement and throw an InvalidPercentageException
), I could also test these in an equally crude manner using other assert
functions.
Running the tests now:
You can see the two new ones that I added correctly fail, as the code in the try
block is run and something is returned.
Now I adjust my code with:
if (!is_numeric($percentage) || $percentage < 0) {
throw new \Exception('Invalid Percentage');
}
And re-run the tests:
They all pass, meaning the code is handling all of the inputs as expected, even the erroneous ones.
Summary
Once again, this is the very basics of what you can do with PHPUnit just to get you started (and yet it was still quite a long post!).
The documentation is excellent and can be found here. It has many examples of many different situations that you may want to test including the proper way of testing for Exceptions as well as far more complex problems than this.