Test-first practice with PHP 8.1
--
Test-first developed Mars Rover Kata.
What you’ll learn in this article
This is the first part of an ongoing series about programming with PHP and different topics. This article is an introduction to the problem and a very basic implementation of the first requirement.
I will employ techniques such as Behaviour driven development
(outside-in TDD) and follow Domain driven design (DDD) principles. The solution uses the hexagonal architecture (ports and adapters) with Command and Query pattern (CQRS), and will be largely event driven using domain events and event sourcing.
I’ll utilize PHP 8.1, Symfony 6.1, PHPUnit, PHP Code Sniffer, Psalm, PHP Coding Standards Fixer and Infection Mutation testing framework besides others.
Mars Rover Kata
Overview
You’re part of the team that explores Mars by sending remotely controlled vehicles (Rover) to the surface of the planet.
The planet is oddly a 8x8 square. 🤷
Develop an API that translates the commands sent from earth to instructions that are understood by the rover.
┌────────────────────────────────────────────┐
│ │ │ │ │ │ │ │ │ │
8 │ ───┼────┼────┼────┼────┼────┼────┼────┼─── │
│ │ │ │ │ │ │ │ │ │
7 │ ───┼────┼────┼────┼────┼────┼────┼────┼─── │
│ │ │ │ │ │ │ ┌─┴─┐ │ │
6 │ ───┼────┼────┼────┼────┼────┼──┤7,6├──┼─── │
│ │ │ │ │ │ │ └─┬─┘ │ │
5 │ ───┼────┼────┼────┼────┼────┼────┼────┼─── │
│ │ │ │ │ │ │ │ │ │
4 │ ───┼────┼────┼────┼────┼────┼────┼────┼─── │
│ │ │ ┌─┴─┐ │ │ │ │ │ │
3 │ ───┼────┼──┤3,3├──┼────┼────┼────┼────┼─── │
│ │ │ └─┬─┘ │ │ │ │ │ │
2 │ ───┼────┼────┼────┼────┼────┼────┼────┼─── │
│ │ │ │ │ │ │ │ │ │
1 │ ───┼────┼────┼────┼────┼────┼────┼────┼─── │
│ │ │ │ │ │ │ │ │ │
y └────────────────────────────────────────────┘
x 1 2 3 4 5 6 7 8
Rules
- A rover can’t escape the world, if it reaches the edges,
it appears on the other side (like in a game like Snake or Pac-Man)
Example:
Rover is at position “8,3 E” and gets the instruction “forward”. Then the next position is “1,3 E”.
- There are other obstacles on Mars the rover can’t move over
- The rover is dropped from a spaceship at an initial starting point (x, y) and a cardinal direction (
N, S, W, E
) the rover is facing - The Rover receives a collection of commands (character string):
f
for forward movement,b
for backward movementl
for turn left,r
for turn right (does not change x or y)
Tasks
- Drop the rover on the planet at an initial position (x, y, cardinal direction)
- Add position reporting (e.g., “1,1 N” for bottom left corner facing north)
- Implement commands that move the rover forward/backward
- Implement commands that turn the rover left/right
- Implement obstacle detection before each move to a new square. If a given sequence of commands encounters an obstacle, the rover moves up to the last possible point, aborts the sequence and reports the obstacle
Example:
Rover is at position “1,1 E”, obstacle at “5,1”, rover moves forward to “4,1” and reports “O:3,1 E”
Considerations
Test driven development does not mean you don’t need a plan. Of course, some design evolves from writing tests first, but you should have (at least) a big-picture architecture and design in mind before starting to write code.
This example project is overengineered (aka uses things you probably don’t need), for sure, but you need to practice these things to apply them in a real project.
We use three layers (Infrastructure, Application, Domain) and Commands and Queries as our public API in the Application layer.
In case you don’t know Hexagonal Architecture (also called Ports and Adapters), Ted has some great videos about that on YouTube.
What do I mean by public API?
This has nothing to do with REST or any other so-called “Web API”, our API here is simply the only accessible way to enter our application (our domain) and to trigger a state change or query state. These patterns are a very powerful way of encapsulation.
Setup
I set up a simple Docker container and a standard Symfony project. I won’t go into details about this.
The first test
I add a file in my projects tests folder tests/RoverDropTest.php
(in anticipation of the first use case) and just call the TestCase::fail
method. When I run this test, the test obviously fails ❌ and thereby confirms that I set up everything correctly. Perfect!
The first use case
The first use case is to drop the Rover on Mars and then check that the Rover reports its initial position.
What information do we need for this?
First, we need our planet Mars, and we need a name (or an ID) for our Rover, that we can use to precisely identify (and query information from) it. Then we know, that the Rover should be dropped at an initial position (x, y, cardinal direction). I think that’s currently enough information to write our first expectation:
The TDD circle
Red ❌ — Green ✅ — Refactor
Outside-in methodology
I don’t want to couple my test code to my production code, so I’m trying to keep a bird’s eye view and just check the behaviour of my components at their borders (test only the public API). This is of course an opinionated view, and I’ll change my tactic in other parts (as you’ll see in later parts of the series), where coupling is not that much of a problem.
The failing test
I run the test, and it fails (expectedly). ❌
Error : Class “App\Tests\Mars” not found
/app/tests/RoverDropTest.php:17
So I go ahead and create an empty entity Mars
.
I run the test again and it fails. ❌
Error : Class “App\Tests\DropRover” not found
So I create another class for the command DropRover
with namespace App\Rover\Application
.
I run the test again and it fails. ❌
Error : Call to undefined method App\Rover\Application\DropRover::with()
/app/tests/RoverDropTest.php:25
so I implement this method and this is the DropRover
command so far:
We have a private constructor and a named constructor (aka factory method) called with
that takes all required information to drop the Rover on Mars as arguments.
I run the test again and it fails. ❌
Error : Class “App\Tests\DropRoverHandler” not found
/app/tests/RoverDropTest.php:31
So I add the handler class with namespace App\Rover\Application\Handler
and run the test again, and it fails. ❌
Error : Object of type App\Rover\Application\Handler\DropRoverHandler is not callable
/app/tests/RoverDropTest.php:32
I add the __invoke
method and this is our empty handler so far (does not do any useful things yet). Remember that Commands don’t return a value, they only lead to a state change.
If you want to know more about callable and the __invoke
method, .com software wrote an article about that.
I run the test again and it fails. ❌
Error : Class “App\Tests\GetRoverPosition” not found
I create the Query GetRoverPosition
in the Rover\Application\Handler
namespace with the Rover ID as argument (that’s the only thing we need to get the position of a certain Rover).
I run the test and it fails. ❌
Error : Class “App\Tests\GetRoverPositionHandler” not found
/app/tests/RoverDropTest.php:37
I add the handler for the Query and run the test again, and it fails. ❌
Error : Object of type App\Rover\Application\Handler\GetRoverPositionHandler is not callable
/app/tests/RoverDropTest.php:38
I implement the __invoke
method with the GetRoverPosition
Query as argument.
I run the test again and it fails. ❌
TypeError : App\Rover\Application\Handler\GetRoverPositionHandler::__invoke(): Return value must be of type string, none returned
/app/src/Rover/Application/Handler/GetRoverPositionHandler.php:18
/app/tests/RoverDropTest.php:38
I add a hardcoded return value with the same position as I used in the test:
I run the test again and it passes! ✅
I commit this result to git with First test passes
and a note that there’s still a hardcoded value in the Query handler.
Implement the first use case
Now that we have a green test and no compile errors, I’ll add the actual functionality.
I start with the DropRoverHandler
. What do we need here? We have Mars
via dependency injection and Mars should “know” what things (the Rover, obstacles, …) are currently on its surface and the Rover
should know its own position.
But first I have to consider when the Rover comes into existence. When “adding” it to Mars? What about the Spaceship? Wasn’t “dropping” a requirement? So the Rover is already somewhere waiting to be used.
I decide to add a Spaceship for the Rover. I adapt the test.
When I run the test, it fails. ❌
Error : Class “App\Tests\Spaceship” not found
/app/tests/RoverDropTest.php:23
I create the Spaceship
class.
I run the test and it fails. ❌
Error : Call to undefined method App\Rover\Domain\Entity\Spaceship::with()
/app/tests/RoverDropTest.php:24
I add the named constructor with
and run the test again, and it fails. ❌
Error : Class “App\Tests\Rover” not found
/app/tests/RoverDropTest.php:24
I create the Rover
class.
I run the test again and it fails. ❌
TypeError : App\Rover\Domain\Entity\Spaceship::with(): Argument #1 ($param) must be of type App\Tests\Rover, App\Rover\Domain\Entity\Rover given, called in /app/tests/RoverDropTest.php on line 25
/app/src/Rover/Domain/Entity/Spaceship.php:9
/app/tests/RoverDropTest.php:25
I import the correct class in Spaceship
.
I run the test and it fails. ❌
TypeError : App\Rover\Domain\Entity\Spaceship::with(): Return value must be of type App\Rover\Domain\Entity\Spaceship, none returned
/app/src/Rover/Domain/Entity/Spaceship.php:11
/app/tests/RoverDropTest.php:25
I implement the constructor and add an array property to store the spaceship’s freight:
I run the test and it fails. ❌
Error : Call to undefined method App\Rover\Domain\Entity\Rover::id()
/app/src/Rover/Domain/Entity/Spaceship.php:13
/app/src/Rover/Domain/Entity/Spaceship.php:18
/app/tests/RoverDropTest.php:25
I add the getter for the Rover ID and rename the property to just id
(roverId
has no added value in this context):
I run the test and it fails. ❌
TypeError : App\Rover\Application\Handler\DropRoverHandler::__construct(): Argument #1 ($mars) must be of type App\Rover\Domain\Entity\Mars, App\Rover\Domain\Entity\Spaceship given, called in /app/tests/RoverDropTest.php on line 37
/app/src/Rover/Application/Handler/DropRoverHandler.php:12
/app/tests/RoverDropTest.php:37
A passing test
I adapt the handler arguments and run the test again.
And the test passes. ✅
Now I continue implementing the DropRoverHandler
.
I’ll skip some of the individual steps here now, you can imagine it by now.
I also changed all the properties of the command to public (in fact this is just a DTO).
When the test passes again the classes look like this:
Mars is now to concrete for my taste, so I introduce an interface Planet
and Mars implements this interface. I adapt all occurences of Mars
and the test still passes. ✅
The Planet
interface gets a new method add
(in absence of a better name) and Mars
implements this method. At this point I decide that Rover
is to specific too and introduce another interface Thing
(for both Rover
and the not yet existing Obstacle
). I adapt the DropRoverHandler
and Spaceship
and the test still passes. ✅
Next thing I implement is the dropOn
method of Rover
which sets the initial position on Rover
and adds the Rover
to the given planet.
Removing the hardcoded values
Now I turn my attention to the Query handler GetRoverPositionHandler
. The Query handler gets the Planet
via dependency injection and we can query the Rover
there.
I still work in TDD cycles.
I also changed all the properties of the query to public (in fact this is also just a DTO).
The final handler looks like this:
and Rover
:
And guess what, the test still passes without the hardcoded values now. ✅
Refactoring the test
I turn my attention to the test itself and make it more easily readable.
I run the test and it passes. ✅
It would be nice to check if we can drop the Rover at other positions and it reports it correctly. Therefore I add a dataProvider
method to the test:
There are still some things to cleanup (or rename), but I’m pretty satisfied with the first solution.
I’ll push the repository to GitHub before I start with the next iteration. Follow me to be notified of upcoming articles.
Preview
In the next article we’ll take a look at value objects and primitive obsession and how to secure our domain against invalid values.