Defensive Counter Measures
Towards the end of the previous video we had started writing the tests / specification of how our WallpaperUploadListener
service should work.
We had seen how we would need to react to creating new, and updating existing Wallpaper entities by way of Doctrine's prePersist
and preUpdate
events.
As part of this process we would need to move the uploaded wallpaper file from wherever PHP is temporarily storing the uploaded file, to a location on disk that we control. To do this we could use our FileMover
service.
In order to access the FileMover
service we would need to inject it into our WallpaperUploadListener
.
From personal experience, this is where testing starts to become a little more confusing.
We had a service we control that relies on a service we control. How on Earth do we test this? Let's crack on, and find out.
Testing Nested Dependencies
The tricky part of our test setup now is that we want to ensure that FileMover::move
has been been called with some specific arguments, but we don't directly have access to those arguments any more.
Let's take a moment to think about where these two arguments may come from. As a quick recap, here's our test spec so far:
// /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();
}
This is a failing test at present.
It fails because we're making an assumption:
We expect that after prePersist
has been called with the given $eventArgs
, that the fileMover->move
method should have been called as part of this process, using the $fakeTempPath
and $fakeRealPath
variables.
The problem here is that $fakeTempPath
and $fakeRealPath
are not being passed to the prePersist
call, so how would it have any knowledge about them?
It doesn't... so the test fails.
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/some.file"), exact("/path/to/my/project/some.file"))
but expected at least one.
66% 33% 3
1 specs
3 examples (2 passed, 1 failed)
67ms
At this stage we have no implementation logic at all in our real prePersist
method.
We know that as part of this process we expect, at some point, to call the fileMover->move
method.
This move
method expects these two arguments.
Behind the scenes - and outside our control - when our prePersist
method is called, it will be called with an instance of LifecycleEventArgs
. We don't need to worry about how this happens, we are simply hooking into this process to help ourselves.
If we get given this LifecycleEventArgs
object, we can make use of the public methods it provides. These are:
getEntity
getEntityManager
There are a bunch of ways to figure out what this object's public methods are. To figure this out, a quick trip to the documentation will tell us all we need to know.
Getting access to the entity manager may be helpful to us a little later on.
More important to us immediately is being able to access the entity. There is a little gotcha here that we need to take care of - and it's certainly not immediately obvious either.
If you remember, a few videos back we discussed how if we hook into Doctrine's lifecycle events, our WallpaperUploadListener
's prePersist
and preUpdate
event listener methods will be called every time an entity is either persisted, or updated.
This means this code will be called for every entity. Not just wallpapers, but Category
, and any other entities we create in our project.
Our WallpaperUploadListener
really needn't concern itself with anything other than Wallpaper
objects.
Let's get defensive and ensure we only run this code if we are working with a Wallpaper
instance:
// /spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php
function it_returns_early_if_prePersist_LifecycleEventArgs_entity_is_not_a_Wallpaper_instance(
LifecycleEventArgs $eventArgs
)
{
$this->prePersist($eventArgs)->shouldReturn(false);
}
This test isn't really helping us that much just yet.
It's also pretty confusing, and the name is not helping.
But don't be put off, we are actively working on this code and are still in the exploratory phase. Things may look and feel a little rough around the edges. We will smooth them out as we go.
This test still fails as our implementation doesn't do anything - if we ran the test now it would fail as returning null
, rather than the expected false
.
We could do the bare minimum to make this test pass:
// /src/AppBundle/Event/Listener/WallpaperUploadListener.php
public function prePersist(LifecycleEventArgs $eventArgs)
{
return false;
}
And whilst we now have a passing test, again it's fairly meaningless.
What we want to check is that if the entity returned by a called to $eventArgs->getEntity();
is anything other than a Wallpaper
, then we expected to immediately return false;
.
Otherwise, we will continue on and do "other stuff".
Let's add this logic to our prePersist
method:
// /src/AppBundle/Event/Listener/WallpaperUploadListener.php
public function prePersist(LifecycleEventArgs $eventArgs)
{
if (false === $eventArgs->getEntity() instanceof Wallpaper) {
return false;
}
return true;
}
Great, now our test should pass and there is a little more sense as to why.
But we aren't done here just yet.
It might be that we have a few places inside this method where we return false
. Maybe we return false
if any of our guard clauses fail. Maybe we have a few guard clauses in our method.
Consider The Unhappy Path
It would be nice to ensure other things did not happen here. One such thing is checking that our file mover did not try to move any files.
We can add this into our test logic:
// /spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php
use Prophecy\Argument; // make sure to add in this use statement
function it_returns_early_if_prePersist_LifecycleEventArgs_entity_is_not_a_Wallpaper_instance(
LifecycleEventArgs $eventArgs
)
{
$this->prePersist($eventArgs)->shouldReturn(false);
$this->fileMover->move(
Argument::any(),
Argument::any()
)->shouldNotHaveBeenCalled();
}
Ok, things just got a little stranger :)
Let's cover this in a little more depth.
We have our original assertion - that if we try to call prePersist
, and the returned value from $eventArgs->getEntity();
is not an instance of Wallpaper
, then we should return false;
.
None of that has changed.
You may be curious as to exactly what is being returned when PHP actually runs $eventArgs->getEntity();
- to answer this, it will be null
. If we don't explicitly tell our PhpSpec setup what to return for a given method call, it will return null
by default.
This test passes because null
is not an instance of Wallpaper
:)
We can make this more explicit. We can tell our $eventArgs->getEntity();
call that, when called, it should return something else:
// /spec/AppBundle/Event/Listener/WallpaperUploadListenerSpec.php
use AppBundle\Entity\Category; // make sure to add in this use statement
function it_returns_early_if_prePersist_LifecycleEventArgs_entity_is_not_a_Wallpaper_instance(
LifecycleEventArgs $eventArgs
)
{
$eventArgs->getEntity()->willReturn(new Category());
$this->prePersist($eventArgs)->shouldReturn(false);
}
Here we are being more explicit. We are saying that when the getEntity
method is called on the collaborator $eventArgs
object, we want to pretend a new Category
instance was returned.
Again, a Category
is not an instanceof Wallpaper
, so this guard clause is met, and our test still passes.
Essentially we are able to set up our system in such a way that we can test how it responds to very specific situations and circumstances. So long as we provide all the known circumstances that should happen in production, we can ensure our code behaves as expected. Quite cool :)
But let's get back to this line:
use Prophecy\Argument;
$this->fileMover->move(
Argument::any(),
Argument::any()
)->shouldNotHaveBeenCalled();
We checked that our method return false;
.
It would be super useful to double check that somehow the move
method was not also inadvertently called.
We know that when we call move
, we must pass in two arguments - the path to move from
, and the path to move to
.
We're making an assertion here. We're describing in our test code a task that we're interested in observing.
We haven't yet covered where these two bits of data will come from. We don't need to worry about the specifics though, we can use the underlying Prophecy library (included as part of PhpSpec) to help us preemptively describe our way through this process.
Argument::any()
is going to allow us to check that this function was called with any argument - string
, int
, SomeSpecialType
- or in our case, was not called with any arguments.
I like to think of this as my insurance policy. It doesn't matter what arguments this function is being called with at this point, as we don't want it to be called at all.