⚠️ Warning ⚠️
This article was written 4 years and 11 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 👍 !