Testing your code is very important as a software developer. It not only helps you prevent bugs when you’re adding or changing features, but a good test suite also gives you and your customers a lot of confidence in the stability of the product.
Testing in Symfony is fairly easy using the symfony/phpunit-bridge
package. You’ll write classes containing your tests and run them, no problem. But creating the classes adds a lot of boilerplate code. What if I told you it’s possible to get rid of a lot of boilerplate code and have a nice and elegant way of writing your tests as if you’re writing an English sentence? This is where Pest comes in!
What is Pest?
Pest is a testing framework which leverages the power of PHPUnit to run its tests. This is why Pest can also be used to run your PHPUnit test suite. You can even mix PHPUnit’s test classes and the Pest tests in one test suite. It just works!
Install pest
Installing Pest is really easy to do. Just run the following composer
command and Pest will be installed in your vendor
folder.
composer require pestphp/pest --dev --with-all-dependencies
Code language: Bash (bash)
Next you need to create a tests
folder if it doesn’t exist. When you have created the folder run the pest --init command
to initialize your Pest test suite.
./vendor/bin/pest --init
Code language: Bash (bash)
This creates the files necessary to run Pest test cases, as well as an example test file.
Now you are ready to actually write your own tests using the Pest syntax. To run the tests, simply execute the following command.
./vendor/bin/pest
Code language: Bash (bash)
Creating different testsuites
I always like to seperate my Unit tests and Feature in different folders, so it’s immediately clear what kind of tests are located in a file. To do that I’ll create 2 sub folders in my tests
folder and name them Unit
and Feature
.
Then I’ll update my phpunit.xml.dist
file to create 2 tests suites like so:
<!-- Other PHP Unit Stuff -->
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<!-- Other PHP Unit Stuff -->
Code language: HTML, XML (xml)
Expectations
If you’ve used PHPUnit before you are familiar with assertions; asserting that a certain criteria is met. Pest extends this using so called expectations. Using expectations you can write your tests using an English like syntax. See the simple example below.
test('one plus one equals two', function () {
expect(1+1)->toBe(2);
});
Code language: PHP (php)
Because this reads almost like English, it’s very easy to see what’s going on in the test case. The expectation interface also supports modifiers like not
, which you can chain after the expect
keyword like so.
test('one plus one does not equal three', function () {
expect(1+1)->not->toBe(3);
});
Code language: PHP (php)
As with the previous example this reads like English, making it easy to understand. The list of expectations you can use is endless. You can even extend the expectation interface yourself, for more advanced features.
Using Pest in Symfony
Let’s use the API of our stockportfolio project as an example. We’ll be creating a testcase for our JwtTokenValidator
to ensure this validator will always work correctly.
Start by creating our test file in tests/Unit/Validator/JwtTokenValidatorTest.php
. We can then add our first test to check if the validator throws an exception when the wrong constraint is used in the validate
call.
use App\Validator\JwtTokenValidator;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTManager;
use Symfony\Component\Validator\Constraints\NotNull;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
it('throws an exception when an invalid constraint is given', function () {
$tokenManager = $this->getMockBuilder(JWTManager::class)
->disableOriginalConstructor()
->getMock();
$validator = new JwtTokenValidator($tokenManager);
$validator->validate('', new NotNull());
})->expectException(UnexpectedTypeException::class);
Code language: PHP (php)
We’re using the getMockBuilder
function to get an instance of the JWTManager
class used by the validator. We cannot use the mock
plugin of Pest because then we’ll get a Pest\Mock\Mock
object and we cannot pass that to the constructor of the JwtTokenValidator
because of the typehinting.
Now let’s run our test to check if it passes by executing ./vendor/bin/pest
.
In my case I got this error Class "Symfony\Bridge\PhpUnit\SymfonyTestsListener" does not exist
this is because I haven’t installed the symfony/phpunit-bridge
package. So we’ll need to update our phpunit.xml.dist
file and comment or remove the listeners
section.
Run ./vendor/bin/pest
again and voila, it works. Our first real testcase for the JwtTokenValidator
.
Adding more tests
We’ve got our first testcase working correctly using the Pest testing framework. Now let’s add some more testcases to fully test the validator class. I want to add a testcase to check if the validator correctly handles expired tokens. So I’ve added the following testcase.
it('should add an expired violation if the token is expired', function () {
$tokenManager = $this->getMockBuilder(JWTManager::class)
->disableOriginalConstructor()
->getMock();
$tokenManager->expects($this->once())
->method('parse')
->willThrowException(new JWTDecodeFailureException(JWTDecodeFailureException::EXPIRED_TOKEN, ''));
/** @var ExecutionContextInterface $executionContextMock */
$executionContextMock = $this->getMockBuilder(ExecutionContextInterface::class)
->disableOriginalConstructor()
->getMock();
/** @var ConstraintViolationBuilderInterface $executionContextMock */
$constraintViolationBuilderMock = $this->getMockBuilder(ConstraintViolationBuilderInterface::class)
->disableOriginalConstructor()
->getMock();
$executionContextMock
->expects($this->once())
->method('buildViolation')
->with('The token is expired.')
->willReturn($constraintViolationBuilderMock);
$constraintViolationBuilderMock
->expects($this->once())
->method('addViolation')
->willReturn(null);
$validator = new JwtTokenValidator($tokenManager);
$validator->initialize($executionContextMock);
$validator->validate('DUMMY', new JwtToken());
});
Code language: PHP (php)
Refactoring our tests
As you can see we need to mock 3 classes to be able to perform our checks. This works for now, but I’d also like to add more validation checks. I could simply copy and paste the mocking, but I could also use the ‘Setup and teardown‘ functionality for this.
Pest lets you specify a few special function which will be ran before or after each testcase. Let’s make use of the beforeEach
function to create / reset our mocked objects, so we don’t have to mock them in each testcase. I’ve added the following code to our tests file.
beforeEach(function() {
$this->tokenManager = $this->getMockBuilder(JWTManager::class)
->disableOriginalConstructor()
->getMock();
$this->executionContextMock = $this->getMockBuilder(ExecutionContextInterface::class)
->disableOriginalConstructor()
->getMock();
$this->constraintViolationBuilderMock = $this->getMockBuilder(ConstraintViolationBuilderInterface::class)
->disableOriginalConstructor()
->getMock();
});
Code language: PHP (php)
Because each testcase is scoped to a class we can use and assign properties to the testcase base class using $this
. This is exactly what we’ve done in the example above. We’ve created the mocks and assigned them to $this
. Now we can refer to them in our testcases.
Now that I’ve removed a lot of boilerplate code I’ll add a testcase to check if our token verification works correctly. I’ve added the following testcase to the file.
it('should add an unverified violation if the token is not verified', function () {
$this->tokenManager->expects($this->once())
->method('parse')
->willThrowException(new JWTDecodeFailureException(JWTDecodeFailureException::UNVERIFIED_TOKEN, ''));
$this->executionContextMock
->expects($this->once())
->method('buildViolation')
->with('The token is unverified.')
->willReturn($this->constraintViolationBuilderMock);
$this->constraintViolationBuilderMock
->expects($this->once())
->method('addViolation')
->willReturn(null);
$validator = new JwtTokenValidator($this->tokenManager);
$validator->initialize($this->executionContextMock);
$validator->validate('DUMMY', new JwtToken());
});
Code language: PHP (php)
As you might have noticed a lot of the code is exactly the same as the testcase for expired tokens. Only the code of the exception and the validation message are different.
Because of this we can use parameters in our function to create ‘dynamic’ testcases. In Pest we can chain the with
function to our testcase to provide a list of parameters to use in the testcase.
Using the with
function we can refactor our validation testcase to the following.
it('should add an violation', function ($exceptionCode, $message) {
$this->tokenManager->expects($this->once())
->method('parse')
->willThrowException(new JWTDecodeFailureException($exceptionCode, ''));
$this->executionContextMock
->expects($this->once())
->method('buildViolation')
->with($message)
->willReturn($this->constraintViolationBuilderMock);
$this->constraintViolationBuilderMock
->expects($this->once())
->method('addViolation')
->willReturn(null);
$validator = new JwtTokenValidator($this->tokenManager);
$validator->initialize($this->executionContextMock);
$validator->validate('DUMMY', new JwtToken());
})->with([
[JWTDecodeFailureException::EXPIRED_TOKEN, 'The token is expired.'],
[JWTDecodeFailureException::UNVERIFIED_TOKEN, 'The token is unverified.'],
[JWTDecodeFailureException::INVALID_TOKEN, 'The token is invalid.'],
]);
Code language: PHP (php)
Now have validation check for not just 2, but 3 cases! Another benefit is it’s a lot easier to add another validation check to this testcase. Just add another entry in the with
call.
Finishing up
We’re almost there with our test for the JwtTokenValidator
. We just need to add tests for the validation of the constraint action. We’ll use the stuff we’ve learned previously to implement these testcases.
use App\Validator\JwtToken;
use App\Validator\JwtTokenValidator;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTManager;
use Symfony\Component\Validator\Constraints\NotNull;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface;
beforeEach(function() {
$this->tokenManager = $this->getMockBuilder(JWTManager::class)
->disableOriginalConstructor()
->getMock();
$this->executionContextMock = $this->getMockBuilder(ExecutionContextInterface::class)
->disableOriginalConstructor()
->getMock();
$this->constraintViolationBuilderMock = $this->getMockBuilder(ConstraintViolationBuilderInterface::class)
->disableOriginalConstructor()
->getMock();
});
it('throws an exception when an invalid constraint is given', function () {
$validator = new JwtTokenValidator($this->tokenManager);
$validator->validate('', new NotNull());
})->expectException(UnexpectedTypeException::class);
it('should add an violation', function ($exceptionCode, $expectedMessage) {
$this->tokenManager->expects($this->once())
->method('parse')
->willThrowException(new JWTDecodeFailureException($exceptionCode, ''));
$this->executionContextMock
->expects($this->once())
->method('buildViolation')
->with($expectedMessage)
->willReturn($this->constraintViolationBuilderMock);
$this->constraintViolationBuilderMock
->expects($this->once())
->method('addViolation')
->willReturn(null);
$validator = new JwtTokenValidator($this->tokenManager);
$validator->initialize($this->executionContextMock);
$validator->validate('DUMMY', new JwtToken());
})->with([
[JWTDecodeFailureException::EXPIRED_TOKEN, 'The token is expired.'],
[JWTDecodeFailureException::UNVERIFIED_TOKEN, 'The token is unverified.'],
[JWTDecodeFailureException::INVALID_TOKEN, 'The token is invalid.'],
]);
it('should check for correct actions', function($token, $constraintAction, $expectedMessage) {
$this->tokenManager->expects($this->once())
->method('parse')
->willReturn($token);
$this->executionContextMock
->expects($this->once())
->method('buildViolation')
->with($expectedMessage)
->willReturn($this->constraintViolationBuilderMock);
$this->constraintViolationBuilderMock
->expects($this->once())
->method('addViolation')
->willReturn(null);
$validator = new JwtTokenValidator($this->tokenManager);
$validator->initialize($this->executionContextMock);
$validator->validate('DUMMY', new JwtToken(null, null, null, $constraintAction));
})->with([
[['sub' => 'test'], 'password_reset', 'This token does not have an action.'],
[['sub' => 'test', 'action' => 'invalid'], 'password_reset', 'This token does not have the correct action.'],
]);
it('should not add a violation for correct tokens', function($action) {
$this->tokenManager->expects($this->once())
->method('parse')
->willReturn(['sub' => 'test', 'action' => $action]);
$this->executionContextMock
->expects($this->never())
->method('buildViolation');
$this->constraintViolationBuilderMock
->expects($this->never())
->method('addViolation');
$validator = new JwtTokenValidator($this->tokenManager);
$validator->initialize($this->executionContextMock);
$validator->validate('DUMMY', new JwtToken(null, null, null, $action));
})->with([
['test-action'],
]);
Code language: PHP (php)
That’s it! In less that 100 lines we’ve created a full test for our JwtTokenValidator
. This code is easily understandable because of the syntax Pest offers.
I’ll be using Pest for all my testing needs from now on!