Testing with PhpSpec to Guide Our Implementation
Next we need to consider how this "File Move" process actually happens.
We said in a previous video that out of the three immediately obvious options, we are going to use the Symfony Filesystem component.
This being a Symfony service, we know by now that if we want to use this component we need to do a few things.
We need to inject an implementation of the Filesystem
into this service.
This means we will need to create or update our service definition for our FileMover
. Well, we don't yet have a service, so let's create one. And whilst creating one, let's inject the Filesystem
:
# /app/config/services.yml
services:
app.wallpaper_mover:
class: AppBundle\Service\FileMover
That's the basis of our service definition.
Next, we want to pass in the Filesystem
. How do we do this? We need to pass in the service id that represents the Filesystem inside Symfony:
php bin/console debug:container filesystem
Information for Service "filesystem"
====================================
------------------ -----------------------------------------
Option Value
------------------ -----------------------------------------
Service ID filesystem
Class Symfony\Component\Filesystem\Filesystem
Tags -
Public yes
Synthetic no
Lazy no
Shared yes
Abstract no
Autowired no
Autowiring Types -
------------------ -----------------------------------------
The Service ID
option is what we need. Our service will therefore be @filesystem
. Don't forget the @
symbol prefix.
# /app/config/services.yml
services:
app.wallpaper_mover:
class: AppBundle\Service\FileMover
arguments:
- "@filesystem"
And inside our implementation once more:
<?php
// /AppBundle/Service/FileMover.php
namespace AppBundle\Service;
use Symfony\Component\Filesystem\Filesystem;
class FileMover
{
private $fileSystem;
public function __construct(Filesystem $fileSystem)
{
$this->fileSystem = $fileSystem;
}
public function move($existingFilePath, $newFilePath)
{
// TODO: write logic here
}
}
We only need to look in the Symfony docs for the Filesystem to figure out the next step.
public function move($existingFilePath, $newFilePath)
{
$this->fileSystem->rename($existingFilePath, $newFilePath);
}
}
If we run our test now though, we've broken both tests:
php vendor/bin/phpspec run
AppBundle/Service/FileMover
23 - it is initializable
exception [err:ArgumentCountError("Too few arguments to function AppBundle\Service\FileMover::__construct(), 0 passed and exactly 1 expected")] has been thrown.
AppBundle/Service/FileMover
28 - it can move a file from temporary to controlled storage
exception [err:ArgumentCountError("Too few arguments to function AppBundle\Service\FileMover::__construct(), 0 passed and exactly 1 expected")] has been thrown.
100% 2
1 specs
2 examples (2 broken)
27ms
The issue is that we're now reliant on the Filesystem
when constructing our FileMover
. PhpSpec, however, is not aware of any of this.
What we need to do is to tell PhpSpec that when constructing our FileMover
instance, it should be given an instance of Filesystem
.
Unlike in our services, we won't use the constructor in a PhpSpec to do this. Instead, we could inject an object that resembles a Filesystem
into each function:
<?php
// /spec/AppBundle/Service/FileMover.php
namespace spec\AppBundle\Service;
use AppBundle\Service\FileMover;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Symfony\Component\Filesystem\Filesystem;
class FileMoverSpec extends ObjectBehavior
{
function it_is_initializable(Filesystem $filesystem)
{
$this->beConstructedWith($filesystem);
$this->shouldHaveType(FileMover::class);
}
function it_can_move_a_file_from_temporary_to_controlled_storage(Filesystem $filesystem)
{
$this->beConstructedWith($filesystem);
$temporaryPath = '/some/fake/temporary/path';
$controlledPath = '/some/fake/real/path.ext';
$this->move($temporaryPath, $controlledPath)->shouldReturn(true);
$this->filesystem->rename($temporaryPath, $controlledPath)->shouldHaveBeenCalled();
}
}
However, this is a little repetative (and tedious) if you have a bunch of examples in your spec. Instead, it is better to move this common construction logic in to the let
function:
<?php
// /spec/AppBundle/Service/FileMover.php
namespace spec\AppBundle\Service;
use AppBundle\Service\FileMover;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Symfony\Component\Filesystem\Filesystem;
class FileMoverSpec extends ObjectBehavior
{
private $filesystem;
function let(Filesystem $filesystem)
{
$this->filesystem = $filesystem;
$this
->beConstructedWith($filesystem)
;
}
function it_is_initializable()
{
$this->shouldHaveType(FileMover::class);
}
function it_can_move_a_file_from_temporary_to_controlled_storage()
{
$temporaryPath = '/some/fake/temporary/path';
$controlledPath = '/some/fake/real/path.ext';
$this->move($temporaryPath, $controlledPath)->shouldReturn(true);
}
}
Same outcome, but a little tidier.
Now, if we were to run our tests - surely they pass now, right?
Wrong:
php vendor/bin/phpspec run
AppBundle/Service/FileMover
33 - it can move a file from temporary to controlled storage
expected true, but got null.
50% 50% 2
1 specs
2 examples (1 passed, 1 failed)
40ms
Ok, back to one passing and our new test is still failing.
The error tells us why:
expected true, but got null.
This becomes obvious if we look at the move
implementation:
public function move($existingFilePath, $newFilePath)
{
$this->fileSystem->rename($existingFilePath, $newFilePath);
}
}
We don't return anything. This method is void, but we expect it to return true
.
It turns out that the rename
method in Symfony's Filesystem
is also void.
It will throw
if things go wrong, but it won't return anything on success.
We will simply return true
then ourselves. If the rename / move process fails for any reason, this will throw and we'll deal with it elsewhere as appropriate.
public function move($existingFilePath, $newFilePath)
{
$this->fileSystem->rename($existingFilePath, $newFilePath);
return true;
}
}
Ok, test time:
php vendor/bin/phpspec run
100% 2
1 specs
2 examples (2 passed)
69ms
Cool, it passes.
But what have we tested here?
We have tested that the class exists, is constructable, and that it returns true
when calling move
.
We've gone to all this trouble of injecting the Filesystem
but we haven't checked the expected method has been called. Let's fix that:
<?php
// /spec/AppBundle/Service/FileMover.php
namespace spec\AppBundle\Service;
use AppBundle\Service\FileMover;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Symfony\Component\Filesystem\Filesystem;
class FileMoverSpec extends ObjectBehavior
{
private $filesystem;
function let(Filesystem $filesystem)
{
$this->filesystem = $filesystem;
$this
->beConstructedWith($filesystem)
;
}
function it_is_initializable()
{
$this->shouldHaveType(FileMover::class);
}
function it_can_move_a_file_from_temporary_to_controlled_storage()
{
$temporaryPath = '/some/fake/temporary/path';
$controlledPath = '/some/fake/real/path.ext';
$this->move($temporaryPath, $controlledPath)->shouldReturn(true);
// new line here
$this->filesystem->rename($temporaryPath, $controlledPath)->shouldHaveBeenCalled();
}
}
Now we can spy on the rename
method and ensure that not only was it called, but that it was called with the expected properties. This gives us much more confidence.
Ok, we've smashed through a whole host of PhpSpec here, and we've got the foundations of our file upload process in place. We're going to continue on with this in the next video, building up a robust file upload and move process that we can rely on as our site grows.