Solution 2: (ab)Using Doctrine
Towards the end of the previous video we had looked at one way to test dates and times with Behat. It wasn't a particularly elegant solution, but it allowed us to approach the problem from one perspective.
In truth this was the way I tested dates and times for a long while inside Behat.
On simple applications - very few endpoints, very few entities - this approach works well enough to allow you to move on quickly.
A better solution, in my opinion, is to mock the clock.
The problem with this approach is that by good God almighty this feels 'Enteprise-y'.
Mock Around The Clock Tonight
I put this series together solely to use that pun as a headline.
No, I kid. ho ho.
In researching a better solution to this problem I came across two important pieces of information:
- This GitHub ticket
- This post from Ross Tuck
GitHub Ticket
The issues list of any particular GitHub project (of sufficient size) is often a particularly useful source for learning, and problem solving.
In a project the size of Behat, the likelihood of me being the first developer to hit upon any particular problem is low.
That doesn't always mean someone will have opened a ticket and shared a solution, but it's a good place to start.
From reading this comment it was clear there was a potentially better solution to be found.
As we saw in this series, there can be problems in writing testable code if we don't own important concepts in our projects.
One of the most important, and often overlooked, is the concept of Time.
Typically if we want to work with a Date and Time, we new
up an instance of \DateTime
.
The problem in doing this is that time marches on unceasing.
This introduces an area of unhelpful change when trying to test this code.
User avant1 suggests instead that we abstract away the specifics of how we get the current time, and simply swap out implementations depending on environment.
In other words, when we are in Production, our clock should always be set to now
.
In Development, however, we can set the clock to any time we please, and then use this known quantity as the basis for all our time-sensitive tests.
Seems good.
In Symfony this means we will create ourselves a new clock
service, and then rather than rely on new \DateTime()
directly, we will instead put the way the \DateTime
is created behind a layer of indirection.
The problem now becomes how to access this clock
from any place that needs the concept of date and time.
Previously we had this in our entity:
<?php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
* @ORM\Table(name="widget")
* @ORM\HasLifecycleCallbacks()
*/
class Widget
{
// * snip *
/**
* Set created at
*
* @param \DateTimeImmutable $dateTime
*
* @ORM\PrePersist()
*
* @return $this
*/
public function setCreatedAt(\DateTimeImmutable $dateTime = null)
{
if (null === $dateTime) {
$dateTime = new \DateTimeImmutable('now');
}
$this->createdAt = $dateTime;
$this->updatedAt = $dateTime;
return $this;
}
/**
* Set updated at
*
* @param \DateTimeImmutable $dateTime
*
* @ORM\PreUpdate()
*
* @return $this
*/
public function setUpdatedAt(\DateTimeImmutable $dateTime = null)
{
if (null === $dateTime) {
$dateTime = new \DateTimeImmutable('now');
}
$this->updatedAt = $dateTime;
return $this;
}
But this no longer works.
We can't just new
up and instance of \DateTime
, or \DateTimeImmutable
here any longer, as we know we need to with via our new clock
service.
Without this we have no control.
Aha!
I know, let's try using a Doctrine Listener.
Again, this is an approach we have used before.
Solution 2
By using a Doctrine Listener we can listen for events occurring 'system wide', and take some action accordingly.
What we could do here is to listen for prePersist
and preUpdate
events, and we could still set these values for ourselves automatically in the background.
The advantage of using a Doctrine Listener is that we can now inject other Symfony services.
Good news, everyone! Our clock
can be setup as a Symfony service.
Let's set it up:
# app/config/config_prod.yml
imports:
- { resource: config.yml }
- { resource: services/clock_prod.yml }
and
# app/config/config_dev.yml
imports:
- { resource: config.yml }
- { resource: services/clock_prod.yml }
and
# app/config/config_acceptance.yml
imports:
- { resource: config.yml }
- { resource: services/clock_dev.yml }
This says in production AND development we want to use the service defined in services/clock_prod.yml
.
This will be the clock that returns new \DateTimeImmutable('now')
.
It could return new \DateTime('now')
. I would recommend using \DateTimeImmutable
, however, as mutability is a huge cause of bugs. Mutability simply means can stuff change or not. Immutable things cannot change once set.
In acceptance mode we want to use the service defined in services/clock_acceptance.yml
.
This will still be a clock
. This will just be a clock that we can set.
We will have two different configurations of the same service, switched out depending on our environment.
You may be wondering why dev
and prod
are the same?
Why aren't we group acceptance
and dev
?
This is because we will very likely want to interact with our development setup in app_dev.php
, and keep the constant flattening and repopulating of the acceptance
environment's database from disturbing our dev work. This is my personal preference, anyway.
The service definitions give the game away:
# services/clock_prod.yml
services:
crv.clock:
class: 'AppBundle\Model\SystemClock'
vs
# services/clock_dev.yml
services:
crv.clock:
class: AppBundle\Model\TestClock
arguments:
- "@filesystem"
- "%kernel.project_dir%"
Again, referring to avant1's example implementation what we could do is write the current time to a file, and read it if set, or return the current system time if not.
We're going to do approximately the same, only with two classes, not one.
Also, we will simplify the language based on Ross Tuck's suggestions.
Ok, so if we're going to have two different implementations of the same thing, the thing you are hopefully thinking is:
interface
Our Clock
interface is going to be extremely simple:
<?php
namespace AppBundle\Model;
interface Clock
{
public function now() : \DateTimeImmutable;
}
The production implementation simply returns a new instance of \DateTimeImmutable
:
<?php
namespace AppBundle\Model;
class SystemClock implements Clock
{
public function now() : \DateTimeImmutable
{
return new \DateTimeImmutable('now');
}
}
The test clock is a lot more interesting.
<?php
namespace AppBundle\Model;
use Symfony\Component\Filesystem\Filesystem;
class TestClock implements Clock
{
/**
* @var Filesystem
*/
private $filesystem;
/**
* @var string
*/
private $dir;
/**
* @var string
*/
private $timefile;
public function __construct(Filesystem $filesystem)
{
$this->filesystem = $filesystem;
$this->dir = sys_get_temp_dir() . '/Dummy/time/';
$this->timefile = $this->dir . './timefile';
}
public function setTime(string $time)
{
$this->filesystem->mkdir($this->dir);
if ($this->filesystem->exists($this->timefile)) {
$this->filesystem->remove([$this->timefile]);
}
$isoTime = (new \DateTimeImmutable($time))
->format('c')
;
$this->filesystem->dumpFile(
$this->timefile,
$isoTime
);
}
public function now() : \DateTimeImmutable
{
if (false === $this->filesystem->exists($this->timefile)) {
throw new \RuntimeException('Must set timefile before doing this.');
}
return new \DateTimeImmutable(
file_get_contents($this->timefile)
);
}
}
Firstly I'm not sure AppBundle\Model
is the right namespace
for this code but for the moment that's not a major issue to me.
Because TestClock
implements Clock, we must have a
now()method which must return an
instanceof \DateTimeImmutable`.
Our implementation of now()
says this:
throw
if we try to callnow()
before we have calledsetTime
.- Otherwise get whatever time is set, and return it
I've added the guard clause to the now()
function to make my life a bit easier when configuring (or mis-configuring) my TestClock
.
The setTime()
method is more involved, but is essentially what avant1 suggests:
We use Symfony's @filesystem
service (a pre-configured instance of Symfony's Filesystem Component):
- Create a temporary directory
- Remove any leftover files that are like the one in which we will store our mocked time
new
up a\DateTimeImmutable
given whatever mocked time we needformat
the output as ISO 8601 date format, e.g2024-05-12T15:19:21+01:00
- Write (
dumpFile
) that value to thetimefile
Making It Work With Behat
What we need to do now then is to get Behat to play ball.
We're going to need to set the time as part of our Background
setup:
Feature: Manage Widget data via the RESTful API
Background:
Given the system time at the start of this test is "1 January 2020 00:00:00"
And there are Widgets with the following details:
| id | name | created_at | updated_at |
| 1 | widget A | -4 days | -5 minutes |
| 2 | widget B | -1 day | -1 day |
| 3 | widget C | -6 months | -3 weeks |
And I set header "Content-Type" with value "application/json"
Scenario: Can get a single Widget
When I send a "GET" request to "/widget/1"
Then the response code should be 200
And the response should contain json:
"""
{
"id": 1,
"title": "widget A",
"created_at": "2019-12-25T00:00:00+0000",
"updated_at": "2019-12-31T23:55:00+0000"
}
"""
We'd need a way for Behat to know about this new step:
<?php
// src/AppBundle/Features/Context/TimeContext.php
namespace AppBundle\Features\Context;
use AppBundle\Model\TestClock;
use Behat\Behat\Context\Context;
class TimeContext implements Context
{
/**
* @var TestClock
*/
private $clock;
/**
* Initializes context.
*
* @param TestClock $clock
*/
public function __construct(
TestClock $clock
)
{
$this->clock = $clock;
}
/**
* @Given the system time at the start of this test is :strDate
*/
public function theSystemTimeAtTheStartOfThisTestIs($strDate)
{
$this->clock->setTime(
$strDate
);
}
}
and in behat.yml
:
default:
suites:
default:
type: symfony_bundle
bundle: AppBundle
contexts:
- AppBundle\Features\Context\TimeContext:
clock: "@crv.clock"
With our date and time issues under control, we are able to check for created_at
and updated_at
without fudging our tests.
This has improved our documentation. Also, we can get rid of the extra code we added in Behat to check for approximations of dates.
Writing The Listener
It's not elegant.
We could check if the given entity uses the Timestampable
trait, but honestly this is equivalent in my eyes:
<?php
// src/AppBundle/Entity/Listener/TimestampListener.php
namespace AppBundle\Entity\Listener;
use AppBundle\Model\Clock;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
class TimestampListener
{
/**
* @var Clock
*/
private $clock;
public function __construct(Clock $clock)
{
$this->clock = $clock;
}
public function prePersist(LifeCycleEventArgs $args)
{
$entity = $args->getEntity();
if (method_exists($entity, 'setCreatedAt')) {
$entity->setCreatedAt(
$this->clock->now()
);
}
}
public function preUpdate(PreUpdateEventArgs $args)
{
$entity = $args->getEntity();
if (method_exists($entity, 'setUpdatedAt')) {
$entity->setUpdatedAt(
$this->clock->now()
);
}
}
}
If the entity has a method called setCreatedAt
then call it. Likewise for setUpdatedAt
.
We call it with whatever the clock->now()
call returns.
Depending on our environment this will either be the real, or mocked time.
We will need a service definition:
# app/config/services/event_listener.yml
services:
crv.entity.timestamp:
class: AppBundle\Entity\Listener\TimestampListener
arguments:
- "@crv.clock"
tags:
- { name: doctrine.event_listener, event: prePersist }
- { name: doctrine.event_listener, event: preUpdate }
We can now remove the prePersist
and preUpdate
annotations from the setCreatedAt
and setUpdatedAt
methods on our Trait.
This works.
But I don't like it.
In the next video we will look my third alternative, and preferred solution.