Building functional tests for controllers and formsfor Drupal 8 , 9 , 10 , and 11

Last updated :  

A unit test checks one PHP method in isolation. A functional test does something far more ambitious: it spins up an entire real Drupal site — database, config, modules and all — and drives a simulated browser against it, clicking links and reading pages exactly like a real visitor would. It's slower than a unit test, but it catches an entire category of bugs unit tests structurally cannot: routing mistakes, permission misconfigurations, and broken page rendering.

What you'll learn in this lesson

  • What BrowserTestBase actually does behind the scenes
  • How to write a functional test that logs in and checks a page renders correctly
  • The key properties every functional test class needs to declare
  • Why functional tests are slower, and when that trade-off is worth it

A real functional test, from the Examples module

This is the actual functional test shipped with the phpunit_example module:

namespace Drupal\Tests\phpunit_example\Functional;

use Drupal\Core\Url;
use Drupal\Tests\BrowserTestBase;

class PHPUnitExampleMenuTest extends BrowserTestBase {

  protected $defaultTheme = 'stark';

  protected static $modules = ['phpunit_example'];

  protected $profile = 'minimal';

  public function testLinksAndPages() {
    $this->drupalLogin($this->createUser(['access content']));
    $assert = $this->assertSession();
    $links = [
      '' => Url::fromRoute('phpunit_example.description'),
    ];
    foreach ($links as $page => $path) {
      $this->drupalGet($page);
      $assert->linkByHrefExists($path->getInternalPath());
    }
    foreach ($links as $path) {
      $this->drupalGet($path);
      $assert->statusCodeEquals(200);
    }
  }

}

The properties every functional test declares

  • protected static $modules = [...] — which modules get installed into the fresh, temporary test site before your test runs. Only list what you actually need; Drupal core's required modules install automatically.
  • protected $defaultTheme — required as of Drupal 9.3+; without it, the test fails immediately with a clear error telling you to set one.
  • protected $profile — the installation profile for the test site. 'minimal' here specifically because the test needs the Tool block, which isn't available under the default 'testing' profile.

What drupalLogin(), drupalGet(), and assertSession() actually do

$this->createUser(['access content']) creates a real user account (in the test database, which is thrown away afterward) with exactly the permissions listed. drupalLogin() authenticates as that user through the real login form — not a shortcut, an actual HTTP request cycle. drupalGet($path) requests a page exactly as a browser would. assertSession() returns an assertion helper scoped to whatever page was most recently fetched — linkByHrefExists() and statusCodeEquals() are two of dozens of assertion methods it provides for checking what's actually on the page.

Why this is slow (and that's OK): every functional test installs a fresh, throwaway Drupal site from scratch — full schema, full config, the works — which is why these tests take seconds each rather than milliseconds like a unit test. Reserve functional tests for things that genuinely need a real HTTP request cycle (routing, access control, rendered page content); push everything else down to unit or kernel tests, which don't pay this cost.

See it for yourself

On your own DDEV/local environment, run: vendor/bin/phpunit --group phpunit_example (or scope it to just this class with --filter PHPUnitExampleMenuTest). Watch the output — a passing functional test prints a single dot per test method, exactly like a unit test, even though a whole simulated site installation happened behind that one dot.

Quick check: why does this test call $this->createUser(['access content']) rather than just visiting the page anonymously? Because the test is specifically verifying access for a logged-in user with that permission — testing as an authenticated user with an explicit permission set is a much stronger, more intentional check than relying on whatever the anonymous role happens to allow by default.

Key takeaways

  • Functional tests extend BrowserTestBase and drive a real, temporary Drupal install through actual HTTP requests.
  • $modules, $defaultTheme, and often $profile are the properties every functional test class needs to declare.
  • drupalLogin()/drupalGet()/assertSession() are the core building blocks — create a user, request a page, assert on what came back.
  • Functional tests are slow by nature (full site install per test); reserve them for things unit/kernel tests genuinely can't verify.

Coming up next

Functional tests are thorough but heavy. When you want to test a class's logic without needing its real dependencies at all, you reach for mocks instead — next, we'll build fake stand-ins for a class's dependencies using PHPUnit's mocking tools.