We've climbed the whole ladder now. Unit tests proved a single PHP class works in isolation. Kernel tests proved things work against a real database and service container. This final lesson covers the top rung: the functional test — the closest thing Drupal has to an actual person opening a browser, logging in, and clicking around your module.
What you'll learn in this lesson
- What makes a functional test different from — and slower than — a kernel test
- Why
$defaultThemeand$profilematter, and what values to reach for by default - How to log a test user in and assert on what they actually see
- Why
statusCodeEquals(200)is one of the highest-value single assertions you can write
What makes a test "functional"
A functional test provisions a genuinely complete, isolated Drupal installation for the duration of the test — real routing, a real theme rendering real HTML, a real login form you actually submit. It's the only one of the three test types that can catch a bug like "this page technically loads but the link to it is missing," because it's the only one that actually renders pages and follows links the way a visitor would. The trade-off is speed: spinning up a full site per test class takes real time, so functional tests are the slowest of the three — reserve them for verifying things unit and kernel tests structurally cannot see.
We'll study PHPUnitExampleMenuTest, a small but genuinely useful test that confirms the phpunit_example module's own menu link actually appears on the site and actually resolves to a working page.
The source file
Path: modules/phpunit_example/tests/src/Functional/PHPUnitExampleMenuTest.php
<?php
namespace Drupal\Tests\phpunit_example\Functional;
use Drupal\Core\Url;
use Drupal\Tests\BrowserTestBase;
/**
* Test the user-facing menus in PHPUnit Example.
*
* Note that this is _not_ a PHPUnit-based test. It's a functional
* test of whether this module can be enabled properly.
*
* @ingroup phpunit_example
*
* @group phpunit_example
* @group examples
*/
class PHPUnitExampleMenuTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['phpunit_example'];
/**
* The installation profile to use with this test.
*
* We need the 'minimal' profile in order to make sure the Tool block is
* available.
*
* @var string
*/
protected $profile = 'minimal';
/**
* Verify and validate that default menu links were loaded for this module.
*/
public function testLinksAndPages() {
$this->drupalLogin($this->createUser(['access content']));
$assert = $this->assertSession();
$links = [
'' => Url::fromRoute('phpunit_example.description'),
];
// Go to the page and see if the link appears on it.
foreach ($links as $page => $path) {
$this->drupalGet($page);
$assert->linkByHrefExists($path->getInternalPath());
}
// Visit all the links and make sure they return 200.
foreach ($links as $path) {
$this->drupalGet($path);
$assert->statusCodeEquals(200);
}
}
}
How it works
Namespace and file location
namespace Drupal\Tests\phpunit_example\Functional;
Same PSR-4 pattern you've now seen three times: Drupal\Tests\<module>\Functional, mapping to tests/src/Functional/ inside the module. By this point the pattern should feel familiar — only the final namespace segment and base class change between unit, kernel, and functional tests.
Extending BrowserTestBase
class PHPUnitExampleMenuTest extends BrowserTestBase {
BrowserTestBase provisions a completely isolated Drupal installation — typically backed by an in-memory SQLite database — for every test run, so nothing you do here ever touches your real development database. On top of that isolated site it layers an HTTP client (Mink) capable of making real GET/POST requests, following redirects, and inspecting rendered HTML, all without launching an actual browser process. You get the entire Drupal stack — routing, entities, theming — at the cost of noticeably slower setup than a kernel test.
$defaultTheme — always set it explicitly
protected $defaultTheme = 'stark';
Functional tests need an actual theme to render pages through. stark is core's deliberately bare-bones theme — almost no decorative markup — which makes assertions about page content far more reliable, since there's less incidental HTML for a selector to accidentally match or miss. Using a full theme like Olivero in tests would add a lot of noise you don't want to assert against. Leaving $defaultTheme unset triggers a deprecation warning in Drupal 10+ (and will be a hard error later), so always set it.
$modules — only what this test needs
protected static $modules = ['phpunit_example'];
This tells BrowserTestBase which modules to install (not just load — actually install, on top of the base profile) for this test environment. Keep this list as short as the test genuinely requires: a leaner list boots faster and makes the scope of what you're actually testing obvious at a glance. If your module depends on others, list those too — the framework installs them in the correct dependency order automatically.
$profile — minimal versus standard
protected $profile = 'minimal';
The installation profile decides what baseline of modules and configuration exists in the test site before your own modules are installed on top. minimal gives you only the bare essentials needed for a working site — no comment system, no full editorial workflow — which is both faster to boot and, as the source comment notes, specifically needed here to guarantee the Tools block is available for this particular test.
The test method: any public test* method runs automatically
public function testLinksAndPages() {
Same discovery rule as the other two test types: any public method starting with test gets picked up and run by PHPUnit. This one method checks two related things in sequence — that a link to the module's page actually appears where expected, and that following it actually works — because both checks share the same expensive setup (creating a logged-in session), so it's efficient to do them together.
Logging in as a real user
$this->drupalLogin($this->createUser(['access content']));
createUser() — provided by BrowserTestBase itself — programmatically creates a user with exactly the permissions you list, here just access content. A fresh user every test run means zero state leaking between tests. drupalLogin() then performs an actual form-based login through /user/login, establishing a real session cookie that every subsequent request in this test will carry — this is deliberately the same path a genuine site visitor would take, not a shortcut that bypasses authentication.
assertSession() — your assertion toolkit
$assert = $this->assertSession();
This returns a WebAssert object scoped to the current browser session, with a large vocabulary of assertions purpose-built for testing HTML pages: statusCodeEquals(), linkByHrefExists(), pageTextContains(), elementExists(), and more. Stashing it in a local variable is just a convenience so the method doesn't have to repeat $this->assertSession() everywhere.
Referencing routes by name, not by hardcoded path
$links = [
'' => Url::fromRoute('phpunit_example.description'),
];
Url::fromRoute() builds a Url object from a route's machine name rather than a literal path string. This matters because it decouples the test from URL structure — if a path alias changes later, a test built on the route name still passes, while one hardcoded to /phpunit-example would silently start testing the wrong thing (or fail for the wrong reason). The empty-string key '' represents the site's front page.
Asserting the link is actually there
$this->drupalGet($page);
$assert->linkByHrefExists($path->getInternalPath());
drupalGet() performs a real GET request and stores the response for inspection. linkByHrefExists() then scans the returned HTML for an <a> tag whose href matches the given path — proving the link genuinely renders on the page, not just that the destination route exists somewhere in the routing table.
Asserting the destination actually works
$this->drupalGet($path);
$assert->statusCodeEquals(200);
Having confirmed the link is present, the test now follows it and checks for a clean 200 OK. This one assertion is surprisingly powerful: a 403 would mean a permissions problem, a 404 a missing or misregistered route, and a 500 a PHP error inside the controller. A single statusCodeEquals(200) check on every route your module registers is one of the cheapest, highest-value smoke tests you can write — many production bugs are caught by nothing more sophisticated than this.
See it for yourself
Visit the testing_example module's description page on your DDEV site — the same reference table from the previous lesson lists Drupal\Tests\BrowserTestBase as the base class for functional tests, alongside unit and kernel tests, so you can see all three test types side by side.
Quick check: why does this test bother calling
linkByHrefExists()at all, instead of just checkingstatusCodeEquals(200)on the route directly? Because a route can work perfectly (return 200 when visited directly) while the link to it is broken, missing, or pointing at the wrong path somewhere in the UI. Checking both catches a class of bug that checking only the status code would miss entirely.
Key takeaways
- Extend
BrowserTestBase— notUnitTestCaseorKernelTestBase— when you need a fully bootstrapped Drupal site with real routing, theming, and user sessions. - Always declare
$defaultThemeexplicitly (starkis the standard choice for predictable markup), and keep$moduleslimited to what the test genuinely needs. - Use
Url::fromRoute()to reference routes by machine name rather than hardcoded paths, so tests stay valid even if URL aliases change later. createUser(['permission'])paired withdrupalLogin()simulates a real authenticated session — scope permissions tightly to avoid false passes from an over-privileged test user.assertSession()gives you a rich, purpose-built assertion vocabulary (linkByHrefExists(),statusCodeEquals(),pageTextContains()) — prefer these over manual string checks on raw response HTML.- A functional test that asserts
statusCodeEquals(200)across every route your module registers is a fast, high-value smoke test that catches broken routes, misconfigured access handlers, and controller errors before they ever reach production.
You've finished the course
Take a moment with this one — you've earned it. Sixty-one lessons ago, this course started with a single twelve-line YAML file and the question of how Drupal even recognizes that a module exists. Since then you've built up, piece by piece, a genuinely complete picture of how Drupal module development actually works: hooks that let your code react to core and other modules without touching their source; block plugins you can place and configure through the UI; the Form API and its AJAX-driven cousins; reading and writing the database safely with DBTNG; the Configuration API and config entities for storing settings that survive a deployment; content entities and custom field types for modeling your own data; the event system for decoupled, reactive code; the plugin architecture that quietly powers half of what you built along the way; the Cache API and the render pipeline that make all of it fast; queues, batch operations, and cron for work that can't happen in a single request; Twig theming and template suggestions; JavaScript behaviors that survive AJAX updates; and now, finally, the three-tier testing system that lets you prove all of the above still works after you change it.
That's not a small list, and if you've followed along and actually run these examples on your own DDEV site rather than just reading, you're no longer a beginner at this. You're someone who can open an unfamiliar Drupal module, recognize every pattern in it, and reasonably guess what it does before you've even read the docblocks.
The honest next step isn't another lesson — it's a real module of your own. Pick a small, genuinely useful idea, however modest. Give it an .info.yml file. Build outward from there, one piece at a time, the same way this course did. You already know more than enough to start.