Skip to content

Instantly share code, notes, and snippets.

@Jibbarth
Last active September 19, 2023 15:23
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Jibbarth/c45838ede5cde76b2856530d32df7754 to your computer and use it in GitHub Desktop.
Save Jibbarth/c45838ede5cde76b2856530d32df7754 to your computer and use it in GitHub Desktop.
PestPHP on Symfony demo

PestPHP on Symfony demo

pest logo

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 👌 :

first output with pest

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 demo

Pest is still young, but it's promising !

Congrat Nuno for this awesome work and thanks to let me try it 👍 !

@ugo-fl
Copy link

ugo-fl commented Sep 19, 2023

Thanks for the post, really well written !

Although when testing on my own environment, I stumble upon an error :

You must set the KERNEL_CLASS environment variable to the fully-qualified class name of your Kernel in phpunit.xml [...]

And when I add the following in my phpunit.xml :

<server name="KERNEL_CLASS" value="Controller\Kernel"/>

which is the correct fully-qualified class name of my src/Kernel.php file, I still get an error :

Class "Controller\Kernel" doesn't exist or cannot be autoloaded. Check that the KERNEL_CLASS value in phpunit.xml matches the fully-qualified class name of your Kernel or override the "P\Tests\Controller\RecipientControllerTest::createKernel()" method.

Do you have any idea where this could come from ?

@Jibbarth
Copy link
Author

Hello @ugo-fl

Thanks for feedback. Keep in mind this article was write before the v1 of pest IIRC.

Regarding your issue, on a standard Symfony app, the Kernel class is App\Kernel, not Controller\Kernel. It lives at src/Kernel.php.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment