3 hacks for a magical code kata in PHP

| 8 min read

When creating content for people to learn from, having a story as a backbone can be a good idea. In my improving tests class, I’ve constructed most chapters with a story. To make a story more powerful, sometimes you need to add some magic. In this article, I’ll show you how I’ve created dishonest code to make a production-like environment with painful library upgrades, slow tests that aren’t really that slow, and lying code.

Use these ideas for more common use cases

The techniques I share here are useful for storytelling purposes but are, more than anything, ideas you can apply with legacy code or code organization. For each technic, I share more conventional usages.

Some background

This article is quite long, feel free to skip if you’re only interested in the 3 hacks!

First, let me describe a typical chapter in my testing class. In the beginning, we have some tests. They’re not always terribly bad, but they have room for improvement. They serve as a demonstration of a problem we can see in a lot of test suites. Usually, the next part of the story is about making a change, either in the tests or in the code, and seeing how these tests are getting in the way of that change. Then, we introduce an idea and demonstrate how to change our tests to remove that roadblock. It’s time to try again to make the change or make another related change and see how easier it is now. That story form should show students what these ideas can help them with when they struggle with their tests.

One chapter of the course is about the "Don’t mock what you don’t own" advice. I’ve used the same story for years, and I like it. When I created the course, I chose to use a Burrito restaurant as the backstory for all examples, and I was lucky enough to reuse my favourite story one more time.

That story is about cooking something using an oven. In that case, we’re cooking some peppers and toasting some bread. AKEI, a well-known Swedish company, manufactured our oven, a Kulinarisk. We directly use the Kulinarisk in our own code. At some point, a new version of the Kulinarisk oven is published and, obviously, breaks our code because AKEI finally decided that publishing code in Swedish was a disservice to its customers. In the V2 of the Kulinarisk library, method names are in English, which causes a BC break.

I like this example because it shows a few different things:

  • When you use external code, you must conform to its vocabulary. In the test class, we have some code in French mixed with some Swedish words. Creating an abstraction on top of the external code gives you the power to select what terms you want to use in your code. You are free to make the vocabulary used in the code match the concept you are manipulating. No more "laga" when you’re supposed to talk about "baking".
  • Using the library code in many places in the code and the tests shows how painful it can be when a library makes a BC break. We need to make multiple changes in our code and our tests. Touching at the tests is clearly not the best way to make a refactoring with confidence.

I’ve recently decided to create a kata version of this chapter. From what you’ve read, you already know that I needed to have at least two versions of the Kulinarisk library. I also want the learners to discuss the need to use a double instead of the real Kulinarisk. As often, an excellent reason for using a double in place of the real thing is that the real thing is slow. In my story, baking peppers to get nice and soft peppers takes 25 minutes. Clearly, that’s a bit too long to use in a kata. You don’t want people to stare at their screen for 25 minutes while waiting for the test runner to tell them if the test passed or not.

Could we have our tests be slower when we’re using the real Kulinarisk, to demonstrate that using a double is a good idea, but not to slow to avoid wasting everyone’s time? Yes, we can, and we can even display that the test took 25 minutes to run, even if it actually took a few seconds. These are special effects applied to deliberate practice!

Let’s see how I’ve recreated an almost real environment for that kata.

Using an almost external library and upgrading it

As mentioned before, we need multiple library versions, and students should be able to upgrade to the next version using composer, the PHP package manager.

The first trick here is to use a built-in functionality of composer. Instead of hosting the package online, I kept it inside the repo. The kata comes with all the akei/kulinarisk library versions.

With composer, you can register custom repositories for packages. One of these custom repository types, path, allows you to map a local directory to a package at a specific version.

The composer.json of the kata repositories points to all the necessary versions of akei/kulinarisk. They are stored in the same repository as the kata code.

{
  //composer.json
  // ...
  "repositories": [
    {
      "type": "path",
      "url": "./libs/Kulinarisk_V1.0",
      "canonical": false,
      "options": {
        "versions": {
          "akei/kulinarisk": "1.0"
        }
      }
    },
    {
      "type": "path",
      "url": "./libs/Kulinarisk_V2.0",
      "canonical": false,
      "options": {
        "versions": {
          "akei/kulinarisk": "2.0"
        }
      }
    }
  ]
}

Participants install the project and run composer install when the kata starts. They get akei/kulinarisk:1.0. Later, they can run a composer require akei/kulinarisk to upgrade to the V2 and feel the pain of having code coupled with vendor code.

Keeping libraries in the same repository

Aside from creating katas, this feature is useful with the modular monolith and mono repo approach. Maybe some modules could use some shared code. Instead of creating a library on another repository, pushing it to packagist (PHP libraries repository), and trying to keep the library private, you could make your life simpler and keep that library inside the same repository as your application code.

The second magic trick I use in the kata allows me to hide some code.

The fake sleep method trick

At some point, I hope that participants will look at the Kulinarisk code to understand why it’s so slow. They will notice that some maths is involved in transforming a duration in minutes into seconds, followed by a call to the sleep function.

namespace AKEI;

class Kulinarisk {

        public function laga($maträtt, int $varaktighet)
        {
            $varaktighetISekunder = $varaktighet * 60;
    
            sleep($varaktighetISekunder);
    
            return $this->värme($maträtt, $varaktighetISekunder);
        }
    }

As said before, in the story we’re supposed to wait for 25 minutes before getting our warm peppers, but as a facilitator, I don’t want to see people waiting that long each time they run their tests. Here we need to replace a function everyone knows with something else but make it look like we are calling the original one.

The trick is to monkey patch with namespaces. Alongside the file containing the Kulinarisk class, I have another file declaring a sleep function in the same namespace as the Kulinarisk class.

namespace AKEI 

function sleep($duration)
{
    TimeLogger::setExecutionTime($duration);

    if($duration > 5 * 60) {
        \sleep(intdiv($duration,300));
    }
    else {
        \sleep(intdiv($duration, 60));
    }

}

Because they are in the same namespace, the fake sleep function takes precedence over the original one. Hidden in that fake sleep function, we can add some code to make the tests run slowly but not as slow as the initial sleep implementation.
Note that we’re calling the original sleep function from the global namespace thanks to the \ prefix.

Monkey patching

Using a namespace to replace language’s functions can be helpful when creating a test harness on top of legacy code.

You can quickly introduce a double without touching the code with that technic. This is sometimes called monkey patching and is an example of the Link Substitution pattern described by Michael Feather’s "Working effectively with legacy code".

As you can see above in the code of the fake sleep function, we are passing the expected real sleep time to the TimeLogger::setExecutionTime method. It’s the first step for our next trick.

Lying about the test execution time

So far, we’ve seen how to have a consistent story, saying that we will bake something for 25 minutes, calling the sleep method like we’re going to wait for 25 minutes, but wait only a few seconds, to give a feeling of slowness. To make our story consistent, we need to ask: If our tests are calling a method that is expected to run in 25 minutes, how long should they take? 25 minutes, right? How is it that the test output shows that it only took seconds to execute? That doesn’t make for a compelling story. Of course, we can do something and push our story telling a little further!

In the end, we would like our test report to display that the test took 25 minutes like so:

Two tests marked as taking 25 and 2 minutes.

Look at the TimeLogger class, which is responsible for storing each theoretical sleep duration. That class keeps the value in a static field when we call the setExecutionTime static method. TimeLogger also offers a static method to access the stored value and a static method to reset the value.

final class TimeLogger {

    private static ?int $executionTime = null;

    static public function resetTimer() {
        static::$executionTime = null;
    }

    static public function getExecutionTime() {
        return static::$executionTime;
    }

    static public function setExecutionTime(int $executionTime):void {
        static::$executionTime = $executionTime;
    }
}

We’ve seen where the value is stored. Let’s now discuss where we need to read and reset the value.

So, where? Well, directly inside the test framework. Yes, that’s really hacky, but it does the job.

Do you speak French and want to stop hating your tests ?

I've created a course to help developers to improve their automated tests.

I share ideas and technics to improve slow, flaky, failing for unexpected reason and hard to understand tests until they become tests we take joy to work with !

But you'll need to understand French...

Inside the TestResult::run method from PHPUnit, which runs every test, we add an if statement. If the TimeLogger contains a non-null value, we use that value instead of the value recorded by the PHPUnit timer. Also, we reset the recorded value to null before running each test.

Now, when a test exercise the Kulinarisk::laga method, which in turn calls our fake sleep function, a value is recorded, and we use that value for the duration time display. If a test doesn’t exercise the Kulinarisk::laga method, the recorded value stays null, and we keep the real test duration time.

We found a way to display the information we want. One little problem still needs to be solved. The two modifications we’ve made are on vendors' files, which aren’t versioned in the repository. That means that when participants get the repo and run composer install to get the dependencies they don’t have that dirty hack.

Patching dependencies

It’s now time for the final interesting trick: modify the PhpUnit code when the library is installed.

To do this, we use cweagans/composer-patches, a package that applies patches when we install another package.

We need to create a patch file containing the difference between the code from the repo and the code we would like to have. I’ve cloned the PHPUnit repository, made the change I needed, ran git diff, stored the result in a file in the kata repository.

diff --git a/src/Framework/TestResult.php b/src/Framework/TestResult.php
index 4fde29fac..6be437905 100644
--- a/src/Framework/TestResult.php
+++ b/src/Framework/TestResult.php
@@ -37,6 +37,8 @@
 use SebastianBergmann\ResourceOperations\ResourceOperations;
 use SebastianBergmann\Timer\Timer;
 use Throwable;
+use AKEI\TimeLogger;
+
 
 /**
  * @internal This class is not covered by the backward compatibility promise for PHPUnit
@@ -693,6 +695,8 @@ function_exists('xdebug_start_function_monitor');
             xdebug_start_function_monitor(ResourceOperations::getFunctions());
         }
 
+
+        TimeLogger::resetTimer();
         $timer = new Timer;
         $timer->start();
 
@@ -772,7 +776,8 @@ function_exists('xdebug_start_function_monitor');
             $error = true;
         }
 
-        $time = $timer->stop()->asSeconds();
+
+        $time = TimeLogger::getExecutionTime() ?? $timer->stop()->asSeconds();
 
         $test->addToAssertionCount(Assert::getCount());

I also configured cweagans/composer-patches to apply the patch file every time Phpunit is installed in the vendor directory like so:

{
  // composer.json
  // ...
  "extra": {
    "patches": {
      "phpunit/phpunit": [
        "./patches/patch.diff"
      ]
    }
  }
}

Patching dependencies

Patching dependencies can be necessary when you have some legacy code.

Maybe you’re blocked with an old language version and would like to use a library that could be available to you if you changed a few things or your code relies on a bug that is now fixed in the library.

With a patch, you can keep your code running and use vendor code without needing to maintain your own fork and see it diverge from the original library.

After applying all these tricks, kata participants can clone the kata repository, run composer install, get the patched version of PHPUnit, the V1 of akei/kulinarisk, a fake sleep method they don’t see, tests pretending to run in 25 minutes even if they actually run in a few seconds. This is magic!

Even if creating special effects for kata is fun, the interest of this article is in sharing technics and tools you can apply in your daily life. The monkey patching technic and the patching package are helpful if you’re dealing with legacy code. Keep in mind that it’s not because you can use these ideas that you should stop improving your code. The composer loading library from a local path can help if you use a mono-repo approach and remove the pain of working across many repositories.

I hope you enjoyed that story!

Anyway, if you have trouble dealing with a legacy codebase and think that you could use some help let's have a chat and see what we can do together to help you out of that situation.