Tips for a reliable and fast test suite with Symfony and Doctrine
In my opinion, one of the greatest feature of Symfony is it’s internal organization around HTTP: its kernel “handles” HTTP requests and “returns” HTTP responses (see the documentation about this). This pattern is one of the core principle of the framework, and it brings tons of advantages.
One of these advantages is the possibility to achieve reliable and fast functional testing, by emulating requests and checking responses directly in PHP. This ease of testing is something often overlooked, but it shouldn’t: it’s a crucial feature to create maintainable, quality applications. Creating unit and functional tests in an application should be as easy and as fast as possible and one of the roles of a framework is to help you on this level.
Having reliable and fast automated tests is extremely important:
- Developers are more inclined to run them often to check the application, letting them detect issues early, and the earliest they catch an issue the less it costs to the project
It incentivises developers to take care of the test suite, by not letting it rot and by trying to improve its coverage on every feature
It increases productivity, by limiting the amount of time spent checking continuous integrations processes, allowing faster reviews and faster merges.
However, even in Symfony, your test suite can become slow or unreliable over time. In this article I would like to propose some ideas and tools to increase the speed and reliability of your Symfony test suite with Doctrine.
Reliability is the notion of being able to run your tests suite multiple times without having a test succeeding or failing only certain times. Having a reliable test suite means that you are certain a failure in one of your tests is linked to your code and not to the stability of the test suite itself.
Creating a reliable test suite consists mainly in being able to reset the state of the application to its initial state after each test. Usually, there are two main places where the state of our application lives: the database and the filesystem.
Resetting the database after each test
In many of my projects, I use two bundles for Doctrine dedicated to database tests:
- The DoctrineFixturesBundle is an extremely useful bundle which helps you create and reset fake data in your database using a single command. With this bundle, you will be able to prepare a set of known entities in your database to use in your functional tests.
In addition to the fixtures, I usually use the DoctrineTestBundle which wraps your database connection and start a transaction before each test then roll it back after it. This technique is an easy way to reset the state of your database between tests by using a native and extremely fast database feature.
Using Flysystem to abstract the file system
A great way to create reliable tests for code that uses the filesystem is to use an abstraction for the filesystem. This abstraction will let you configure a different storage for your files in the test environment.
I personally love Flysystem: it’s an extremely well thought and simple to use library.
In a few lines of configuration, you can use a memory storage in your tests, which will be much faster and won’t store state between your tests. Read more about Flysystem and the bundle that integrates it in Symfony on my dedicated blog post.
composer require league/flysystem-bundle composer require --dev league/flysystem-memory
While working on different projects, I discovered a few tips to increase the speed of my test suites.
Using unit tests as much as possible
This is a quite obvious way to increase tests performances, but it’s often forgotten: using unit tests is much faster than using functional tests.
I usually see functional tests as integration tests: tests ensuring all the components of your application fit well together.
When we create tests, we want to ensure all the features in each possible execution case are working properly. To do this, it’s important to list all the possible execution cases, to be sure you write a test for each of them.
When I write tests, I usually like to use one or a few functional tests for the most common execution cases and unit tests for the others. A real-world example of this is how I test security: instead of testing all the possible roles in each possible case using functional tests, which would be really slow, I prefer to test my security voter using a unit test, and then test the most common cases of usage of this voter using functional test. If the voter is properly called on each page (which is ensured by the functional test), and if each role/context case handled by the voter is tested by a unit test, I know my security checks are safe and my test suite is still fast.
Using a simpler security encoder in the test environment
Security encoders describes how to write and check users passwords from the database. In production, you should use bcrypt or any other algorithm at least as secure (Argon, ...):
# config/packages/security.yaml security: encoders: App\Entity\User: bcrypt
In test, having a secure encoder is far from necessary and bcrypt is a quite slow algorithm. A simple way to improve your functional tests speed is to use md5 instead:
# config/packages/test/security.yaml security: encoders: App\Entity\User: algorithm: md5 encode_as_base64: false iterations: 0
Using a profiler to analyze your tests performance
As we have seen earlier, having fast tests is hugely important for the productivity of the project developers: increasing your test suite speed is a great investment for the project’s quality and productivity.
I regularly use two main tools to analyze and improve my tests performance:
- PHPUnit Pretty Printer, a small library providing a PHPUnit printer to display the execution time of each test
- Blackfire, a wonderful profiling tool letting you find the exact function calls and code paths which are the most time consuming. I usually use it in combination with the PHPUnit Pretty Printer, on a specific test I found to be slow