Tests

We all know we should write them. We hardly ever do. One of my hopes with Cavatappi is to make tests easier to write. Part of that is architecting the application to make it testable, and the other part is having some shortcuts on hand to make the tests simpler and easier to run.

Step 1: Set Up the Module

To use some of the shortcuts that Cavatappi has for tests, we'll need to go ahead and set up the Module for our application. This is a special class that's responsible for providing a list of classes as well as a dependency map for the services.

There's some useful shortcuts for this as well:

use Cavatappi\Foundation\Module;
use Cavatappi\Foundation\Module\FileDiscoveryKit;
use Cavatappi\Foundation\Module\ModuleKit;

class PillTimerBackend implements Module {
    use FileDiscoveryKit;
    use ModuleKit;

    private static function serviceMapOverrides(): array {
        return [];
    }
}

Not a lot here! Let's take a look at the actual Module interface:

interface Module {
    /**
     * Array of class name keys with the interfaces they implement.
     *
     * @return array<class-string, class-string[]>
     */
    public static function discoverableClasses(): array;

    /**
     * Get the Services to be registered in this Model and their dependencies.
     *
     * @return array<class-string, array<string, class-string|callable>|string|callable>
     */
    public static function serviceDependencyMap(): array;
}

The heavy lifting is done by ModuleKit which implements the two interface methods using a pair of abstract functions:

trait ModuleKit {
    //...

    /**
     * Get the list of discoverable classes in this Module.
     *
     * @return class-string[]
     */
    abstract private static function listClasses(): array;

    /**
     * Get any overrides to the autogenerated service dependency map.
     *
     * @return array<class-string, array<string, class-string|callable>|string|callable>
     */
    abstract private static function serviceMapOverrides(): array;

    //...
}

These take what is unique about your Module and handle the boilerplate:

  • listClasses() provides a list of class names in your Module. The trait will add the interfaces the classes implement to fulfill the discoverableClasses() requirement.
  • The trait uses the list of classes to find Service classes and automatically infer a dependency map for them. Any that cannot or should not be inferred should be provided in serviceMapOverrides(). The two are merged to fulfill the serviceDependencyMap() requirement.

For our Module, the listClasses() function is implemented by the FileDiscoveryKit trait. By default, it will search its own folder and any sub-folders for concrete classes. (Behind the scenes this uses Construct Finder by The League of Extraordinary Packages.)

We don't currently have any overrides for the dependency map. We might in the future if we have a class that implements a common interface or requires some configuration that can't be auto-inferred (yet).

With this Module in place, our code is ready to be integrated with a Cavatappi app.

Step 2: Install Test Utilities

The only required part of this step is declaring the dependency:

composer require --dev cavatappi/testing

This is going to install a few things including Cavatappi's infrastructure package and PHPUnit. For convenience, I'll add a PHPUnit configuration file and another shortcut in composer.json:

  "scripts": {
    "lint": "./vendor/squizlabs/php_codesniffer/bin/phpcs",
    "lintfix": "./vendor/squizlabs/php_codesniffer/bin/phpcbf",
    "test": "phpunit --testsuite unit --no-coverage"
  }

Finally, I'm going to add a couple of folders. The first, tests, will be where the actual tests go. The second, test-utils, is going to hold a superclass that will be used by most of the tests. We'll add that to our composer.json file:

  "autoload-dev": {
    "psr-4": {
      "oddEvan\\PillTimer\\Test": "test-utils/"
    }
  }

Step 3: Set Up the Model Test

Instead of a strict unit test (where we are only testing one specific service class), I'm going to demonstrate testing the domain model as a whole. Remember that a domain model accepts Command objects and outputs Event objects, and Cavatappi has a way to test exactly that.

In our test-utils folder, I'll create a base test:

use Cavatappi\Test\ModelTest;
use oddEvan\PillTimer\PillTimerBackend;
use oddEvan\PillTimer\Services\DoseRepo;
use oddEvan\PillTimer\Services\MedicineRepo;
use PHPUnit\Framework\MockObject\MockObject;

abstract class PillTimerTest extends ModelTest {
    const INCLUDED_MODELS = [PillTimerBackend::class];

    protected DoseRepo & MockObject $doseRepo;
    protected MedicineRepo & MockObject $medRepo;

    protected function createMockServices(): array {
        $this->doseRepo = $this->createMock(DoseRepo::class);
        $this->medRepo = $this->createMock(MedicineRepo::class);

        return [
            ...parent::createMockServices(),
            DoseRepo::class => fn() => $this->doseRepo,
            MedicineRepo::class => fn() => $this->medRepo,
        ];
    }
}

This sets up two mock services for the repository interfaces we created but never implemented. They're class-level objects, so we can set conditions on each test.

Behind the scenes, Cavatappi will add the result of createMockServices() to the usual output of our PillTimerBackend Module class as well as its internal test module and create an instance of ServiceRegistry, Cavatappi's dependency injection container. This way, our tests will confirm that our Module is fully functional as a whole.

Astute readers will note that we will be writing integration tests and not unit tests. We can get into testing philosophy whenever, but my personal take is that we are always testing input and results. Which input and which results should be whatever gives us the most confidence in our code. In the case of a domain model-driven application, I consider that the Commands and Events. The actual makeup of the services doesn't matter as much as a given command resulting in the given events.

Step 4: Write a Model Test

Enough messing around, let's write a test! We'll start with our AddMedicine Command:

use oddEvan\PillTimer\Entities\Medicine;
use oddEvan\PillTimer\Events\MedicineAdded;
use oddEvan\PillTimer\Test\PillTimerTest;

final class AddMedicineTest extends PillTimerTest {
    private Medicine $testMed;

    protected function setUp(): void {
        parent::setUp();

        $this->testMed = new Medicine(
            name: 'Acetaminophen',
            userId: $this->randomId(),
            hourlyInterval: 6,
            dailyLimit: 3,
        );
    }

    public function testItCreatesTheMedicine() {
        // Medicine does not already exist.
        $this->medRepo->method('has')->with($this->testMed->id)->willReturn(false);

        $this->expectEvent(new MedicineAdded(
            medicine: $this->testMed,
            userId: $this->testMed->userId,
        ));

        $this->app->execute(new AddMedicine(
            medicine: $this->testMed,
            userId: $this->testMed->userId,
        ));
    }
}

Not much here, and that's by design! We set up one stub method on our Medicine repository and one expected Event. We then create the Command and pass it to the test app's execute() method. By using a ModelTest subclass, we already have a mock set up for EventDispatcherInterface as well as a custom PHPUnit comparator to check that two Event objects are equal except for their IDs and timestamps.

We can run the test using the shortcut we set up, composer test, and see that it passes:

Add Medicine (oddEvan\PillTimer\Commands\AddMedicine)
 ✔ It creates the medicine

Step 5: Write an App Test

Now, what about our NextDoseService? Since it responds to an Event and not a Command, we can't use ModelTest. So for this one, we'll go up one level to AppTest. We don't need to set up a superclass in this case:

use Cavatappi\Test\AppTest;
use DateTimeImmutable;
use oddEvan\PillTimer\Entities\Dose;
use oddEvan\PillTimer\Entities\Medicine;
use oddEvan\PillTimer\Events\DoseAdded;
use oddEvan\PillTimer\PillTimerBackend;
use PHPUnit\Framework\MockObject\MockObject;

final class NextDoseServiceTest extends AppTest {
    const INCLUDED_MODELS = [PillTimerBackend::class];

    protected DoseRepo & MockObject $doseRepo;
    protected MedicineRepo & MockObject $medRepo;

    protected function createMockServices(): array
    {
        $this->doseRepo = $this->createMock(DoseRepo::class);
        $this->medRepo = $this->createMock(MedicineRepo::class);

        return [
            ...parent::createMockServices(),
            DoseRepo::class => fn() => $this->doseRepo,
            MedicineRepo::class => fn() => $this->medRepo,
        ];
    }

    public function testItCalculatesTheNextDoseCorrectly() {
        $baseTimestamp = new DateTimeImmutable('@' . time(), timezone_open('America/New_York'));

        $medicine = new Medicine(
            name: 'Ibuprofen',
            userId: $this->randomId(),
            hourlyInterval: 4,
            dailyLimit: 3,
        );
        $this->medRepo->method('get')->with(medicineId: $medicine->id)->willReturn($medicine);

        $existingDoses = [];
        $this->doseRepo->method('dosesForMedicineInLastDay')->
            with(medicineId: $medicine->id)->willReturn($existingDoses);

        $newDose = new Dose(
            id: $this->randomId(),
            medicineId: $medicine->id,
            timestamp: $baseTimestamp,
        );

        $expected = $baseTimestamp->modify('+4 hours');
        $this->medRepo->expects($this->once())->method('setNextDose')->with($medicine->id, $expected);

        $this->app->dispatch(new DoseAdded(
            dose: $newDose,
            userId: $medicine->userId,
        ));
    }
}

...there's a lot of copypasta in there. Siri, make a new GitHub issue to refactor ModelTest into a trait.

I've also set this up to eventually refactor the test into something repeatable using PHPUnit's data providers. This test in particular is going to involve a lot of repetitive set up to make sure that this calculation is being done correctly.

So that's an overview of how Cavatappi makes testing suck less. And hopefully, as the framework improves, this will as well!


And that's our walkthrough of Cavatappi! We haven't built a complete app yet; there's still more to be done around data persistence (saving to the database), creating API endpoints, and maybe building an admin UI.

There's more to come, but that's what we have so far. Thanks for reading!