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 thediscoverableClasses()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 theserviceDependencyMap()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!