⚠️ Warning ⚠️
This article was written 4 years and 7 months ago. The informations it contains may be outdated or no longer relevant.
PestPHP on Symfony demo
Nuno ask me to test his new test framework called Pest on a symfony application.
I decided to test it on Symfony Demo and rewrite all included tests with Pest.
Installation
Just after cloning the Symfony demo repository, I followed this doc: https://pestphp.com/docs/installation/
I faced with two problems :
In composer.json, the php config is setted to version 7.2.9, so I got this error:
[InvalidArgumentException]
Package pestphp/pest at version has a PHP requirement incompatible with your PHP version (7.2.9)
Fixed it by removing this config in composer.json
"config": {
- "platform": {
- "php": "7.2.9"
- },
Then, the minimum stability of the application is defined to "stable". As pest require nunomaduro/collision
in v5.0 and there is not stable version of this package yet, I add to change the minimum stability.
composer config minimum-stability beta
Analyse
Launched phpunit on project:
$ vendor/bin/phpunit
PHPUnit 9.1.4 by Sebastian Bergmann and contributors.
Testing
............................................... 47 / 47 (100%)
Time: 00:09.030, Memory: 64.50 MB
OK (47 tests, 112 assertions)
47 tests, 112 assertions, I have to do the same.
tests
├── bootstrap.php
├── Command
│ └── AddUserCommandTest.php
├── Controller
│ ├── Admin
│ │ └── BlogControllerTest.php
│ ├── BlogControllerTest.php
│ ├── DefaultControllerTest.php
│ └── UserControllerTest.php
├── Form
│ └── DataTransformer
│ └── TagArrayToStringTransformerTest.php
└── Utils
└── ValidatorTest.php
Sweet. Command tests use KernelTestCase, Controller tests use WebTestCase, and pure unit tests in Form and Utils.
Let's begin...
Using Pest
Unit Tests
The first test I want to rewrite was ValidatorTest. It seems to be the simpliest :
public function testValidateUsername(): void
{
$test = 'username';
$this->assertSame($test, $this->validator->validateUsername($test));
}
// ...
I move all tests
folder in an other, and let's initialize a new tests
folder for Pest.
Then, I create a new ValidatorTest
in Utils
with this :
<?php
use App\Utils\Validator;
beforeEach(function () {
$this->validator = new Validator();
});
/**
* @covers Validator::validateUsername
*/
test('validate username', fn ($test) => assertSame($test, $this->validator->validateUsername($test)))
->with(['test']);
test('validate empty username', fn ($value) => $this->validator->validateUsername($value))
->with([null, ''])
->throws(\Exception::class, 'The username can not be empty.');
test('validate username invalid', fn ($value) => $this->validator->validateUsername($value))
->with(['INVALID', 'jibé'])
->throws(\Exception::class, 'The username must contain only lowercase latin characters and underscores.');
/**
* @covers Validator::validatePassword
*/
test('validate password', fn ($test) => assertSame($test, $this->validator->validatePassword($test)))
->with(['password']);
test('validate empty password', fn ($value) => $this->validator->validatePassword($value))
->with([null, ''])
->throws(\Exception::class, 'The password can not be empty.');
test('validate invalid password', fn ($value) => $this->validator->validatePassword($value))
->with(['12345'])
->throws(\Exception::class, 'The password must be at least 6 characters long.');
/**
* @covers Validator::validateEmail
*/
test('validate email', fn ($test) => assertSame($test, $this->validator->validateEmail($test)))
->with(['@', 'contact@example.net']);
test('validate empty email', fn ($value) => $this->validator->validateEmail($value))
->with([null, ''])
->throws(\Exception::class, 'The email can not be empty.');
test('validate invalid email', fn ($value) => $this->validator->validateEmail($value))
->with(['invalid', 'example.net'])
->throws(\Exception::class, 'The email should look like a real email.');
/**
* @covers Validator::validateFullName
*/
test('validate fullname', fn ($test) => assertSame($test, $this->validator->validateFullName($test)))
->with(['Full Name']);
test('validate empty fullname', fn ($value) => $this->validator->validateFullName($value))
->with([null, ''])
->throws(\Exception::class, 'The full name can not be empty.');
Output is nice 👌 :
Comparing to original test, I gain ~ 40LOC. I added the @covers
annotations, to aerate a little the code, but it's not required.
One thing strange is the ✓ validate username invalid with (' i n v a l i d')
. I passed INVALID
data, and it seems to be transformed. However, after a check, the correct value is passed, seems to be only when display results.
Don't know if it's phpunit related or pest.
The second Test I migrate is the TagArrayToStringTransformerTest
. It's interresting because it use Mock. Let's see how it can be implemented with Pest.
On the original test, the class had the following method :
private function getMockedTransformer(array $findByReturnValues = []): TagArrayToStringTransformer
{
$tagRepository = $this->getMockBuilder(TagRepository::class)
->disableOriginalConstructor()
->getMock();
$tagRepository->expects($this->any())
->method('findBy')
->willReturn($findByReturnValues);
return new TagArrayToStringTransformer($tagRepository);
}
I didn't find a way to register such a function with $this in Pest. However, I add a function in my test file, and adding a TestCase argument :
function getMockedTransformer(\PHPUnit\Framework\TestCase $test, array $findByReturnValues = []): TagArrayToStringTransformer
{
$tagRepository = $test->getMockBuilder(TagRepository::class)
->disableOriginalConstructor()
->getMock();
$tagRepository->expects($test->any())
->method('findBy')
->willReturn($findByReturnValues);
return new TagArrayToStringTransformer($tagRepository);
}
Then, I can call it by adding $this in parameter :
it('create the right amount of tag', function () {
$tags = getMockedTransformer($this)->reverseTransform('Hello, Demo, How');
assertCount(3, $tags);
assertSame('Hello', $tags[0]->getName());
});
Maybe this function should be added in a Trait, and use the uses
feature of Pest.
Then the transform is pretty simple:
<?php
use App\Entity\Tag;
use App\Form\DataTransformer\TagArrayToStringTransformer;
use App\Repository\TagRepository;
it('create the right amount of tag', function () {
$tags = getMockedTransformer($this)->reverseTransform('Hello, Demo, How');
assertCount(3, $tags);
assertSame('Hello', $tags[0]->getName());
});
it('create the right amount of tags with too many commas', function () {
$transformer = getMockedTransformer($this);
assertCount(3, $transformer->reverseTransform('Hello, Demo,, How'));
assertCount(3, $transformer->reverseTransform('Hello, Demo, How,'));
});
it('trim names' , function () {
$tags = getMockedTransformer($this)->reverseTransform(' Hello ');
assertSame('Hello', $tags[0]->getName());
});
test('duplicate names', function () {
$tags = getMockedTransformer($this)->reverseTransform('Hello, Hello, Hello');
assertCount(1, $tags);
});
it('uses already defined tags', function () {
$persistedTags = [
createTag('Hello'),
createTag('World'),
];
$tags = getMockedTransformer($this, $persistedTags)->reverseTransform('Hello, World, How, Are, You');
assertCount(5, $tags);
assertSame($persistedTags[0], $tags[0]);
assertSame($persistedTags[1], $tags[1]);
});
test('transform', function () {
$persistedTags = [
createTag('Hello'),
createTag('World'),
];
$transformed = getMockedTransformer($this)->transform($persistedTags);
assertSame('Hello,World', $transformed);
});
function getMockedTransformer(\PHPUnit\Framework\TestCase $test, array $findByReturnValues = []): TagArrayToStringTransformer
{
$tagRepository = $test->getMockBuilder(TagRepository::class)
->disableOriginalConstructor()
->getMock();
$tagRepository->expects($test->any())
->method('findBy')
->willReturn($findByReturnValues);
return new TagArrayToStringTransformer($tagRepository);
}
function createTag(string $name): Tag
{
$tag = new Tag();
$tag->setName($name);
return $tag;
}
Let's see how it handle our lovely KernelTestCase
Integration Tests
Following docs, we can define the base TestCase
in a tests/Pest.php
file by directory.
So, for Command
folder, I want it use KernelTestCase
// tests/Pest.php
<?php
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
uses(KernelTestCase::class)->in('Command');
For this one, I tried to create a Trait for the executeCommand
:
// tests/Command/ExecuteAddUserCommandTrait.php
<?php
namespace App\Tests\Command;
use App\Command\AddUserCommand;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
trait ExecuteAddUserCommandTrait
{
private function executeCommand(array $arguments, array $inputs = []): void
{
self::bootKernel();
// this uses a special testing container that allows you to fetch private services
$command = self::$container->get(AddUserCommand::class);
$command->setApplication(new Application(self::$kernel));
$commandTester = new CommandTester($command);
$commandTester->setInputs($inputs);
$commandTester->execute($arguments);
}
}
and an other for custom assertion assertUserCreated
:
<?php
namespace App\Tests\Command;
use App\Repository\UserRepository;
trait UserCreationAssertion
{
private $userData = [
'username' => 'chuck_norris',
'password' => 'foobar',
'email' => 'chuck@norris.com',
'full-name' => 'Chuck Norris',
];
private function assertUserCreated(bool $isAdmin): void
{
$container = self::$container;
/** @var \App\Entity\User $user */
$user = $container->get(UserRepository::class)->findOneByEmail($this->userData['email']);
$this->assertNotNull($user);
$this->assertSame($this->userData['full-name'], $user->getFullName());
$this->assertSame($this->userData['username'], $user->getUsername());
$this->assertTrue($container->get('security.password_encoder')->isPasswordValid($user, $this->userData['password']));
$this->assertSame($isAdmin ? ['ROLE_ADMIN'] : ['ROLE_USER'], $user->getRoles());
}
}
Then, I add a use for theses trait in the AddUserCommandTest
:
<?php
namespace App\Tests\Command;
uses(UserCreationAssertion::class, ExecuteAddUserCommandTrait::class);
Let's transform other tests..
The original test use a DataProvider, so I created a dataset for it :
dataset('isAdmin', static function () {
yield [false];
yield [true];
});
And there is the full test :
<?php
namespace App\Tests\Command;
uses(UserCreationAssertion::class, ExecuteAddUserCommandTrait::class);
dataset('isAdmin', static function () {
yield [false];
yield [true];
});
it('create user in non interactive mode', function (bool $isAdmin) {
$input = $this->userData;
if ($isAdmin) {
$input['--admin'] = 1;
}
$this->executeCommand($input);
$this->assertUserCreated($isAdmin);
})->with('isAdmin');
it('create user in interactive mode', function (bool $isAdmin) {
$this->executeCommand(
// these are the arguments (only 1 is passed, the rest are missing)
$isAdmin ? ['--admin' => 1] : [],
// these are the responses given to the questions asked by the command
// to get the value of the missing required arguments
array_values($this->userData)
);
$this->assertUserCreated($isAdmin);
})->with('isAdmin');
beforeEach(function () {
exec('stty 2>&1', $output, $exitcode);
$isSttySupported = 0 === $exitcode;
if ('Windows' === PHP_OS_FAMILY || !$isSttySupported) {
$this->markTestSkipped('`stty` is required to test this command.');
}
});
Functional tests
I call functionnal tests the tests which call a controller via a Client. To do this, we use in Symfony the WebTestCase
.
Adding it in tests/Pest.php
for all our controllers tests.
// tests/Pest.php
// ...
uses(WebTestCase::class)->in('Controller');
For the DefaultControllerTest
, it's pretty quick:
<?php
use App\Entity\Post;
use Symfony\Component\HttpFoundation\Response;
test('public urls', function (string $url) {
$client = static::createClient();
$client->request('GET', $url);
$this->assertResponseIsSuccessful(sprintf('The %s public URL loads correctly.', $url));
})->with(static function (): ?\Generator {
yield ['/'];
yield ['/en/blog/'];
yield ['/en/login'];
});
test('public blog posts', function () {
$client = static::createClient();
// the service container is always available via the test client
$blogPost = $client->getContainer()->get('doctrine')->getRepository(Post::class)->find(1);
$client->request('GET', sprintf('/en/blog/posts/%s', $blogPost->getSlug()));
$this->assertResponseIsSuccessful();
});
test('secure urls', function ($url) {
$client = static::createClient();
$client->request('GET', $url);
$this->assertResponseRedirects(
'http://localhost/en/login',
Response::HTTP_FOUND,
sprintf('The %s secure URL redirects to the login form.', $url)
);
})->with(static function (): ?\Generator {
yield ['/en/admin/post/'];
yield ['/en/admin/post/new'];
yield ['/en/admin/post/1'];
yield ['/en/admin/post/1/edit'];
});
For BlogControllerTest and UserControllerTest, same. Just copy paste content of tests into Pest Style :
// ...
it('can post new comment', function () {
$client = static::createClient([], [
'PHP_AUTH_USER' => 'john_user',
'PHP_AUTH_PW' => 'kitten',
]);
$client->followRedirects();
// Find first blog post
$crawler = $client->request('GET', '/en/blog/');
$postLink = $crawler->filter('article.post > h2 a')->link();
$client->click($postLink);
$crawler = $client->submitForm('Publish comment', [
'comment[content]' => 'Hi, Symfony!',
]);
$newComment = $crawler->filter('.post-comment')->first()->filter('div > p')->text();
$this->assertSame('Hi, Symfony!', $newComment);
});
For Admin/BlogControllerTest
, there was an other private method in original class, I declared it as simple function here :
<?php
use App\Repository\PostRepository;
use Symfony\Component\HttpFoundation\Response;
it('deny access for regular user', function ($httpMethod, $url) {
$client = static::createClient([], [
'PHP_AUTH_USER' => 'john_user',
'PHP_AUTH_PW' => 'kitten',
]);
$client->request($httpMethod, $url);
$this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
})->with(static function(): \Generator {
yield ['GET', '/en/admin/post/'];
yield ['GET', '/en/admin/post/1'];
yield ['GET', '/en/admin/post/1/edit'];
yield ['POST', '/en/admin/post/1/delete'];
});
it('has admin backend home page', function () {
$client = static::createClient([], [
'PHP_AUTH_USER' => 'jane_admin',
'PHP_AUTH_PW' => 'kitten',
]);
$client->request('GET', '/en/admin/post/');
$this->assertResponseIsSuccessful();
$this->assertSelectorExists(
'body#admin_post_index #main tbody tr',
'The backend homepage displays all the available posts.'
);
});
it('can create new post', function () {
$postTitle = 'Blog Post Title '.mt_rand();
$postSummary = generateRandomString(255);
$postContent = generateRandomString(1024);
$client = static::createClient([], [
'PHP_AUTH_USER' => 'jane_admin',
'PHP_AUTH_PW' => 'kitten',
]);
$client->request('GET', '/en/admin/post/new');
$client->submitForm('Create post', [
'post[title]' => $postTitle,
'post[summary]' => $postSummary,
'post[content]' => $postContent,
]);
$this->assertResponseRedirects('/en/admin/post/', Response::HTTP_FOUND);
/** @var \App\Entity\Post $post */
$post = self::$container->get(PostRepository::class)->findOneByTitle($postTitle);
$this->assertNotNull($post);
$this->assertSame($postSummary, $post->getSummary());
$this->assertSame($postContent, $post->getContent());
});
it('can duplicate post', function () {
$postTitle = 'Blog Post Title '.mt_rand();
$postSummary = generateRandomString(255);
$postContent = generateRandomString(1024);
$client = static::createClient([], [
'PHP_AUTH_USER' => 'jane_admin',
'PHP_AUTH_PW' => 'kitten',
]);
$crawler = $client->request('GET', '/en/admin/post/new');
$form = $crawler->selectButton('Create post')->form([
'post[title]' => $postTitle,
'post[summary]' => $postSummary,
'post[content]' => $postContent,
]);
$client->submit($form);
// post titles must be unique, so trying to create the same post twice should result in an error
$client->submit($form);
$this->assertSelectorTextSame('form .form-group.has-error label', 'Title');
$this->assertSelectorTextContains('form .form-group.has-error .help-block', 'This title was already used in another blog post, but they must be unique.');
});
it('can show post in admin', function () {
$client = static::createClient([], [
'PHP_AUTH_USER' => 'jane_admin',
'PHP_AUTH_PW' => 'kitten',
]);
$client->request('GET', '/en/admin/post/1');
$this->assertResponseIsSuccessful();
});
it('can edit post', function () {
$newBlogPostTitle = 'Blog Post Title '.mt_rand();
$client = static::createClient([], [
'PHP_AUTH_USER' => 'jane_admin',
'PHP_AUTH_PW' => 'kitten',
]);
$client->request('GET', '/en/admin/post/1/edit');
$client->submitForm('Save changes', [
'post[title]' => $newBlogPostTitle,
]);
$this->assertResponseRedirects('/en/admin/post/1/edit', Response::HTTP_FOUND);
/** @var \App\Entity\Post $post */
$post = self::$container->get(PostRepository::class)->find(1);
$this->assertSame($newBlogPostTitle, $post->getTitle());
});
it('can delete post', function () {
$client = static::createClient([], [
'PHP_AUTH_USER' => 'jane_admin',
'PHP_AUTH_PW' => 'kitten',
]);
$crawler = $client->request('GET', '/en/admin/post/1');
$client->submit($crawler->filter('#delete-form')->form());
$this->assertResponseRedirects('/en/admin/post/', Response::HTTP_FOUND);
$post = self::$container->get(PostRepository::class)->find(1);
$this->assertNull($post);
});
function generateRandomString(int $length): string
{
$chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
return mb_substr(str_shuffle(str_repeat($chars, ceil($length / mb_strlen($chars)))), 1, $length);
}
Final thoughts
Basculating all tests from Symfony Demo is pretty simple and everything works 🎉
At the end, I have 54 tests, so seven more of the original test suite. It's because the ->with()
function of Pest is really cool, and I added more value in it while rewriting tests.
I also liked ->throw()
function, to catch exception and messages.
But it's difficult, for me, to not having real context on $this
. Using PHPStorm, I don't have any autocomplete on $this.
Also, there is no "live" progress on tests. Pest display results file by file, not "test" by "test". When using the Client, it's a bit slow to get results.
Pest is still young, but it's promising !
Congrat Nuno for this awesome work and thanks to let me try it 👍 !