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 inserted, or updated, or deleted - 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 describe 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 (inserted 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.

Code For This Course

Get the code for this course.

Episodes

# Title Duration
1 Introduction and Site Demo 02:14
2 Setup and a Basic Wallpaper Gallery 08:43
3 Pagination 08:24
4 Adding a Detail View 04:47
5 Creating a Home Page 11:14
6 Creating our Wallpaper Entity 07:50
7 Wallpaper Setup Command - Part 1 - Symfony Commands As a Service 05:57
8 Wallpaper Setup Command - Part 2 - Injection Is Easy 08:53
9 Wallpaper Setup Command - Part 3 - Doing It With Style 05:37
10 Doctrine Fixtures - Part 1 - Setup and Category Entity Creation 08:52
11 Doctrine Fixtures - Part 2 - Relating Wallpapers with Categories 05:56
12 EasyAdminBundle - Setup and Category Configuration 06:02
13 EasyAdminBundle - Wallpaper Setup and List View 07:46
14 EasyAdminBundle - Starting with Wallpaper Uploads 05:57
15 Testing with PhpSpec to Guide Our Implementation 03:39
16 Using PhpSpec to Test our FileMover 05:34
17 Symfony Dependency Testing with PhpSpec 08:47
18 Defensive Counter Measures 06:33
19 No Tests - Part 1 - Uploading Files in EasyAdminBundle 11:01
20 No Tests - Part 2 - Uploading Files in EasyAdminBundle 07:05
21 Don't Mock What You Don't Own 09:36
22 You've Got To Take The Power Back 07:36
23 Making Symfony Work For Us 08:56
24 Testing The Wallpaper File Path Helper 15:11
25 Finally, It Works! 14:56
26 Why I Prefer Not To Use Twig 16:51
27 Fixing The Fixtures 11:20
28 Untested Updates 14:30
29 Untested Updates Part Two - Now We Can Actually Update 06:33
30 Adding an Image Preview On Edit 12:31
31 Delete Should Remove The Wallpaper Image File 11:02
32 Getting Started Testing Wallpaper Updates 10:02
33 Doing The Little Before The Big 08:13
34 Tested Image Preview... Sort Of :) 07:36
35 Finishing Up With a Tested Wallpaper Update 10:41
36 Test Driven Wallpaper Delete - Part 1 11:06
37 Test Driven Wallpaper Delete - Part 2 11:57
38 EasyAdminBundle Login Form Tutorial 08:01