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
BrowserTestBaseactually 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.
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
BrowserTestBaseand drive a real, temporary Drupal install through actual HTTP requests. $modules,$defaultTheme, and often$profileare 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.