Tested Image Preview... Sort Of :)
We got a lot done in the previous video. We now have a working Wallpaper Create, and Update facility.
It would be good to get the extra pieces from our untested approach added in to our tested setup. Therefore next up we will tackle the Wallpaper Image Preview on Update feature.
There are a bunch of ways we could go about testing this - after all, it is a visual 'thing', so do we test that the thing we see is behaving as expected, or that the code that makes this all work behaves properly? Or do we test both?
My opinion here is to focus on unit tests (think: PhpSpec) for as long as possible, avoiding testing the visual side of things if at all possible. Rather than refer to these sorts of tests as "visual tests", I will switch to calling them "Acceptance Tests", which is generally what these sorts of tests fall under.
- Acceptance testing is slow
- Acceptance testing is prone to failure
Acceptance tests are slow because they require interacting with your site as though a real visitor were "driving the mouse". This is typically done with a tool like Selenium, and whilst nice to have and fun to watch, they do take a while to run (minutes usually, rather than seconds).
A bigger concern for me is that Acceptance Tests tend to throw up a lot of false positives. In other words, they fail for unexpected reasons - maybe Selenium died mid run, maybe someone changed a class name for a button from btn
to button
or similar, or maybe Selenium just couldn't find the element on this run (trust me, it happens!). Acceptance tests tend to focus too rigidly on on-page specifics (like CSS class names, etc) rather than overall site goals.
True acceptance testing requires a wider discussion than this - there are ways around using CSS class names and specific HTML selectors, for example.
Now, this is a can of worms I do not want to open the lid on - at least no further than this.
Instead, we will focus on using PhpSpec as far as possible, which will unfortunately leave some parts of this feature without a test. This will predominantly relate to the view, as we shall see in the next video.
From our untested approach we know that we will need something similar to following form type:
<?php
namespace AppBundle\Form\Type;
use AppBundle\Entity\Wallpaper;
use AppBundle\Form\DataTransformer\FileTransformer;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CustomFileType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('file', FileType::class)
;
$builder
->addViewTransformer(
new FileTransformer(
$this->logger
)
)
;
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
/**
* @var $entity Wallpaper
*/
$entity = $form->getParent()->getData();
if ($entity) {
$view->vars['file_uri'] = (null === $entity->getFilename())
? null
: '/images/' . $entity->getFilename()
;
}
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'file_uri' => null,
]);
}
}
We have a variation of this which we created already:
<?php
namespace AppBundle\Form\Type;
use AppBundle\File\SymfonyUploadedFile;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class UploadedFileType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('file', FileType::class, [
'multiple' => false,
])
;
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
$view->vars['full_name'] = $view->vars['full_name'] . '[file]';
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefaults([
'data_class' => SymfonyUploadedFile::class
])
;
}
}
There are differences here which we have covered already in this series. If unsure at this point, please go back through the untested and tested approaches - or leave a comment below and I will do my best to clarify further. I wanted to show multiple approaches to the same problem, as there is no "one set way", at least not to my knowledge.
Let's start by creating ourselves a PhpSpec for this existing form implementation:
php vendor/bin/phpspec desc AppBundle/Form/Type/UploadedFileType
Specification for AppBundle\Form\Type\UploadedFileType created in /path/to/my/wallpaper/spec/AppBundle/Form/Type/UploadedFileTypeSpec.php.
And running this new spec:
php vendor/bin/phpspec run spec/AppBundle/Form/Type/UploadedFileTypeSpec.php
100% 1
1 specs
1 example (1 passed)
26ms
We need to write some tests for what we already have. Try not to over think this, and let's start with the buildForm
method:
<?php
// spec/AppBundle/Form/Type/UploadedFileTypeSpec.php
namespace spec\AppBundle\Form\Type;
use AppBundle\Form\Type\UploadedFileType;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class UploadedFileTypeSpec extends ObjectBehavior
{
function it_is_initializable()
{
$this->shouldHaveType(UploadedFileType::class);
}
function it_can_build_a_form(FormBuilderInterface $formBuilder)
{
$this->buildForm($formBuilder, []);
$formBuilder->add('file', FileType::class, [
'multiple' => false,
])->shouldHaveBeenCalled();
}
}
We've covered how we should not mock what we do not own. However, we don't own FormBuilderInterface
, yet we are mocking it anyway.
Well, PhpSpec is generally good to us if what we are trying to mock is an interface
, which in this case $formBuilder
will be. We've covered mocking interfaces already, and why this is a little confusing.
With a mocked FormBuilderInterface
we can call the buildForm
method on our form type, passing in our mock and an empty array of options:
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('file', FileType::class, [
'multiple' => false,
])
;
}
Inside our real buildForm
implementation we expect add
to be called with some given arguments.
As such, after a call to buildForm
has been made, we can expect that our mock form builder should have been called with the expected arguments:
$formBuilder->add('file', FileType::class, [
'multiple' => false,
])->shouldHaveBeenCalled();
Does this pass?
php vendor/bin/phpspec run spec/AppBundle/Form/Type/UploadedFileTypeSpec.php
100% 2
1 specs
2 examples (2 passed)
56ms
Cool.
Onwards!
Next we need to test the buildView
method, and of course, things here won't be quite so simple.
public function buildView(FormView $view, FormInterface $form, array $options)
{
$view->vars['full_name'] = $view->vars['full_name'] . '[file]';
}
FormView
is not an interface
. This will potentially cause us problems.
Likewise, we aren't using a setter
for vars
, but instead modifying a public property which happens to contain an array. PhpSpec is almost certainly not going to appreciate this.
Here's the gist of the test:
<?php
// spec/AppBundle/Form/Type/UploadedFileTypeSpec.php
namespace spec\AppBundle\Form\Type;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use AppBundle\Form\Type\UploadedFileType;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class UploadedFileTypeSpec extends ObjectBehavior
{
// * snip *
function it_can_build_a_view(
FormView $view,
FormInterface $form
)
{
$view->vars['full_name'] = 'quidditch';
$this->buildView($view, $form, []);
$view->vars['full_name']->shouldReturn('quidditch[file]');
}
}
Will this work?
php vendor/bin/phpspec run spec/AppBundle/Form/Type/UploadedFileTypeSpec.php
AppBundle/Form/Type/UploadedFileType
30 - it can build a view
notice: Indirect modification of overloaded property PhpSpec\Wrapper\Collaborator::$vars has no effect in
/path/to/my/wallpaper/spec/AppBundle/Form/Type/UploadedFileTypeSpec.php line 34
Oh my, no.
We need to heed the advice already espoused: "Don't mock what you don't own."
Let's try again, this time with a real implementation of the FormView
:
<?php
// spec/AppBundle/Form/Type/UploadedFileTypeSpec.php
namespace spec\AppBundle\Form\Type;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use AppBundle\Form\Type\UploadedFileType;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class UploadedFileTypeSpec extends ObjectBehavior
{
// * snip *
function it_can_build_a_view(
FormInterface $form
) {
$view = new FormView();
$view->vars['full_name'] = 'quidditch';
$this->buildView($view, $form, []);
$view->vars['full_name']->shouldReturn('quidditch[file]');
}
}
When we run the tests:
php vendor/bin/phpspec run spec/AppBundle/Form/Type/UploadedFileTypeSpec.php
AppBundle/Form/Type/UploadedFileType
30 - it can build a view
exception [err:Error("Call to a member function shouldReturn() on string")] has been thrown.
Rats, so close!
What's going wrong here?
Well, everything up until the last line of the test seems good.
But here:
$view->vars['full_name']->shouldReturn('quidditch[file]');
We are trying to call a method on a string.
See, $view->vars['full_name']
returns a string.
We cannot call PhpSpec methods on a string.
What to do?
Well, there may be a way to do this properly with PhpSpec, but I am not aware of how to do so.
If this were our own code then we would likely refactor here not use a public property in this way. At least, that would be my first port of call.
However, we don't own Symfony's form implementation, and I'm certainly not wanting to start meddling too deep here just to get PhpSpec to play ball.
Instead - and with a hint of irony - let's use a library from the creator of Symfony's form component to get ourselves out of this problem:
composer require --dev webmozart/assert
With over 22 million installs at the time of writing, we should be in good company here.
Now all we need to do is assert the outcome of $view->vars['full_name']
is as we expect:
<?php
namespace spec\AppBundle\Form\Type;
use AppBundle\Form\Type\UploadedFileType;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\Test\FormBuilderInterface;
use Webmozart\Assert\Assert;
class UploadedFileTypeSpec extends ObjectBehavior
{
function it_is_initializable()
{
$this->shouldHaveType(UploadedFileType::class);
}
function it_can_build_a_form(FormBuilderInterface $formBuilder)
{
$this->buildForm($formBuilder, []);
$formBuilder->add('file', FileType::class, [
'multiple' => false,
])->shouldHaveBeenCalled();
}
function it_can_build_a_view(
FormInterface $form
) {
$view = new FormView();
$view->vars['full_name'] = 'quidditch';
$this->buildView($view, $form, []);
Assert::same(
$view->vars['full_name'],
'quidditch[file]'
);
}
}
And now:
php vendor/bin/phpspec run spec/AppBundle/Form/Type/UploadedFileTypeSpec.php
100% 3
1 specs
3 examples (3 passed)
21ms
Stay tuned for more Happy Days.
The last method to test is configureOptions
.
Again, we don't own OptionsResolver
and it's not an interface
either. Still, in the first instance I always try my luck:
function it_correctly_configures_the_expected_options(
OptionsResolver $resolver
)
{
$this->configureOptions($resolver);
$resolver
->setDefaults([
'data_class' => SymfonyUploadedFile::class
])
->shouldHaveBeenCalled()
;
}
And whaddayaknow?
php vendor/bin/phpspec run spec/AppBundle/Form/Type/UploadedFileTypeSpec.php
100% 4
1 specs
4 examples (4 passed)
66ms
This brings us to a point where we have a working test spec for our existing implementation. Now we're going to change this implementation, but with a confidence of knowing that by the end of this process, if all our tests pass, we didn't break anything.