Functional Tests

Drive your application through a fake browser: request a URL, assert on the response, fill a form, follow the redirect.

Syntax$client = static::createClient(); $crawler = $client->request('GET', '/path');

A functional test sends a request into your application and checks what comes back. Extend WebTestCase and call static::createClient(). The client is a fake browser: it boots the kernel, hands it a request object and keeps the response, with no web server and no network involved.

The client and the crawler

$client->request('GET', '/articles') returns a crawler, a queryable view over the response HTML. filter() takes a CSS selector, text() reads the first match. Most of the time you do not need the crawler at all, because the base class provides assertions that read better: assertResponseIsSuccessful(), assertResponseStatusCodeSame(404), assertSelectorTextContains('h1', 'Articles'), assertSelectorCount(2, '.teaser'), assertResponseRedirects('/articles', 302).

Forms, long and short

Submitting a form has a long form and a short one, and they do the same work:

$form = $crawler->selectButton('Save')->form();
$form['article[title]'] = 'Third';
$client->submit($form);

$client->submitForm('Save', ['article[title]' => 'Third']);   // identical

Reach for the long form when you need to tick a checkbox, select an option or upload a file, since $form['field'] gives you tick(), select() and upload().

Redirects

The client does not follow redirects by default. After a successful POST the response is a 302 and the body is empty. Assert the redirect, then call $client->followRedirect() for the page it points at, or $client->followRedirects() once to follow them automatically for the rest of the test.

Example

Example · php
<?php
// tests/Controller/ArticleControllerTest.php

declare(strict_types=1);

namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class ArticleControllerTest extends WebTestCase
{
    public function testTheListPageShowsPublishedArticlesNewestFirst(): void
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/articles');

        // GET /articles returns 200 with a page headed "Articles"
        // and one <article> block per published row.
        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('h1', 'Articles');
        self::assertSelectorCount(2, 'article.teaser');
        self::assertSame('Second', $crawler->filter('article.teaser h2')->first()->text());
    }

    public function testAMissingArticleIsNotFound(): void
    {
        $client = static::createClient();
        $client->request('GET', '/articles/99999');

        // GET /articles/99999 returns 404.
        self::assertResponseStatusCodeSame(404);
    }

    public function testCreatingAnArticleRedirectsToTheList(): void
    {
        $client = static::createClient();
        $client->request('GET', '/articles/new');

        // Shorthand: find the form holding the "Save" button, fill it, submit.
        $client->submitForm('Save', ['article[title]' => 'Third']);

        // Longhand, exactly the same three steps:
        //   $form = $client->getCrawler()->selectButton('Save')->form();
        //   $form['article[title]'] = 'Third';
        //   $client->submit($form);

        // The POST answers 302 to /articles; the client does not follow it.
        self::assertResponseRedirects('/articles', 302);

        $client->followRedirect();
        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('article.teaser h2', 'Third');
    }
}

When to use it

  • Prove a route still answers after a refactor, without starting a web server.
  • Check that an invalid form submission re-renders with the error message visible.
  • Confirm a POST redirects to the right page and the flash message appears.
  • Assert a JSON endpoint's exact payload, including fields that must not be exposed.

More examples

Checkboxes, selects and uploads

The field objects behind $form['name'] are what give you tick(), select() and upload(); submitForm() only accepts a flat array of values, so it cannot do these. The second method is the one people forget to write: a form that rejects bad input is a feature, and 422 rather than 200 is what Symfony's form handling returns when a submitted form is invalid.

Example · php
<?php
// tests/Controller/ArticleFormTest.php

declare(strict_types=1);

namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class ArticleFormTest extends WebTestCase
{
    public function testAnArticleCanBePublishedWithACoverImage(): void
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/articles/new');

        $form = $crawler->selectButton('Save')->form();
        $form['article[title]'] = 'With a cover';
        $form['article[category]']->select('news');
        $form['article[published]']->tick();
        $form['article[cover]']->upload(__DIR__.'/fixtures/cover.jpg');

        $client->submit($form);

        // POST /articles answers 302; following it lands on 200.
        self::assertResponseRedirects();
        $crawler = $client->followRedirect();

        self::assertSelectorTextContains('h1', 'With a cover');
        self::assertSame('news', $crawler->filter('.category')->text());
    }

    public function testASubmissionWithNoTitleIsRejected(): void
    {
        $client = static::createClient();
        $client->request('GET', '/articles/new');

        $client->submitForm('Save', ['article[title]' => '']);

        // An invalid submission re-renders the form, so the status is 422
        // and the page carries the validation message.
        self::assertResponseStatusCodeSame(422);
        self::assertSelectorTextContains('.form-error', 'should not be blank');
    }
}

Asserting on a JSON response

There is no crawler worth using on a JSON body, so read the raw content off the response and decode it. The last assertion is the one that earns its place: it proves a field you deliberately excluded from serialisation has not crept back in. Request headers go in the server array with dashes turned into underscores, uppercased, prefixed HTTP_.

Example · php
<?php
// tests/Controller/Api/ArticleApiTest.php

declare(strict_types=1);

namespace App\Tests\Controller\Api;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class ArticleApiTest extends WebTestCase
{
    public function testTheEndpointReturnsTheArticleAsJson(): void
    {
        $client = static::createClient();
        $client->request('GET', '/api/articles/7', server: [
            // RFC 3875 form: Accept becomes HTTP_ACCEPT.
            'HTTP_ACCEPT' => 'application/json',
        ]);

        // GET /api/articles/7 returns 200 with
        // {"id":7,"title":"Second","author":"[email protected]"}
        self::assertResponseIsSuccessful();
        self::assertResponseHeaderSame('content-type', 'application/json');

        $payload = json_decode(
            $client->getResponse()->getContent(),
            associative: true,
            flags: JSON_THROW_ON_ERROR,
        );

        self::assertSame(7, $payload['id']);
        self::assertSame('Second', $payload['title']);
        self::assertArrayNotHasKey('internalNotes', $payload);
    }
}

Following every redirect, and catching exceptions

followRedirects() flips the client into auto-follow mode for the whole test, which is convenient when the redirect chain is not what you are testing; assertRouteSame() then confirms where you landed. catchExceptions(false) is the debugging switch: without it a failing page gives you an unhelpful 500 assertion instead of the stack trace.

Example · php
<?php
// tests/Controller/HomeControllerTest.php

declare(strict_types=1);

namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class HomeControllerTest extends WebTestCase
{
    public function testTheRootUrlEndsOnTheArticleList(): void
    {
        $client = static::createClient();

        // Follow redirects for the rest of this test, however many there are.
        $client->followRedirects();

        $client->request('GET', '/');

        // GET / redirects to /articles, which returns 200.
        self::assertResponseIsSuccessful();
        self::assertRouteSame('app_article_index');
    }

    public function testABrokenPageSurfacesTheRealException(): void
    {
        $client = static::createClient();

        // Let the exception escape instead of being turned into a 500 page.
        $client->catchExceptions(false);

        $this->expectException(\RuntimeException::class);

        $client->request('GET', '/articles/boom');
    }
}

Discussion

  • Be the first to comment on this lesson.