Symfony Dependency Testing with PhpSpec
In the previous video we created a FileMover
service to help us move files from the temporary location that PHP stores our images after we have uploaded them, into a location that we control.
In our case, this location is going to be on the server's local hard disk, and specifically in the web/images
directory. But it needn't be. Depending on who you are allowing to upload files, you may be better initially storing the files in a location that isn't publicly accessible.
For now, the only people who will be allowed to upload via our Admin area will be... admins! In the scale of our project, there is little to no risk of us maliciously destroying our own site. Of course, in the real world, use your best judgment.
Taking a leaf out of the old Cookbook entry we're going with a similar approach, but rather than rely on Doctrine's lifecycle callbacks, we're going to use Lifecycle Event Listeners.
To clarify, the difference between a lifecycle callback and a lifecycle event listener is that:
- Lifecycle Callbacks are methods on the entity classes that are called when the event is triggered
- Lifecycle Event Listeners and Subscribers are classes with specific callback methods that receives some kind of EventArgs instance
This is taken directly from the Doctrine documentation.
We're going with the Event Listener approach as we will need to inject our FileMover
service, and we don't want to be injecting that into our Wallpaper
entity.
We need a "trigger" to kick start the calling of move
on our FileMover
service.
A fair first thought here is to put this logic inside our controller action. But we have an immediate problem to tackle if we take this approach: we don't have a controller. At least not one that we... control.
As we are relying on EasyAdminBundle here, our controller actions are predefined. We are working within their confines. That's not to say that we couldn't override the provided controller, but it's not something that I want to do at this stage.
Looking at the code for the EasyAdminBundle newAction
, there are events we could listen for, and act upon which seem really similarly named to events created by Doctrine. I debated (with myself) the pros and cons of hooking into the EasyAdminBundle events instead of relying on the Doctrine events.
There are definitely negatives to using Doctrine's lifecycle callbacks / events. We will see some of these as we proceed through this process. However, my reasoning for going with Doctrine's lifecycle events over the EasyAdminBundle events is this:
If we use EasyAdminBundle's events, we only capture the concept of our Wallpaper entity needing an associated file when a new Wallpaper is created in the EasyAdminBundle admin area.
What happens later on if we were to allow our end users to upload their own Wallpapers? We would need to re-implement a bunch of upload logic.
I want to stress here that this decision may be incorrect. However, given everything I know, this appears to be the current best solution. You may disagree, and that's cool. I would love to hear your opinions on this in the comments - I'm always open to improving :)
Lifecycle Events
We are going to hook into a variety of Doctrine's lifecycle events to help us through this problem.
What this will mean is that when interesting things happen to our entity - it is insert
ed, or update
d, or delete
d - we can take some action that makes sense in our domain.
An example of this is that when we create or update our entity, we will make sure that the uploaded image is correctly moved, we store the new file path as a property on our entity, and that the image's width and height are set.
Another benefit here is that because our upload process is tied quite tightly to the Wallpaper
entity, it's impossible to forget this step - it happens in sync with a new wallpaper, or an update of an existing wallpaper.
However, there are downsides to this approach:
Our Lifecycle Event Listener will be called for every single event that we care about. Even though we will only be interested in prePersist
and preUpdate
events for our Wallpaper
entity, this listener will be called for prePersist
and preUpdate
events on every entity.
We will need to defend against this situation. We also need to be aware of the overhead - however small - that this introduces.
Also, our entities are not truly part of our business domain. They are a way in which we can store and retrieve the important data that makes our website work.
And yet, we are about tie the concept of uploading a wallpaper to our entities persistence operations.
My reasoning around this is that Doctrine is an intrinsic part of our application. There is very little chance I am going to want to rip Doctrine out of my project, and if I did, the chances are that it would be as part of a drastic re-write, or a change of framework.
Your opinion on this may differ, and I respect this. Feel free to disregard any or all of this.
As mentioned above, you may prefer hooking into the EasyAdminBundle events.
Note also that the VichUploadBundle integration takes a different approach to this problem.
Wallpaper Upload Listener
We're going to create a new object called a WallpaperUploadListener
.
This object will be defined as a Symfony service.
Into this object we will inject our FileMover
service.
We will also tag this service to allow it to listen to Doctrine's prePersist
and preUpdate
events.
As parts of other tasks so far in this project we have already seen how all of this code should work. All we need to do now is bring everything together. Along the way we're going to learn just a little more about testing using PhpSpec too :)
Given that we know we need a new WallpaperUploadListener
object, let's use PhpSpec to desc
ribe the classes specification:
php vendor/bin/phpspec desc AppBundle/Event/Listener/WallpaperUploadListener
You can put your class anywhere you like. Note, no .php
extension.
This generates me a new spec:
Specification for AppBundle\Event\Listener\WallpaperUploadListener created in /path/to/wallpaper/spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php.
I'm going to run the spec immediately to let it create me the corresponding new implementation file:
php vendor/bin/phpspec run spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php
Note now that I'm running a specific spec file, with the file extension, and from the spec
directory.
The output:
AppBundle/Event/Listener/WallpaperUploadListener
11 - it is initializable
class AppBundle\Event\Listener\WallpaperUploadListener does not exist.
100% 1
1 specs
1 example (1 broken)
18ms
Do you want me to create `AppBundle\Event\Listener\WallpaperUploadListener`
for you?
[Y/n]
y
Class AppBundle\Event\Listener\WallpaperUploadListener created in /Users/Shared/Development/wallpaper/src/AppBundle/Event/Listener/WallpaperUploadListener.php.
100% 1
1 specs
1 example (1 passed)
13ms
I follow the Doctrine documentation guidance here. I create new public methods for each event using a method name that is the event name.
We're interested in Doctrine's prePersist
and preUpdate
events. Therefore, I'm going to have two new public functions
:
// src/AppBundle/Event/Listener/WallpaperUploadListener.php
public function prePersist(LifecycleEventArgs $args)
{
}
public function preUpdate(PreUpdateEventArgs $args)
{
}
One question you might have at this point is how, when writing this code, did I know I would get a LifecycleEventArgs
and PreUpdateEventArgs
parameters for these two functions?
From the Doctrine documentation.
These arguments give me options. I can look at their contents and do interesting stuff.
There's quite a lot of stuff to do. We need to make sure we do all of it. That's why we're testing :)
We've committed the TDD cardinal sin so far. We're writing code before writing a test. In truth I rarely follow TDD 100% of the time. I normally use git's branching feature to prototype, saving the branch as a reference before re-writing in TDD.
That's ideal world though :)
Let's create some tests for these methods:
// spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php
function it_can_prePersist(LifecycleEventArgs $eventArgs)
{
$this->prePersist($eventArgs);
}
function it_can_preUpdate(PreUpdateEventArgs $eventArgs)
{
$this->preUpdate($eventArgs);
}
And these tests should be passing at this point. After all, we did this back to front.
What Does This Thing Need To Do?
Let's think about what would be very helpful to us here, if it happened.
In both cases - whether creating (prePersist
) or updating (preUpdate
), we want to make sure we move the uploaded wallpaper file.
We don't need to concern ourselves with how the wallpaper file has been uploaded. At this point, it will have been uploaded somehow (by our form), and we can start working with the file.
Whilst we have access to the image file, why don't we do what we did back in our excursion into learning about console commands? Why not pull out the image size data using getimagesize
:
[
0 => $width,
1 => $height
] = getimagesize($pathToImageFile);
As we are in the pre
phase, we can still make changes to our entity object before it is saved (insert
ed in) to the database.
Let's add this as a concept into our tests:
// spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php
function it_can_prePersist(LifecycleEventArgs $eventArgs)
{
// setup - not actually used in our tests just yet
$fakeTempPath = '/tmp/some.file';
$fakeRealPath = '/path/to/my/project';
// the method we are testing
$this->prePersist($eventArgs);
// what we expect to have happened, if this test is passing
$this->fileMover->move($fakeTempPath, $fakeRealPath)->shouldHaveBeenCalled();
}
function it_can_preUpdate(PreUpdateEventArgs $eventArgs)
{
$this->preUpdate($eventArgs);
}
We're re-using that same concept from the previous video of spying on our system under test.
To do this we will need to look at the calls made to the fileMover
object that's being injected into our WallpaperUploadListener
.
We haven't properly configured any of this yet, I just want to say what I need to happen, for this system to be considered in working order.
Currently the fileMover
is not being injected into the WallpaperUploadListener
. Let's fix that in our test:
<?php
// spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php
namespace spec\AppBundle\Event\Listener;
use AppBundle\Event\Listener\WallpaperUploadListener;
use AppBundle\Service\FileMover;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class WallpaperUploadListenerSpec extends ObjectBehavior
{
// this method will always be called before any
// of our PhpSpec tests are run
function let(FileMover $fileMover)
{
// PhpSpec will now check our implementation
// for a __construct function with one argument
$this->beConstructedWith($fileMover);
}
function it_is_initializable()
{
$this->shouldHaveType(WallpaperUploadListener::class);
}
function it_can_prePersist(LifecycleEventArgs $eventArgs)
{
$fakeTempPath = '/tmp/file.path';
$fakeRealPath = '/my/project/path';
$this->prePersist($eventArgs);
$this->fileMover->move($fakeTempPath, $fakeRealPath)->shouldHaveBeenCalled();
}
function it_can_preUpdate(PreUpdateEventArgs $eventArgs)
{
$this->preUpdate($eventArgs);
}
}
I've not bothered repeating any of this test yet for the preUpdate
method. No point at this stage.
Now that PhpSpec knows our constructor will be given a FileMover
instance, it will try and __construct
our WallpaperUploadListener
with one.
Our WallpaperUploadListener
doesn't even have a __construct
function. PhpSpec will dutifully create one for us.
Given the concierge service that PhpSpec brings us, it would be super nice if it could type hint the method arguments it generates for us. Unfortunately if it can, I don't know how to make it do so.
We need to do a little addition then here:
<?php
// src/AppBundle/Event/Listener/WallpaperUploadListener.php
namespace AppBundle\Event\Listener;
use AppBundle\Service\FileMover;
use Doctrine\ORM\Event as Event;
class WallpaperUploadListener
{
/**
* @var FileMover
*/
private $fileMover;
public function __construct(FileMover $fileMover)
{
$this->fileMover = $fileMover;
}
Simply adding in the constructor argument hasn't fixed the problem. It's just got us a step further.
If you remember back to the previous video, we covered how to use PhpSpec to spy on our system's behaviour by using the awesomely named Collaborator
objects.
If we want to call shouldHaveBeenCalled
, we need some way of accessing that object. We can make it available inside the class by setting it as a property on our Spec:
// spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php
class WallpaperUploadListenerSpec extends ObjectBehavior
{
private $fileMover;
function let(FileMover $fileMover)
{
$this->fileMover = $fileMover;
$this->beConstructedWith($fileMover);
}
And now, when we run our tests, each step can access that collaborator if needed, and spy on what it does. Nice.
php vendor/bin/phpspec run spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php
AppBundle/Event/Listener/WallpaperUploadListener
28 - it can prePersist
no calls have been made that match:
Double\AppBundle\Service\FileMover\P2->move(exact("/tmp/file.path"), exact("/my/project/path"))
but expected at least one.
66% 33% 3
1 specs
3 examples (2 passed, 1 failed)
62ms
In a way, it's as though we're being hand held through what we need to do to make this system start to work.
Unlike in the previous test, however, here things get a little more complex.
We aren't directly working with the FileMover
anymore. It is now a dependency of the current code we are using.
Our prePersist
method doesn't allow us to directly pass in the two parameters we expect to be being used:
"/tmp/file.path"
"/my/project/path"
What the heck do we do?
That's exactly what we are about to tackle in the very next video.