From zero to test: the easy way
Here are some things I too often let slide: exercise, writing, studying, simply experiencing the world around me, reading good books, eating well, catching up with friends. Oh, and testing my code. For all these things, it often seems that something else more urgent or insistent demands precedence.
I’m not going delve too deeply into the relative merits of grass touching or running up hills, but I will make a case for testing.
It makes sense, of course. Life and work are stressful and writing tests can feel like one more overhead. We are more likely to be rewarded by clients and managers for fixing bugs and implementing features than we are for warding off future trouble. Despite the lip service that everyone seems to pay to the practice, we might even take some flack for writing tests. So it’s tempting to focus on the demands our projects place upon us and then move on quickly to the next feature or fix.
Like other kinds of insurance, though, testing can incur a surprisingly low upfront cost and save your life (or at least your income, time and reputation) in the long term.
Why test?
Testing is a good thing. At least, that’s the accepted wisdom. Is it true? Often, as you write tests, it feels as if they are doing little more than confirming the simplest of functions. And yet, even a basic test can improve your code and insure against bugs.
Here is an incomplete list of reasons why you should consider placing tests at the heart of your practice.
- Tests become more useful over time. When you write your code it is probably fine. Super. Dandy. Fit for purpose. So much so that any tests you write can seem laughably redundant. But then, one day, someone – could be you – makes an ‘improvement’ and breakage ensues. The test, once so trivial and pointless, becomes suddenly useful.
- Systems are interconnected beasts. A change in one part of a large codebase can cause effects in an entirely new area. Unit tests, which focus on elements in isolation might not catch this kind of problem, but functional tests, which address a working and interconnected system, can reproduce and catch ripple effects of this kind.
- Tests are documentation (ok, documentation is documentation – but tests provide a record of what a system is expected to do – and not to do).
- Tests promote good design. In order render a component amenable to tests, you might, for example, remove direct instantiations, which will make a class both easier to test and more reusable.
- Tests support refactoring. One of the problems with improving code is the real possibility that one of your changes might break more than it fixes. A good test suite can give you some confidence that your system remains sane as you refine it.
A class to test
Time to crack on with some example code. Let’s pretend we’re writing an adventure game. Let’s face it, whatever your real project might happen to be, you’d rather be writing a cool text adventure right? Maybe that’s just me.
Anyway, here’s a class to test.
namespace thehouse\world;
class Room
{
public function __construct(public string $name, public string $description)
{
}
public function __toString(): string
{
return "{$this->name}: {$this->description}";
}
}
With a class so simple, is there any point testing? Might be good to put the constructor promotion through its paces, perhaps.
Here’s a thing though, if you’re anything like me, you have already written the test. You will probably have added something like this to the bottom of the document:
$name = "Main bedroom";
$desc = "An old-fashioned darkly-paneled bedroom with a four-poster bed";
$room = new Room($name, $desc);
print $room->name . "\n";
print $room->description . "\n";
Of course, you’ll blow the test away as part of your clean up. Which is a waste of something useful. Virtuous laziness is one the themes of this blog. Throwing away good code is improperly lazy!
So what should we do with our back-of-a-napkin, bottom-of-the-class test?
Getting PHPUnit
The industry standard for PHP testing is PHPUnit. The easiest way to get it is by downloading the phar:
curl -LO https://phar.phpunit.de/phpunit-9.5.23.phar
php phpunit-9.5.23.phar --version
Running it will give this output (after some noise from the downloading).
PHPUnit 9.5.23 #StandWithUkraine
Or, if your project uses a composer file – which might look something like this
{
"autoload": {
"psr-4": {
"thehouse\\": "src/"
}
}
}
you can run composer require
composer require --dev phpunit/phpunit 9.5.23
And end up with
{
"autoload": {
"psr-4": {
"thehouse\\": "src/"
}
},
"require-dev": {
"phpunit/phpunit": "9.5.23"
}
}
Either way, you should now have your hands on PHPUnit. Let’s use it to run our minimal test.
Writing (and running) your test
You should should typically place your test in a file with a name that ends in Test.php
– that’s so PHPUnit can automatically recognise it as test code. As you might expect, you should name the test class accordingly. So here is the RoomTest
class as found in the RoomTest.php
class file.
namespace thehouse\tests;
use thehouse\world\Room;
use PHPUnit\Framework\TestCase;
final class RoomTest extends TestCase
{
public function testProperties(): void
{
$name = "main bedroom";
$desc = "An old-fashioned darkly-paneled bedroom with a four-poster bed";
$room = new Room($name, $desc);
$this->assertTrue($room->name == $name, "Name is incorrect");
$this->assertTrue($room->description == $desc, "Description is incorrect");
}
}
As you can see, I’ve simply copied across my bottom-of-the-page sanity test. There is one major difference, of course. For a quick and dirty check, I’m able to eyeball the results. With laziness as our guiding principle, we need to automate that kind of confirmation. We have better things to be doing than manually confirming test runs. So, instead of printing the object’s properties I’ve used a method call – assertTrue()
to confirm them. assertTrue()
accepts an expression and an optional failure message. If the expression does not evaluate truthily, the test will be marked a failure.
NOTE
assertTrue()
is the Swiss army knife of test assertions. It can be bent to almost any kind of confirmation – but as we’ll see, there is usually a more precise way of getting the job done.
Note that I have placed my test in a method named testProperties()
. Once again the naming convention is important here – PHPUnit looks in your test class for methods named testXXX()
and automatically invokes them for you.
NOTE You can also mark methods so that they are included in your test suite by using an annotation:
@test
.
Speaking of running tests, how do we make the magic happen? We have already seen how to run phpunit --version
– using the phar. This time we’ll use the composer-installed script to invoke our test class.
./vendor/bin/phpunit tests/RoomTest.php
And here is the output
PHPUnit 9.5.23 #StandWithUkraine
. 1 / 1 (100%)
Time: 00:00.009, Memory: 4.00 MB
OK (1 test, 2 assertions)
assert() yourself
Note that you can achieve almost anything with assertTrue()
. But there are dozens of assertions. Because we’ve been testing equivalence assertEquals()
is more appropriate. This method accepts the expected and actual values and an optional failure method. If the expected and actual values don’t match, the test will fail.
Let’s add a new method and use assertEquals()
.
public function testToString(): void
{
$name = "Main bedroom";
$desc = "An old-fashioned darkly-paneled bedroom with a four-poster bed";
$room = new Room($name, $desc);
$this->assertEquals("$name: $desc", "{$room}", "__toString() value is incorrect");
}
By placing $room
in a string I cause the implicit invocation of the magic __toString()
. I can then test this output against my expectation.
Note Although
assertEquals()
is useful, it compares the values but not the types of its arguments.assertSame()
will compare both the values and types of its arguments. This makes it a preferable choice in most circumstances.
Have you noticed that I have had to create two identical Room
instances so far in these test methods? Obviously, this kind of duplication is pretty minimal – but a more involved scenario might require tens of lines of setup before a round of assertions can be applied.
Fixtures
In testing parlance, a fixture is the prerequisite environment you must create before you run a set of tests. In our example that is a simple matter of creating a Room
object – but a fixture is often much more complicated than that, requiring the creation of data structures and multiple stub objects to create a sane environment for the class you’re testing.
In order to support this, PHPUnit automatically invokes a setUp()
method before each test method it calls.
Let’s use this to create a common instantiation routine for Room
.
final class RoomTest2 extends TestCase
{
private string $desc;
private string $name;
private Room $room;
public function setUp(): void
{
$this->desc = "An old-fashioned darkly-paneled bedroom with a four-poster bed";
$this->name = "main bedroom";
$this->room = new Room($this->name, $this->desc);
}
public function testGetters(): void
{
$this->assertEquals($this->name, $this->room->name, "Name is incorrect");
$this->assertEquals($this->desc, $this->room->description, "Description is incorrect");
}
public function testToString(): void
{
$this->assertEquals("{$this->name}: {$this->desc}", "{$this->room}", "__toString() value is incorrect");
}
}
Tests succeed when they fail
The tests we’ve developed in this article probably seem pretty trivial. Here’s the thing, though, tests often seem simple at the time you write them. After all, the code under test is fresh in your mind. Tests can really come into their own when you’ve forgotten all about them.
Even in our trivial example we can easily break things in relatively hard-to-spot ways. The new PHP 8 syntax makes life a lot easier, but it’s not always clear at a glance that constructor promotion is in operation. What if someon decided to ‘clean up’ an argument variable?
namespace thehouse\world;
class Room
{
public function __construct(public string $name, public string $desc)
{
}
public function __toString(): string
{
return "{$this->name}: {$this->description}";
}
}
See what I’ve done? By changing the $description
argument to $desc
, I’ve also altered the equivalent property variable. As a result, the __toString()
method will no longer return the expected string.
Luckily, even we don’t spot the breakage at the time, our test is in place to catch it for us:
PHPUnit 9.5.23 #StandWithUkraine
EE 2 / 2 (100%)
Time: 00:00.014, Memory: 4.00 MB
There were 2 errors:
1) thehouse\tests\RoomTest::testGetters
Undefined property: thehouse\world\Room::$description
/home/devpad/devpad-code/unbundled/0001.zero-to-test/batch02/code/tests/RoomTest.php:23
2) thehouse\tests\RoomTest::testToString
Undefined property: thehouse\world\Room::$description
/home/devpad/devpad-code/unbundled/0001.zero-to-test/batch02/code/src/world/Room.php:12
/home/devpad/devpad-code/unbundled/0001.zero-to-test/batch02/code/tests/RoomTest.php:28
ERRORS!
Tests: 2, Assertions: 1, Errors: 2.
Conclusion
Look, tests won’t magically make everything better. Not everyone tests as much as they pretend. At least 60% of people who claim they’re practicing test-driven development are lying liars who lie. I just spent an unpleasant week with covid writing tests because I didn’t have the energy for anything more fun or creative. Unless you fit into a very particular personality type, tests just aren’t that much fun.
Having said all that, by now tests have certainly repaid my investment of time and effort at least tenfold. They always make my code better and my projects safer. Testing won’t make you sexy – but it just might win you more time with sexy people. And that’s not bad.