Test-first practice with PHP 8.1

Wolfgang Klinger
9 min readOct 28, 2022

--

Test-first developed Mars Rover Kata.

Photo by Tara Winstead: https://www.pexels.com/photo/white-and-black-letter-t-7666402/

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 movement
    l 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.

Layered architecture with Commands and Queries

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.

Credits

Mars Rover Kata on kata-log.rocks

--

--

Wolfgang Klinger
Wolfgang Klinger

Written by Wolfgang Klinger

Programmer, Photographer, Gardener

Responses (2)