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