You've Got To Take The Power Back


Towards the end of the previous video we had seemingly coded ourselves into a corner.

Because of the way that \SplFileInfo works internally (read this if geeking) we can't mock it.

And anyway, we shouldn't.

We need to make Symfony bend to our will.

Our will here is that Symfony's UploadedFile should conform to the way our code works.

That means we need a standard way of working with uploaded files, of which Symfony's UploadedFile chicanery is just one possible implementation.

As Rage Against The Machine will tell you:

You gotta take the power back

(nerr nerr I know you're making guitar noises in your head. Eughhh, come-on!)

We need an interface.

What this interface does is standardises the way the system behaves, without worrying about the specifics of the implementation.

We're pushing the bounds of this being a unit test. It's pushing more towards being better served with an integration test. But we will persist.

Let's adapt our spec to describe what should happen:

// /spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php

use AppBundle\Entity\Wallpaper;
// - use Symfony\Component\HttpFoundation\File\UploadedFile;
// + use AppBundle\Model\FileInterface;

// ...

    function it_can_prePersist(
        LifecycleEventArgs $eventArgs,
        FileInterface $file
    )
    {
        $fakeTempPath = '/tmp/some.file';
        $fakeRealPath = '/path/to/my/project/some.file';

        $path = $fakeTempPath;
        $originalName = 'some.file';

        $wallpaper = new Wallpaper();
        $wallpaper->setFile($file);

        $eventArgs->getEntity()->willReturn($wallpaper);

        $this->prePersist($eventArgs)->shouldReturn(true);

        $this->fileMover->move($fakeTempPath, $fakeRealPath)->shouldHaveBeenCalled();
    }

We've not change a huge amount.

All we've done is started mocking our own interface rather than relying on the specifics of the way Symfony's UploadedFile implementation works.

Where does that get us towards a passing test?

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

AppBundle/Event/Listener/WallpaperUploadListener
  45  - it can prePersist
      collaborator does not exist : AppBundle\Model\FileInterface

                            75%                                     25%          4
1 specs
4 examples (3 passed, 1 broken)
60ms

  Would you like me to generate an interface `AppBundle\Model\FileInterface`
  for you?
                                                                         [Y/n]
y

Interface AppBundle\Model\FileInterface created in /Users/Shared/Development/wallpaper/src/AppBundle/Model/FileInterface.php.

AppBundle/Event/Listener/WallpaperUploadListener
  45  - it can prePersist
      no calls have been made that match:
        Double\AppBundle\Service\FileMover\P4->move(exact("/tmp/some.file"), exact("/path/to/my/project/some.file"))
      but expected at least one.

                            75%                                     25%          4
1 specs
4 examples (3 passed, 1 failed)
24ms

Quite a bit further.

We no longer care about the specifics of how the file retrieval process actually takes place. We simply care it behaves like we expect it too.

And for testing purposes, we can make it behave however the heck we like.

Then, when it comes to creating any implementations, so long as they behave the same way, they can conform to our interface, and life is good.

But:

That's not to say this way isn't more code to write.

As ever, trade-offs.

Back To The Implementation

We've just spent a ton of time in the tests.

Now it's time to use all that specification goodness that we just stewed up.

<?php

// /src/AppBundle/Event/Listener/WallpaperUploadListener.php

namespace AppBundle\Event\Listener;

use AppBundle\Service\FileMover;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;

class WallpaperUploadListener
{
    /**
     * @var FileMover
     */
    private $fileMover;

    public function __construct(FileMover $fileMover)
    {
        $this->fileMover = $fileMover;
    }

    public function prePersist(LifecycleEventArgs $eventArgs)
    {
        if (false === $eventArgs->getEntity() instanceof Wallpaper) {
            return false;
        }

        $file = $eventArgs->getEntity()->getFile();

        $this->fileMover->move(
            $file->getExistingFilePath(),
            $file->getNewFilePath()
        );

        return true;
    }

    public function preUpdate(PreUpdateEventArgs $eventArgs)
    {
    }
}

I've removed code hints here so be sure to watch the video for an IDE-warning friendly approach.

We now need to get back to PhpSpec and run our tests.

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

AppBundle/Event/Listener/WallpaperUploadListener
  45  - it can prePersist
      exception [err:TypeError("Argument 1 passed to AppBundle\Entity\Wallpaper::setFile() must implement interface AppBundle\Model\FileInterface, instance of PhpSpec\Wrapper\Collaborator given, called in /Users/Shared/Development/wallpaper/spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php on line 54")] has been thrown.

                            75%                                     25%          4
1 specs
4 examples (3 passed, 1 broken)
61ms

This one is a strange one on me.

To fix this we need to use a method called getWrappedObject.

The thing is, we aren't using an object. We are using an interface.

If we call to getWrappedObject then this will fix the problem.

I believe this fixes the problem because by calling getWrappedObject in PhpSpec we are really calling reveal in Prophecy. When we call reveal, this returns a dummy object that implements the interface. Such is my interpretation.

// /spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php

use AppBundle\Entity\Wallpaper;
// - use Symfony\Component\HttpFoundation\File\UploadedFile;
// + use AppBundle\Model\FileInterface;

// ...

    function it_can_prePersist(
        LifecycleEventArgs $eventArgs,
        FileInterface $file
    )
    {
        $fakeTempPath = '/tmp/some.file';
        $fakeRealPath = '/path/to/my/project/some.file';

        $path = $fakeTempPath;
        $originalName = 'some.file';

        $wallpaper = new Wallpaper();
        // change here
        $wallpaper->setFile(
            $file->getWrappedObject()
        );

        $eventArgs->getEntity()->willReturn($wallpaper);

        $this->prePersist($eventArgs)->shouldReturn(true);

        $this->fileMover->move($fakeTempPath, $fakeRealPath)->shouldHaveBeenCalled();
    }

Now when running PhpSpec it's able to notice that the first of our interface's expected methods is not available on whatever is passing for a FileInterface implementation, behind the scenes.

We need to define this as a valid method on the interface itself.

Fix this, and you will be met with the second missing method. This would go on, and on until every part of your test requirements had been met.

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

AppBundle/Event/Listener/WallpaperUploadListener
  45  - it can prePersist
      method `Double\FileInterface\P5::getExistingFilePath()` not found.

                            75%                                     25%          4
1 specs
4 examples (3 passed, 1 broken)
22ms

➜  wallpaper git:(cf52be8) ✗ php vendor/bin/phpspec run spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php

AppBundle/Event/Listener/WallpaperUploadListener
  45  - it can prePersist
      method `Double\FileInterface\P5::getNewFilePath()` not found.

                            75%                                     25%          4
1 specs
4 examples (3 passed, 1 broken)
61ms

To fix this:

<?php

// src/AppBundle/Model/FileInterface.php

namespace AppBundle\Model;

interface FileInterface
{
    public function getExistingFilePath();
    public function getNewFilePath();
}

To be clear here, we don't have a real implementation for this yet.

Adding our Symfony FileUpload based implementation will come shortly.

Running the tests now we see the file mover is being hit, but it doesn't appear to be getting the expected arguments:

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

AppBundle/Event/Listener/WallpaperUploadListener
  45  - it can prePersist
      no calls have been made that match:
        Double\AppBundle\Service\FileMover\P4->move(exact("/tmp/some.file"), exact("/path/to/my/project/some.file"))
      but expected at least one.
      Recorded `move(...)` calls:
        - move(null, null) @ src/AppBundle/Event/Listener/WallpaperUploadListener.php:43

                            75%                                     25%          4
1 specs
4 examples (3 passed, 1 failed)
65ms

This is because we haven't told our mock implementation how to respond when asked the two questions:

  • getExistingFilePath
  • getNewFilePath

To do so we need to tell our mock how to respond. For this, we can use willReturn:

// /spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php

    function it_can_prePersist(
        LifecycleEventArgs $eventArgs,
        FileInterface $file // new line
    )
    {
        $fakeTempPath = '/tmp/some.file';
        $fakeRealPath = '/path/to/my/project/some.file';

        // two new lines
        $file->getExistingFilePath()->willReturn($fakeTempPath);
        $file->getNewFilePath()->willReturn($fakeRealPath);

        $wallpaper = new Wallpaper();
        $wallpaper->setFile($file->getWrappedObject());

        $eventArgs->getEntity()->willReturn($wallpaper);

        $this->prePersist($eventArgs)->shouldReturn(true);

        $this->fileMover->move($fakeTempPath, $fakeRealPath)->shouldHaveBeenCalled();
    }

Finally, when we run our tests, they all pass:

php vendor/bin/phpspec run spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php
                                      100%                                       4
1 specs
4 examples (4 passed)
62ms

Does that mean we have a working solution?

Unfortunately not. As mentioned, this approach is going to lead to us writing more lines of code.

A lot of this code will be test code.

For comparative purposes, let's look at how the implementation differs from the comparative point we were at in the un-tested approach:

<?php

// /src/AppBundle/Event/Listener/WallpaperUploadListener.php

namespace AppBundle\Event\Listener;

use AppBundle\Entity\Wallpaper;
use AppBundle\Service\FileMover;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;

class WallpaperUploadListener
{
    /**
     * @var FileMover
     */
    private $fileMover;

    public function __construct(FileMover $fileMover)
    {
        $this->fileMover = $fileMover;
    }

    public function prePersist(LifecycleEventArgs $eventArgs)
    {
        if (false === $eventArgs->getEntity() instanceof Wallpaper) {
            return false;
        }

        /**
         * @var $entity Wallpaper
         */
        $entity = $eventArgs->getEntity();

        $filename = $entity->getFile()->getFilename();

        $existingFilePath = $entity->getFile()->getPathname();
        $newFilePath = '/Users/Shared/Development/wallpaper/web/images/' . $filename;

        $this->fileMover->move($existingFilePath, $newFilePath);

        $entity
            ->setFile($entity->getFile()->getPathname())
            ->setFilename($filename);

        return true;
    }

    public function preUpdate(PreUpdateEventArgs $eventArgs)
    {
    }
}

What are your thoughts so far?

Code For This Course

Get the code for this course.

Code For This Video

Get the code for this video.

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