Test Driven Wallpaper Delete - Part 1


In this video we are going to work towards a test-driven Wallpaper Delete process. Again, much like our test-drive approaches to Wallpaper Create, and Wallpaper Update, we will refer to our prototype implementation - our untested Wallpaper Delete process.

During our untested approach we ended up with two different listeners - a WallpaperUploadListener, and a WallpaperDeleteListener.

As we have discovered during our test-driven approach, a better name for our listener is simply WallpaperListener, and as a result, putting the different processes (create, update) inside this single listener has made sense. Taking this further, it also makes sense - to me, at least - to put the delete logic in here, too.

You needn't do this. You may prefer one listener for each event. I'm going with this approach as it is working fairly well so far, and would only add a more complicated implementation if our requirements changed.

Our first step is going to be to add a tag to our Symfony service definition for our wallpaper_listener:

# app/config/services.yml

services:

    app.event.listener.wallpaper_listener:
        class: AppBundle\Event\Listener\WallpaperListener
        arguments:
            - "@app.service.file_mover"
            - "@app.service.wallpaper_file_path_helper"
            - "@app.service.image_file_dimensions_helper"
        tags:
            - { name: doctrine.event_listener, event: prePersist }
            - { name: doctrine.event_listener, event: preUpdate }
            - { name: doctrine.event_listener, event: preRemove }

We have no direct test for this, so it's easy to forget to do this process :) Not for us, however, as we have done it up front.

As we know we will be listening for preRemove events, we should start writing some tests to cover this process.

Looking at our untested approach, our preRemove implementation is as follows:

// src/AppBundle/Event/Listener/WallpaperDeleteListener.php

    public function preRemove(LifecycleEventArgs $args)
    {
        /**
         * @var $entity Wallpaper
         */
        $entity = $args->getEntity();

        $this->fileDeleter->delete(
            $entity->getFilename()
        );

        $entity->setFile(null);
    }

Testing this should be fairly straightforward, given what we know at this point.

// spec/AppBundle/Event/Listener/WallpaperListenerSpec.php

    function it_can_preRemove(LifecycleEventArgs $eventArgs, Wallpaper $wallpaper)
    {
        $wallpaper->getFilename()->willReturn('fake-filename.jpg');
        $eventArgs->getEntity()->willReturn($wallpaper);

        $this->preRemove($eventArgs);

        $this->fileDeleter->delete('fake-filename.jpg')->shouldHaveBeenCalled();

        // this won't work as expected
        // $wallpaper->setFile(null)->shouldHaveBeenCalled();
    }

Let's break this down to be absolutely clear what's happening.

Our test is called it_can_preRemove, because we expected to be able to call a method called preRemove.

This test will need two objects:

  • LifecycleEventArgs $eventArgs
  • Wallpaper $wallpaper

We will get PhpSpec to provide a mock for both.

Even though we don't own the implementation of LifecycleEventArgs, we haven't had any issues with mocking it so far, so I would persist with this approach until PhpSpec dictates otherwise. This is something we have covered throughout the videos in this series.

The first two lines of our test are setup. It helps to read them bottom-to-top.

From our implementation we know we will need to call getEntity on our instance of LifecycleEventArgs. We expect this to return a Wallpaper instance.

What if it doesn't?

We should write another test to cover this.

// spec/AppBundle/Event/Listener/WallpaperListenerSpec.php

    function it_can_preRemove(LifecycleEventArgs $eventArgs, Wallpaper $wallpaper)
    {
        // * snip *
    }

    function it_will_not_continue_with_preRemove_if_not_a_Wallpaper_instance()
    {

    }

What's nice about this is that we have probably just identified a bug in our untested approach.

Remember in our prePersist and preUpdate methods we added a chunk of guard logic:

    // if not Wallpaper entity, return false
    if (false === $entity instanceof Wallpaper) {
        return false;
    }

This is because, as we have covered previously, Doctrine is going to generate prePersist, preUpdate, and preRemove events whenever we create, update, or delete any entity.

We need to make sure our listener code reacts only to entities of type Wallpaper.

This is good stuff. Our tests are making us think at a deeper level, and stop us from laser focusing only on the happy path.

Ok, back to our test code.

    $wallpaper->getFilename()->willReturn('fake-filename.jpg');
    $eventArgs->getEntity()->willReturn($wallpaper);

When we call getEntity, we want to force PhpSpec to return our mocked $wallpaper object.

From our untested implementation we know that when we call the delete method of our fileDeleter service, we will need to pass in the Wallpaper's filename property. Therefore we fake what the return value of a call to getFilename will be.

Next, we call the method we are testing, passing in our now-setup $eventArgs object:

    $this->preRemove($eventArgs);

The implementation for preRemove is rather basic. We aren't doing any heavy lifting, but rather gathering together all the bits of data that we will need, and then using these bits of data to make the appropriate calls to underlying services which will do the heavy lifting:

$this->fileDeleter->delete('fake-filename.jpg')->shouldHaveBeenCalled();

We will use a service called fileDeleter to delete our given file by filename.

We faked the filename, so we should expect that fake filename to have been used as the only argument when invoking the delete method.

We don't have any implementation for this as yet, but our tests will guide us towards making this the reality.

Lastly in our untested implementation we null'ed out the file property on our Wallpaper class:

$entity->setFile(null);

It would be really nice if we could spy on our Wallpaper entity to see that a call to setFile was made with the argument of null:

$wallpaper->setFile(null)->shouldHaveBeenCalled();

We're going to hit upon an issue with this, but it's a potential unintuitive "gotcha" so we will cover this more in the next video.

An alternative approach here might be to use a real instance of Wallpaper, and then validate that a call to getFile returns null after our test has run.

We might need to alter our tests as we go through. We have the benefit of foresight given that we have the untested / prototype code to review. When writing tests for code that doesn't exist, typically I find the test process requests a few revisions to get to the final code.

Now let's run our new test:

php vendor/bin/phpspec run spec/AppBundle/Event/Listener/WallpaperListenerSpec.php

AppBundle/Event/Listener/WallpaperListener
 143  - it can preRemove
      method AppBundle\Event\Listener\WallpaperListener::preRemove not found.

AppBundle/Event/Listener/WallpaperListener
 155  - it will not continue with preRemove if not a Wallpaper instance
      todo: write pending example

    14%                               71%                               14%      7
1 specs
7 examples (5 passed, 1 pending, 1 broken)
75ms

  Do you want me to create
  `AppBundle\Event\Listener\WallpaperListener::preRemove()` for you?
                                                                         [Y/n]
y

  Method AppBundle\Event\Listener\WallpaperListener::preRemove() has been created.

AppBundle/Event/Listener/WallpaperListener
 143  - it can preRemove
      property fileDeleter not found.

AppBundle/Event/Listener/WallpaperListener
 155  - it will not continue with preRemove if not a Wallpaper instance
      todo: write pending example

    14%                               71%                               14%      7
1 specs
7 examples (5 passed, 1 pending, 1 broken)
30ms

PhpSpec has helpfully generated us the method stub for preRemove inside WallpaperListener.

It's pointing out that we don't have any class property called fileDeleter, so we probably need to fix that before going much further.

A Basic File Deleter

We know from our untested / prototype approach that our FileDeleter service is going to be hardcoded towards our local filesystem. We've also said that in the future we might want to move our image files from local disk up to Amazon S3, or a similar service.

Thinking about this we probably will need multiple implementations of our FileDeleter. We will start with one for local disk, and in time add more as needed to delete from remote file systems.

By using an interface here, we can standardise our approach to deleting a file. Exactly how a file is deleted really is of no concern of the WallpaperListener.

In truth, we will probably need to rethink this approach in the future, but putting an interface here will still help us more than harm.

Let's get PhpSpec to help us through our test-driven implementation of a LocalFilesystemFileDeleter:

php vendor/bin/phpspec desc AppBundle/Service/LocalFilesystemFileDeleter

Specification for AppBundle\Service\LocalFilesystemFileDeleter created in /path/to/my/wallpaper/spec/AppBundle/Service/LocalFilesystemFileDeleterSpec.php.

That's the spec generated. Running this spec will get PhpSpec to do the dirty work of creating the expected file:

php vendor/bin/phpspec run spec/AppBundle/Service/LocalFilesystemFileDeleterSpec.php

AppBundle/Service/LocalFilesystemFileDeleter
  11  - it is initializable
      class AppBundle\Service\LocalFilesystemFileDeleter does not exist.

                                      100%                                       1
1 specs
1 example (1 broken)
23ms

  Do you want me to create `AppBundle\Service\LocalFilesystemFileDeleter` for
  you?
                                                                         [Y/n]
y
Class AppBundle\Service\LocalFilesystemFileDeleter created in /path/to/my/wallpaper/src/AppBundle/Service/LocalFilesystemFileDeleter.php.

                                      100%                                       1
1 specs
1 example (1 passed)
11ms

Immediately we can update our test code to reflect that we want this newly generated LocalFilesystemFileDeleter to implement our required interface:

<?php

// spec/AppBundle/Service/LocalFilesystemFileDeleterSpec.php

namespace spec\AppBundle\Service;

use AppBundle\Service\LocalFilesystemFileDeleter;
// don't forget this line:
use AppBundle\Service\FileDeleter;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class LocalFilesystemFileDeleterSpec extends ObjectBehavior
{
    function it_is_initializable()
    {
        $this->shouldHaveType(LocalFilesystemFileDeleter::class);
        $this->shouldImplement(FileDeleter::class);
    }
}

If we try to run this now, PhpSpec is understandably unhappy:

php vendor/bin/phpspec run spec/AppBundle/Service/LocalFilesystemFileDeleterSpec.php

AppBundle/Service/LocalFilesystemFileDeleter
  12  - it is initializable
      expected an instance of AppBundle\Service\FileDeleter, but got
      [obj:AppBundle\Service\LocalFilesystemFileDeleter].

                                      100%                                       1
1 specs
1 example (1 failed)
25ms

We're basically just inventing things out of thin air here.

Let's create the expected interface:

<?php

// src/AppBundle/Service/FileDeleter.php

namespace AppBundle\Service;

interface FileDeleter
{
    public function delete(string $pathToFile);
}

Our interface is very straightforward.

It says that anything that implements this interface will need to have one public function called delete, which will take a single argument, a string of $pathToFile.

To make our latest PhpSpec test pass, all we need to do is ensure we implement that interface in LocalFilesystemFileDeleter and we should be good to go:

<?php

namespace AppBundle\Service;

class LocalFilesystemFileDeleter implements FileDeleter
{
    public function delete(string $pathToFile)
    {
        // TODO: Implement delete() method.
    }
}

Notice here that because FileDeleter is in the same directory as our LocalFilesystemFileDeleter, we don't need a use statement.

Because we now implements the FileDeleter interface we must have the expected method, even though currently it's just a stub that doesn't do anything.

Our test now happily passes:

php vendor/bin/phpspec run spec/AppBundle/Service/LocalFilesystemFileDeleterSpec.php
                                      100%                                       1
1 specs
1 example (1 passed)
28ms

Let's take a quick look at the implementation of FileDeleter that we have from our prototype:

<?php

// src/AppBundle/Service/FileDeleter.php

namespace AppBundle\Service;

use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\File\File;

class FileDeleter
{
    /**
     * @var Filesystem
     */
    private $filesystem;
    /**
     * @var string
     */
    private $filePath;

    public function __construct(Filesystem $filesystem, string $filePath)
    {
        $this->filesystem = $filesystem;
        $this->filePath = $filePath;
    }

    public function delete($pathToFile)
    {
        $this->filesystem->remove(
            $this->filePath . '/' . $pathToFile
        );
    }
}

Writing a test for this should be pretty easy at this point. There's nothing new here:

<?php

// spec/AppBundle/Service/LocalFilesystemFileDeleterSpec.php

namespace spec\AppBundle\Service;

use AppBundle\Service\LocalFilesystemFileDeleter;
use AppBundle\Service\FileDeleter;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Symfony\Component\Filesystem\Filesystem;

class LocalFilesystemFileDeleterSpec extends ObjectBehavior
{
    function let(Filesystem $filesystem)
    {
        $this->beConstructedWith($filesystem, '/expected/base/path');
    }

    function it_is_initializable()
    {
        $this->shouldHaveType(LocalFilesystemFileDeleter::class);
        $this->shouldImplement(FileDeleter::class);
    }

    function it_can_delete()
    {
        $this->delete('to/some-file.jpg');

        $this
            ->filesystem
            ->remove('/expected/base/path/to/some-file.jpg')
            ->shouldHaveBeenCalled()
        ;
    }
}

We've covered it_is_initializable, so let's focus on the other two new functions:

    function let(Filesystem $filesystem)
    {
        $this->beConstructedWith($filesystem, '/expected/base/path');
    }

Every test /example that PhpSpec runs through will initially set up / __construct the underlying LocalFilesystemFileDeleter instance.

We use the let function to make sure that LocalFilesystemFileDeleter is configured with the expected properties.

In this case we mock Symfony's Filesystem. Again, we don't own this, but mocking is always my first attempt. I only change this up if PhpSpec is less than happy with my motives.

We pass this mock through to the __construct call:

    public function __construct(Filesystem $filesystem, string $filePath)
    {
        $this->filesystem = $filesystem;
        $this->filePath = $filePath;
    }

We also pass in a string: '/expected/base/path' as our second argument.

This should be enough to get the LocalFilesystemFileDeleter instance constructed properly.

The meat of our test is as follows:

    function it_can_delete()
    {
        $this->delete('to/some-file.jpg');

        $this
            ->filesystem
            ->remove('/expected/base/path/to/some-file.jpg')
            ->shouldHaveBeenCalled()
        ;
    }

Next we start by calling the delete method (as defined on our interface) with a path we just made up. It doesn't matter that this path doesn't exist in the real world, as we will never really try to delete a file in this test. All we care about is that the functions are being called with the right arguments.

As we mocked the filesystem, next we spy on what happened to that filesystem instance after a call to delete has been made.

Our assertion is that the filesystem's remove method should have been called with the path of:

'/expected/base/path/to/some-file.jpg'

This is the path we passed in to the constructor via our let method call, concatenated with the path we provided when calling delete.

Of course none of this works just yet.

Let's run our tests and get PhpSpec to help us out:

php vendor/bin/phpspec run spec/AppBundle/Service/LocalFilesystemFileDeleterSpec.php

AppBundle/Service/LocalFilesystemFileDeleter
  18  - it is initializable
      method AppBundle\Service\LocalFilesystemFileDeleter::__construct not found.

AppBundle/Service/LocalFilesystemFileDeleter
  24  - it can delete
      method AppBundle\Service\LocalFilesystemFileDeleter::__construct not found.

                                      100%                                       2
1 specs
2 examples (2 broken)
56ms

  Do you want me to create
  &#x60;AppBundle\Service\LocalFilesystemFileDeleter::__construct()&#x60; for you?
                                                                         [Y/n]
y
  Method AppBundle\Service\LocalFilesystemFileDeleter::__construct() has been created.

AppBundle/Service/LocalFilesystemFileDeleter
  24  - it can delete
      property filesystem not found.

                  50%                                     50%                    2
1 specs
2 examples (1 passed, 1 broken)
56ms

After running through our tests, PhpSpec will have created us a __construct function inside our LocalFilesystemFileDeleter implementation, but we must provide further logic to make the it_can_delete test into a passing state.

We have an implementation for this which should get us to a passing state. Let's copy / paste from our prototype and see where we get:

<?php

namespace AppBundle\Service;

use Symfony\Component\Filesystem\Filesystem;

class LocalFilesystemFileDeleter implements FileDeleter
{
    /**
     * @var Filesystem
     */
    private $filesystem;
    /**
     * @var string
     */
    private $filePath;

    public function __construct(Filesystem $filesystem, string $filePath)
    {
        $this->filesystem = $filesystem;
        $this->filePath = $filePath;
    }

    public function delete(string $pathToFile)
    {
        $this->filesystem->remove(
            $this->filePath . '/' . $pathToFile
        );
    }
}

If we run the tests now, they still fail:

php vendor/bin/phpspec run spec/AppBundle/Service/LocalFilesystemFileDeleterSpec.php

AppBundle/Service/LocalFilesystemFileDeleter
  24  - it can delete
      property filesystem not found.

                  50%                                     50%                    2
1 specs
2 examples (1 passed, 1 broken)
57ms

The error message:

property filesystem not found

Is what we need to focus on.

This is potentially confusing.

In our test file we have the following:

<?php

// spec/AppBundle/Service/LocalFilesystemFileDeleterSpec.php

class LocalFilesystemFileDeleterSpec extends ObjectBehavior
{
    // * snip *

    function it_can_delete()
    {
        $this->delete('to/some-file.jpg');

        $this
            ->filesystem
            ->remove('/expected/base/path/to/some-file.jpg')
            ->shouldHaveBeenCalled()
        ;
    }
}

The error message relates to our call to $this->filesystem.

We're trying to access $this->filesystem but we haven't defined it inside our test spec.

Fixing this is easy, but I wanted to highlight this as a potential problem area:

<?php

// spec/AppBundle/Service/LocalFilesystemFileDeleterSpec.php

class LocalFilesystemFileDeleterSpec extends ObjectBehavior
{
    private $filesystem;

    function let(Filesystem $filesystem)
    {
        $this->filesystem = $filesystem;

        $this->beConstructedWith($filesystem, '/expected/base/path');
    }

    // * snip *

    function it_can_delete()
    {
        $this->delete('to/some-file.jpg');

        $this
            ->filesystem
            ->remove('/expected/base/path/to/some-file.jpg')
            ->shouldHaveBeenCalled()
        ;
    }
}

Notice now that we have defined $filesystem as a property of the class. And now our tests pass:

php vendor/bin/phpspec run spec/AppBundle/Service/LocalFilesystemFileDeleterSpec.php
                                      100%                                       2
1 specs
2 examples (2 passed)
58ms

Ok, so we are well on our way.

The last thing we need to here is to add in the service definition for our new LocalFilesystemFileDeleter:

# app/config/services.yml

    app.service.local_filesystem_file_deleter:
        class: AppBundle\Service\LocalFilesystemFileDeleter
        arguments:
            - "@filesystem"
            - "%kernel.root_dir%/../web/images"

The service definitions are starting to feel a little messy. We will come back to sort these out in a few videos time.

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