Functional Tests
Drive your application through a fake browser: request a URL, assert on the response, fill a form, follow the redirect.
$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
<?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.
<?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_.
<?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.
<?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