Skip to content
Source

Functional Testing with PHPUnit🔗

This cookbook will reference various practices and code samples about functional/end-to-end testing in a Symfony application and PHPUnit test suite.

Speedup your test suite by disabling debug mode 🔗

By default, functional tests booting the Symfoiny Kernel are using the debug mode. You can disable the debug mode in order to speedup your test suite.

In your phpunit.xml.dist:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<?xml version="1.0" encoding="UTF-8"?>
<phpunit>
    <php>
        <!--
            Defaults to no debug for tests speed.
            Env vars allows to be overridden by using APP_DEBUG=1
            so you can debug functional tests for which the debug mode isn't on yet
            and inspect the profiler.
        -->
        <env name="APP_DEBUG" value="0" />
    </php>
</phpunit>

As the test container might not always be rebuilt on changes since debug mode is off, ensure the cache is cleared with no debug in your make test targets:

1
2
3
4
5
6
# Makefile

test: export APP_ENV = test
test: export APP_DEBUG = 0 # Ensure cache and other commands uses no debug mode
test:
    bin/console cache:clear --ansi

Debug your functional tests with the Symfony Profiler 🔗

Debugging functional tests might not always be easy, as most of the application is like a black box at this point and information about the error might not be in the response, or difficult to find/reason about from generated HTML response.

Hence, the easiest way to debug such tests is to directly access the application in a web context and inspect the Symfony Web Profiler.

In your ansible/group_vars/app.yml, add the following host entry:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# ansible/group_vars/app.yml

  #########
  # Nginx #
  #########

  nginx_configs:
    # [...]

    # App (for functional tests debugging purposes)
    - file: app_test.conf
      config:
        - server:
          - server_name: "test.{{ app.host }}"
          - root:        "{{ app.dir }}{{ app.dir_release }}/public"
          - access_log:  "{{ app.log_dir }}/nginx.access.log"
          - error_log:   "{{ app.log_dir }}/nginx.error.log"
          - include:     conf.d/app_gzip
          - location /:
            - try_files: $uri /index.php$is_args$args
          - location ~ ^/index\.php(/|$):
            - include: conf.d/app_php_fpm
            - fastcgi_param APP_ENV: test
            - fastcgi_param APP_DEBUG: 1
            - internal;

This will allow you to access the app in test env, with debug mode on.

But so far, the profiler isn't available. in order to enable it, change the following file:

1
2
3
4
5
6
7
8
# config/packages/test/web_profiler.yaml
web_profiler:
    toolbar: false
    intercept_redirects: false

framework:
-    profiler: { collect: false }
+    profiler: { collect: '%kernel.debug%' }

and add profiler routes to your test env:

1
2
3
4
5
6
7
8
9
# config/routes/test/web_profiler.yaml

web_profiler_wdt:
    resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
    prefix: /_wdt

web_profiler_profiler:
    resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
    prefix: /_profiler

You should now be able to access the profiler in test env by opening http://test.your-app-host.vm/_profiler/

However, if you followed the "Speedup your test suite by disabling debug mode" section, debug mode is off by default when your tests are executed, hence are not collected by the profiler.
Use APP_DEBUG=1 bin/phpunit --filter=YourTestClass::yourTestCaseMethod to force execute your test in debug mode, so you can inspect it in the web profiler.

Enable debug mode per test-case🔗

In some case, you might want to enable Symfony kernel debug mode for specific test cases (for instance, in order to access Profiler & collector data).

In order to do this, you can register the SymfonyDebugModeListener in your phpunit.xml.dist:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!-- phpunit.xml.dist -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="bin/.phpunit/phpunit.xsd"
>
    <!-- ... -->
    <listeners>
        <!-- Enables Symfony kernel debug mode on tests marked with "@group debug" -->
        <listener class="App\Infra\Test\Listener\SymfonyDebugModeListener" />
    </listeners>
</phpunit>

Then, use the debug PHPUnit group on your test case:

1
2
3
4
5
6
7
/**
 * @group debug
 */
public function testFoo(): void 
{
    // ...
}

which should be enough for \Symfony\Bundle\FrameworkBundle\Test\KernelTestCase test cases to create a kernel with debug mode on.

Writing tests with auto-updated expectations 🔗

Writing & updating functional tests should not be a tedious task for the developer.
That's why one of the practice we use in several projects in order to ease this is using expectations files and auto-updated expectations with results.

An expectation file is a file with a dumped result from a test case.
Results refers here to various things generated by your application to compare, depending on the nature of the tested feature.

Imagine testing a JSON or Graphql API. Writing a functional test usually requires:

  • writing fixtures for the test case
  • writing a test case
  • providing an input (the request payload)
  • performing the request
  • comparing the result, i.e: the generated JSON response

By simply using the ExpectationsTestTrait trait in your test class, you can use the ExpectationsTestTrait::assertStringMatchesExpectationsFile(string $result, string $expectationsFilePath) method to compare a result normalized as a string (here it's pretty obvious: the json response) with an expectation file stored in your project.

Nothing fancy, but the magic happens whenever you need to init or update these expectations files (I.e: a new field is added to the JSON response): by running the test suite (or the single test case using bin/phpunit --filter=...) with a specific flag, you can auto-update the expectations files with the test case results.

Usually, an env var is used for this purpose:

1
UP=1 bin/phpunit --filter=... # UP is actually a shorthand for the UPDATE_EXPECTATIONS flag

so it's easily actionable.

This requires a small setup for your test suite, in a tests/bootstrap.php file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<?php
# tests/bootstrap.php

declare(strict_types=1);

require __DIR__ . '/../config/bootstrap.php';

// Should update expectations files (api outputs, dumps, ...) automatically or not.
\define('UPDATE_EXPECTATIONS', filter_var(getenv('UPDATE_EXPECTATIONS') ?: getenv('UP'), FILTER_VALIDATE_BOOLEAN));

const TEST_DIR = __DIR__; // Used by ExpectationsTestTrait for more friendly path in warnings

If not already done, reference this file as the test suite bootstrapping file in your phpunit.xml.dist file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!-- phpunit.xml.dist -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="bin/.phpunit/phpunit.xsd"
         bootstrap="tests/bootstrap.php"
>
    <!-- ... -->
    <listeners>
        <!-- Register this listener as well, in order to get warning whenever you updates expectations files -->
        <listener class="App\Infra\Test\Listener\UpdateExpectationsGuardListener" />
    </listeners>
</phpunit>

Warning

Whenever you're using the expectations auto-update feature, you MUST always carefully check the diff using your preferred Git tool to ensure any change is actually expected.
You'll also get warnings inviting you to double-check updated expectations files that were previously containing string formats (%s, %d, ...), as they were replaced by the actual data and will probably need partial revert.
Using a Git GUI interface like Tower or PhpStorm versionning tools makes this task pretty handy.

Going further🔗

The scenario mentioned here is pretty straightforward to understand: an API JSON response retrieved as string is compared to a file.
But this feature can be way more powerful coupled with the Symfony VarDumper component in order to be able compare anything (objects as well) and can really simplify writing test cases about anything produced by your application.

Some projects use this to collect and update expectations based on the command and query buses of your app, allowing to dump input (queries/commands) and their handlers results.

Some of those projects:

Libraries/projects using a similar approach🔗

Structure your test cases resources 🔗

Tests (especially functional ones) often require some resources:

  • fixtures (specifically loaded for your test case)
  • inputs (payload sent to an API, csv file read by a CLI command, ...)
  • expectations (an API json response, an object dumped with the VarDumper component, a CSV file generated by a process, ...). See Writing tests with auto-updated expectations for more info.

In order to help you to manage these resources, you can use the TestCaseResourcesTrait. It relies on some conventions on the way you store these resources by:

  • using the test class name as dir path where to store them
  • using test cases & dataset names as file paths

This trait exposes numerous methods allowing you to get paths or content of these files.

Loading fixtures🔗

By using the FixturesTestTrait trait, you can benefit from the loadFixtures() and appendFixtures() methods from within your test cases.

This trait relies on two PHP packages:

As well as a bridge service, which you can use to load fixtures in dev env too:

1
2
3
4
5
6
# services_test.yaml

services:
    App\Infra\Bridge\Nelmio\Alice\FixturesLoader:
        public: true # So it's neither inlined nor removed from the container
        arguments: ['@fidry_alice_data_fixtures.loader.doctrine']

The trait requires you to provide a base dir from where to load your fixtures files, but this can be automatically provided by convention when relying on the TestCaseResourcesTrait.

You can load fixtures listed in a getFixtureFiles method before each of your test cases with something like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php

declare(strict_types=1);

namespace App\Infra\Test\Functional;

use App\Infra\Test\Functional\FixturesTestTrait;
use App\Infra\Test\Functional\TestCaseResourcesTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpKernel\KernelInterface;

abstract class AbstractKernelTestCase extends KernelTestCase
{
    use TestCaseResourcesTrait;
    use FixturesTestTrait;

    protected function setUp(): void
    {
        static::bootKernel(); // Boot kernel to access the container

        // Load fixtures defined by child test classes:
        $this->loadFixtures($this->getFixturesFiles(), true);
    }

    protected static function getKernel(): KernelInterface
    {
        return static::$kernel;
    }

    /**
     * Implements {@link \App\Infra\Test\Functional\DITrait}
     */
    protected static function getService(string $service): ?object
    {
        return static::$container->get($service);
    }
}

GraphQL test cases 🔗

The GraphQLTestTrait and GraphQLTestCase base class exposes methods to perform GraphQL requests and assertions based on test cases resources conventions (cf previous sections).
It loads a query/mutation from a .graphql file, performs the request with provided variables and files.
If the call is successful, the response can be compared with an expectation file (for instance using the JsonAssertionsTrait::assertJsonResponseMatchesExpectations() method).
If the call is erroneous, the trait formats a bit the error and trace.

Login 🔗

The official documentation suggest two ways to login as a user in your tests cases:

Since Symfony 5.1, new methods will be available to ease this. Also see the related blog post about this method.

But until there, you may see in existing applications the two previous mentioned methods in use.

Using an HTTP basic auth🔗

The HttpBasicLoginTrait exposes a loginAs method to set credentials headers on a client, so you're authenticated when performing a request.

It requires enabling an HTTP basic auth on your firewall(s), at least in test env:

1
2
3
4
5
6
# config/packages/test/security.yaml
security:
    firewalls:
        # replace 'main' by the name of your own firewall
        main:
            http_basic: ~

Forging a token in session🔗

The SessionLoginTrait exposes a method to forge a token to be stored in the session (the code used to fetch the user has to be adapted to your app).

Compose your tests 🔗

In this cookbook we expose you multiple traits you can use depending on your needs. By using traits, we add some flexibility and reusability for specifics needs. Each of these traits work on their own by defining contracts on their requirements. Not every app needs everything. Compose your base test classes accordingly.

The FunctionalTestTrait is a sample trait compound of other traits and using a kernel. It can be used as is in a base test class or adapted to your app.
ControllerTestCase and GraphQLTestCase are base classes samples using this trait.

Projects references🔗


Last update: December 20, 2024