No Tests - Part 1 - Uploading Files in EasyAdminBundle


We've now defended against 'accidentally' running our listener code for every entity.

We know now that this prePersist method will only go further if the given entity is an instance of Wallpaper.

Now we do need to figure out how move will be called with the expected arguments.

Let's quickly recap our original test:

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

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

        $this->prePersist($eventArgs);

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

We need to know two things:

  • How to get the temporary path
  • How to get the path we wish to use in the longer term

What we know is:

  • The temporary path will be provided to us by PHP
  • Our real path will be $projectRoot . '/web/images/' . $fileName

Now this is, in my experience, where things get super interesting.

What we are about to do is to cover two different approaches to this same problem.

First, we are going to do this without tests.

Have you ever thought to yourself:

I will just write this code now, and then I promise (fingers-crossed) that I will come back and write tests for this later.

I know I have.

Skipping the tests immediately is an easy productivity win.

Unfortunately, the curve against this practice remaining favourable over time is a drop off a cliff.

Still, we do this to ourselves.

I've often wondered what the outcome of doing a task with AND without tests would be. The same task.

If we skip the tests, what impact will this actually have on our code?

What about on the overall system design?

We are going to build this next step without tests.

Then, we are going to switch branches back to our starting point, and re-write the exact same solution, but test-driven.

The questions I'm posing to answer is:

Will these two approaches lead to the same, or different overall system design?

No tests. Shield's Up, Red Alert!

It's best if you imagine a picture of William Riker for the above sub-title.

On a smaller system, which this objectively is, it can be very easy to retain the entire system work flow in your head.

If all you ever build is small systems - ones you use exclusively - then having no tests, in my opinion, is fine.

The only person you will ever impact is yourself. Assuming it's a small system, one for hobby work or learning, then tests will slow you down, and are another new thing to have to learn on top of the mountain of "stuff" you already feel you need to know to feel comfortable using Symfony.

Deep breath. And relax.

You can skip all of what I am about to show you.

I did for ages.

But then one day you will want to become a better developer, and take the greatest pride in your work.

When I deliver code, I want it to work, first time. I'm guessing you do, too.

It's good for your career, but also, fixing bugs sucks. If there was a single way to write code in a manner that consistently produced code with fewer bugs, wouldn't it make sense to do this?

I hope you're in agreement, but for the benefit of science, let's start with the alternative.

Prototyping

We start with our listener.

<?php

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

namespace AppBundle\Event\Listener;

use Symfony\Component\HttpFoundation\File\UploadedFile;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use AppBundle\Entity\Wallpaper;
use AppBundle\Service\FileMover;

class WallpaperUploadListener
{
    private $fileMover;

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

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

        // todo:
        // - move the uploaded file
        // - update the Wallpaper entity with new info

        return true;
    }
}

We need to move the uploaded file from the temporary, to its longer term location.

At this stage, this is simply a path on our local hard disk. There are so many improvements that we could make on this.

Earlier, I said we need to know two things:

  • How to get the temporary path
  • How to get the path we wish to use in the longer term

We can look at the methods that Symfony's UploadedFile class will give us, and work from there.

To recap, Symfony will turn the data submitted via the form into a Wallpaper object for us.

One of the properties we have on our Wallpaper is file. This is being used to store the object representing the uploaded file from the form. When using the File form field type, the uploaded file will be represented by an instance of Symfony's UploadedFile.

By accessing that object we can figure out properties of the file.

From these properties we can figure out both the file's current, temporary location, and also determine the path we want to use in the longer term.

Figuring out the temporary path of an UploadedFile is deceptively easy.

UploadedFile extends File which extends \SplFileInfo.

There is a method on \SplFileInfo that gives us exactly what we want:

getPathName

And we also need to figure out the new file name.

Now, looking at the bigger picture here, we are running a Wallpaper Website. We're going to have a bunch of wallpaper images for people to browse, and we want to maximise our chances of people finding us via Google, etc.

Therefore, if we are spending time diligently crafting beautiful file names for SEO Reasons, then the last thing we want is to have our filenames mangled on upload.

Fortunately we can get access to the uploaded file's name by using getClientOriginalName().

<?php

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

namespace AppBundle\Event\Listener;

use Symfony\Component\HttpFoundation\File\UploadedFile;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use AppBundle\Entity\Wallpaper;
use AppBundle\Service\FileMover;

class WallpaperUploadListener
{
    private $fileMover;

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

    public function prePersist(LifecycleEventArgs $args)
    {
        $entity = $eventArgs->getEntity();

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

        /**
         * @var $entity Wallpaper
         */

        // get access to the file
        $file = $entity->getFile();

        // todo:
        //  - figure out new file path
        $newFilePath = '/this/is/nonsense/' . $file->getClientOriginalName();

        // move the uploaded file
        // args: $currentPath, $newPath
        $this->fileMover->move(
            $file->getPathname(),
            $newFilePath
        );

        // todo:
        // - update the Wallpaper entity with new info

        return true;
    }
}

This method feels like it's doing too much to me already.

It's tempting to inject the path the project's kernel.root_dir here (or kernel.project_dir in Symfony 3.3+), and replace the this/is/nonsense part of the variable.

The seemingly better way to do this would be to create a new, more specialised implementation of the file mover - a LocalFilesystemFileMover.

Quite a crazy specific name, but it does what it says on the tin.

I'm working on the assumption that we will only ever want to upload Wallpaper files. This is a wallpaper website after all. If we needed to upload other types of file then maybe we might even consider calling this a WallpaperLocalFilesystemFileMover, or similar.

There's a fine line with this sort of thing, and it's fairly easy to go overly abstract which only serves to cloud more than clarify.

Continuing on, let's extract an Interface from our FileMover:

<?php

// src/AppBundle/Service/FileMover.php

interface FileMover
{
    public function move($existingFilePath, $newFilePath);
}

It doesn't matter specifically where the existing file path, or the new file paths are. They may be on local disk, or on S3, or Dropbox, or FTP... hopefully this interface is sufficient to cover all implementations. We shall see.

Recently I've been trying to stop using the Interface suffix on my interfaces:

FileMover vs FileMoverInterface.

I currently prefer the former. There is no right or wrong, to the very best of my knowledge.

We already have our FileMover.php file, which I'm now renaming to LocalFilesystemFileMover.php:

<?php

// src/AppBundle/Service/LocalFilesystemFileMover.php

namespace AppBundle\Service;

use Symfony\Component\Filesystem\Filesystem;

class LocalFilesystemFileMover implements FileMover
{
    // rest of the implementation remains the same

As we've changed the name of this implementation, we will need to update the corresponding service definition as well:

# app/config/services.yml

services:

    app.service.local_filesystem_file_mover:
        class: AppBundle\Service\LocalFilesystemFileMover
        arguments:
            - "@filesystem"

As we've changed the name of this service, we will also need to update any other service definitions that use this service:

# app/config/services.yml

    app.doctrine_event_listener.wallpaper_upload_listener:
        class: AppBundle\Event\Listener\WallpaperUploadListener
        arguments:
            # - '@app.service.file_mover'
            - '@app.service.local_filesystem_file_mover'

Even though we have a more specialised FileMover implementation, so long as this class implements FileMover, we can inject it without issue. Think of this FileMover interface as our contract. So long as the class we pass in meets the contract, it can be used - or swapped out for a different class that also fulfills that contract.

Extracting out to the FileMover interface allows us to decouple the specifics of the way files are named, and stored, from the process of "uploading a wallpaper".

By relying on an interface for our FileMover::move call, we could - in theory - more easily swap out saving files on our servers local hard disk, to a more robust storage mechanism such as Amazon S3.

One way in which we might do this later on is by making use of FlySystem. This would make it super easy to store on local disk, or Amazon S3, or Dropbox. Right now, however, adding this in seems like overkill.

We now have the FileMover interface, and a concrete LocalFilesystemFileMover implementation of that interface.

Into the LocalFilesystemFileMover we might think to inject the kernel.root_dir path, and hide away a bunch of unnecessary implementation details from the wider WallpaperUploadListener.

However, if we look at the signature for move, we expect to be given a full path to move too: $newFilePath.

It's not really the job of the FileMover implementation to figure out the $newFilePath.

Even though it seems overkill to do so, I'm going to create a separate service that can help us determine the $newFilePath. It's a very simple service with nothing new that we haven't already seen before:

<?php

// src/AppBundle/Service/WallpaperFilePathHelper.php

namespace AppBundle\Service;

class WallpaperFilePathHelper
{
    /**
     * @var string
     */
    private $wallpaperFileDirectory;

    public function __construct(string $wallpaperFileDirectory)
    {
        $this->wallpaperFileDirectory = $wallpaperFileDirectory;
    }

    /**
     * @param string $newFileName
     * @return string
     */
    public function getNewFilePath(string $newFileName)
    {
        return $this->wallpaperFileDirectory . $newFileName;
    }
}

Don't forget the service definition:

# app/config/services.yml

services:

    app.service.wallpaper_file_path_helper:
        class: AppBundle\Service\WallpaperFilePathHelper
        arguments:
            - "%kernel.root_dir%/../web/images/"

Now, let's also inject the WallpaperFilePathHelper into our listener, and get a new file path which we can pass on to the LocalFilesystemFileMover::move call:

<?php

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

namespace AppBundle\Event\Listener;

use Symfony\Component\HttpFoundation\File\UploadedFile;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use AppBundle\Entity\Wallpaper;
use AppBundle\Service\FileMover;
use AppBundle\Service\WallpaperFilePathHelper;

class WallpaperUploadListener
{
    private $fileMover;
    private $wallpaperFilePathHelper;

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

    public function prePersist(LifecycleEventArgs $eventArgs)
    {
        $entity = $eventArgs->getEntity();

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

        /**
         * @var $entity Wallpaper
         */

        // get access to the file
        $file = $entity->getFile();

        $newFilePath = $this->wallpaperFilePathHelper
            ->getNewFilePath(
                $file->getClientOriginalName()
            )
        ;

        // move the uploaded file
        // args: $currentPath, $newPath
        $this->fileMover->move(
            $file->getPathname(),
            $newFilePath
        );

        // todo:
        // - update the Wallpaper entity with new info

        return true;
    }
}

Again, let's update the service definition to correctly inject this additional dependency:

# app/config/services.yml

    app.doctrine_event_listener.wallpaper_upload_listener:
        class: AppBundle\Event\Listener\WallpaperUploadListener
        arguments:
            - '@app.service.local_filesystem_file_mover'
            - "@app.service.wallpaper_file_path_helper"

Remember, the order of these arguments matters.

We're about half way there.

We still need to hook up our listener so that the prePersist (and preUpdate) methods will be fired when appropriate.

We also need to tease out some information from the uploaded wallpaper file to help populate our entity - such as the width and height of the image, the slug, and make sure we're storing the correct information about the file on the entity itself.

We will get onto this 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