Drupal Testing: How to Test Drupal Websites
Choose the right Drupal test layer for each behavior, run PHPUnit tests with version-aware configuration, and cover browser workflows without confusing test types.
Test Drupal behavior at the lowest layer that can exercise it correctly. Use a unit test for isolated class logic, a kernel test when Drupal services or entities are involved, a functional test for routes, permissions, forms, and rendered pages, and a functional JavaScript test when JavaScript or Ajax behavior matters in a real browser. These layers cover different risks; one does not replace the others.
Drupal also documents Cypress browser testing, Nightwatch JavaScript testing, performance testing, and Behat scenarios. Choose among them based on your project’s workflow and the behavior you need to verify. For a UI assertion that only concerns server-rendered output, a functional test is usually enough; when the result depends on browser JavaScript, use a JavaScript-capable test.
1. Which Drupal test should I write?
Start with the question the test must answer. Pick the least expensive environment that can answer it reliably.
| Test type | Base class | Drupal environment | Use it for | Real browser JavaScript? |
|---|---|---|---|---|
| Unit | Drupal\Tests\UnitTestCase |
Minimal dependencies; no full Drupal bootstrap | Isolated class behavior, value transformations, decisions, and collaborator interactions | No |
| Kernel | Drupal\KernelTests\KernelTestBase |
Bootstrapped kernel with a minimal set of extensions enabled | Behavior that needs Drupal services, the entity system, or a limited Drupal environment | No |
| Functional | Drupal\Tests\BrowserTestBase |
A full booted Drupal instance | Routes, permissions, forms, rendered output, and integrated server-side behavior | No. It simulates browser behavior but does not exercise actual JavaScript or Ajax interaction. |
| Functional JavaScript | Drupal\FunctionalJavascriptTests\WebDriverTestBase |
Drupal plus WebDriver and a real browser | JavaScript, Ajax, and interactions whose result depends on browser-side behavior | Yes |
- Is the behavior isolated from Drupal? Write a unit test. Keep it focused on inputs, outputs, and relevant collaborator calls.
- Does it need Drupal services or entities, but not a complete site? Use a kernel test and enable only the extensions the behavior requires.
- Does it depend on a route, access control, a form, or rendered page output? Use a functional test.
- Does it depend on JavaScript, Ajax, or browser interaction? Use a functional JavaScript test or another browser-testing tool suited to the workflow.
- Is the main requirement a readable user scenario or a broader browser workflow? Consider Behat for Gherkin-style scenarios, or Cypress and Nightwatch for browser and JavaScript workflows. They are distinct approaches, not interchangeable names for PHPUnit tests.
Keep the mix proportionate to the code. Unit tests are usually the most direct way to cover isolated logic. A smaller number of higher-level tests can check important integrations and user journeys. Drupal’s testing guidance describes the goal as covering all or most components and features, and running automated tests to catch regressions when code changes. See the official [Drupal testing overview](https://www.drupal.org/docs/develop/automated-testing) and [test type descriptions](https://www.drupal.org/docs/develop/automated-testing/types-of-tests).
2. Put tests in the right place
Drupal test discovery and namespace conventions depend on the project and test type. Follow the layout already used by the module or site, and consult the current PHPUnit-in-Drupal documentation before adding a new test directory. A typical custom module separates test classes by layer:
modules/custom/example_module/
example_module.info.yml
src/
tests/
src/
Unit/
Kernel/
Functional/
FunctionalJavascript/
Use the matching Drupal test base class, and name test classes so PHPUnit can discover them. The test should state the behavior and expected result in its method name or assertions. Avoid making a unit test boot Drupal just to reach a service; if the behavior truly depends on Drupal, move the test to the kernel or a higher layer.
3. Write a test at each layer
Unit test: isolated logic
A unit test is appropriate when the class can be exercised with small test doubles or mocked collaborators. For example, an eligibility rule can be checked without installing Drupal:
<?php
namespace Drupal\Tests\example_module\Unit;
use Drupal\Tests\UnitTestCase;
use Drupal\example_module\EligibilityChecker;
final class EligibilityCheckerTest extends UnitTestCase {
public function testEligibleAccountIsAccepted(): void {
$checker = new EligibilityChecker();
self::assertTrue($checker->isEligible(['status' => 'active']));
}
}
This is illustrative: replace the class, namespace, and method with code from your module. Keep the example’s class free of Drupal service lookups so the test remains isolated. If production code needs a service, inject it and provide a test double or mock rather than relying on global container state.
Kernel test: Drupal services or entities
Use a kernel test when the behavior needs Drupal’s container, entity APIs, or selected extensions, but not a complete browser journey. A kernel test must install or enable the dependencies its behavior needs. Its setup is project-specific, so copy the module list and schema requirements from a relevant test in your codebase rather than enabling every extension.
<?php
namespace Drupal\Tests\example_module\Kernel;
use Drupal\KernelTests\KernelTestBase;
final class ExampleServiceTest extends KernelTestBase {
protected static $modules = ['system', 'user', 'example_module'];
public function testServiceUsesDrupalState(): void {
$this->installSchema('system', ['key_value']);
$service = $this->container->get('example_module.example_service');
self::assertNotNull($service);
}
}
This skeleton demonstrates the base class and container access; it is not a universal test. Install the schemas, configuration, and modules required by the actual behavior. For configuration-dependent tests, install the required configuration or create it in setup. A kernel test still needs a working database connection.
Functional test: server-rendered behavior
Use BrowserTestBase for assertions about the integrated Drupal site, such as whether a route is accessible to a role, a form can be submitted, or expected content is rendered. Functional tests run a full Drupal instance and require Drupal to be reachable through a web server according to the project’s test setup. They do not execute actual browser JavaScript or Ajax.
<?php
namespace Drupal\Tests\example_module\Functional;
use Drupal\Tests\BrowserTestBase;
final class ExamplePageTest extends BrowserTestBase {
protected static $modules = ['system', 'user', 'example_module'];
public function testPageIsVisibleToAuthorizedUser(): void {
$account = $this->drupalCreateUser(['access content']);
$this->drupalLogin($account);
$this->drupalGet('/example');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Example page');
}
}
Replace the permission, path, and expected content with values defined by your site. If the assertion depends on access control, create a user with the exact permission under test. Avoid asserting incidental markup that is likely to change while the behavior remains correct.
Functional JavaScript test: browser-side behavior
Use WebDriverTestBase when JavaScript or Ajax changes the result. This requires a compatible browser and WebDriver setup in addition to Drupal and PHPUnit. Wait for a visible state or completion condition before asserting; a fixed short delay can make a test flaky when the browser or environment is slower.
<?php
namespace Drupal\Tests\example_module\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
final class ExampleInteractionTest extends WebDriverTestBase {
protected static $modules = ['system', 'user', 'example_module'];
public function testInteractionUpdatesThePage(): void {
$this->drupalGet('/example-interaction');
$page = $this->getSession()->getPage();
$page->pressButton('Load details');
$this->assertSession()->assertWaitOnAjaxRequest();
$this->assertSession()->pageTextContains('Details loaded');
}
}
Use a button and expected response that exist in your application. If the interaction is not Ajax-based, wait for the relevant element or text to appear instead of waiting on an Ajax request that never occurs.
4. How do I run PHPUnit tests?
- Check your versions. Confirm the Drupal core, PHP, PHPUnit, database, and local development environment versions supported by the project. Drupal’s running-tests guide was updated July 17, 2026 and includes PHPUnit v9 guidance for Drupal 10 and below. Do not copy version-specific settings into a different version without checking the current guide.
- Find the project’s PHPUnit configuration. PHPUnit reads
phpunit.xmlfrom the current directory unless you provide another configuration. Drupal projects often use a project-level configuration that references core’s defaults. Run commands from the directory whose configuration you intend to use, or pass the correct configuration file explicitly. - Set environment-specific values. The Drupal guide documents
SIMPLETEST_BASE_URL,SIMPLETEST_DB, andBROWSERTEST_OUTPUT_DIRECTORYfor PHPUnit v9 configurations used with Drupal 10 and below. Set the base URL and database connection to match your local test environment. Do not point tests at production data. - Run the project’s configured PHPUnit executable. Use the Composer-installed binary and your project’s configuration. For example, adapt this command to the paths and executable supported by your project:
vendor/bin/phpunit -c web/core/phpunit.xml.dist modules/custom/example_module/tests/src/Unit
The path above is an example, not a universal Drupal layout. If your project has a root phpunit.xml, use that configuration instead. To run one test file, pass its path rather than the test directory. To list PHPUnit options supported by the installed version, run:
vendor/bin/phpunit --help
Do not assume the same command or flags apply to every Drupal and PHPUnit version. Follow [Drupal’s current running PHPUnit guide](https://www.drupal.org/docs/develop/automated-testing/phpunit-in-drupal/running-phpunit-tests) and [PHPUnit in Drupal](https://www.drupal.org/docs/develop/automated-testing/phpunit-in-drupal). Composer-managed core updates can overwrite a phpunit.xml stored inside the core/ directory; keep project-specific configuration in the project’s intended location.
Prerequisites by test type
| Layer | Typical prerequisite | What to check first |
|---|---|---|
| Unit | PHP, PHPUnit, autoloading, test dependencies | The project can discover the test class and load the class under test. |
| Kernel | Unit prerequisites plus a usable database connection and required Drupal extensions | The configured test database is reachable, and setup installs the schemas and modules needed by the test. |
| Functional | Drupal test configuration and an accessible web server/base URL | The URL and database settings point to the intended test environment. |
| Functional JavaScript | Functional prerequisites plus a compatible WebDriver and browser | The browser driver can start and connect to the test site. |
DDEV provides add-ons for PHPUnit workflows, and its Selenium Standalone Chrome add-on can support core PHPUnit and Nightwatch testing. Add-ons and compatibility vary, so check the current Drupal and DDEV documentation for your versions before configuring them.
5. Test active configuration deliberately
When a test asks, “How do I run PHPUnit tests against active config?”, first identify which configuration the behavior is expected to use. A test installation is not automatically the same thing as a site’s active configuration. Tests should create or install the configuration they need within an isolated test environment, then assert the behavior produced by that configuration.
- For configuration that is part of the module, include and install the relevant default configuration in the test setup.
- For configuration created by the test, create it through Drupal’s configuration APIs and keep the setup explicit.
- For behavior that depends on a site’s exported configuration, ensure the test environment imports the intended configuration before running the assertion.
- Keep test configuration and databases separate from live site data. A test should be repeatable without changing a developer’s or production site’s active configuration.
The exact setup depends on the Drupal version, test layer, and project configuration. Use a kernel test when configuration is the only Drupal integration needed; use a functional test when the assertion concerns how the configured site responds through a route or form.
6. Cypress, Nightwatch, Behat, and performance testing
These tools address different workflows. Drupal’s documentation lists them alongside PHPUnit testing; select one based on the kind of scenario and the project’s existing tooling.
- Cypress: Consider for browser-based application workflows when the project uses Cypress. It is a separate browser-testing approach, not a PHPUnit test type.
- Nightwatch: Consider for JavaScript and browser workflows. Drupal documents Nightwatch testing, and its running guide discusses DDEV Selenium Standalone Chrome for core PHPUnit and Nightwatch setup.
- Behat: Consider when readable scenarios expressed as Given/When/Then are useful to the team. It does not replace lower-level tests for isolated logic.
- Performance testing: Use when the question is about performance under a defined workload. Functional correctness tests alone do not establish how a site behaves under load.
For browser-level checks of a publicly reachable page’s visual output, screenshot capture can be a separate review step from Drupal’s PHPUnit suite. It can help inspect what a page looks like after a deployment, but it does not prove that PHP logic, permissions, or database behavior is correct.
7. Screenshot a Drupal page without setting up a browser
If you need a page image for visual review or documentation, [ScreenshotNeo](https://screenshotneo.com) is a website screenshot API and MCP server. A GET request can return PNG, JPEG, WebP, or PDF output. Its 63 options include full-page capture with lazy images loaded, selector capture, device and viewport settings, custom CSS and JavaScript, waits, request blocking, headers and cookies, caching, async jobs, bulk capture, and more. See the [ScreenshotNeo API documentation](https://screenshotneo.com/docs/).
cURL
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://example.com \
-o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://example.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
Replace the target URL with a publicly reachable page you are authorized to capture. Keep API keys out of source control and client-side code. ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for AI agents using Claude, Cursor, or another MCP client. The free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots.
Sign up free for 1,000 screenshots a month, no card required.
8. Troubleshooting Drupal tests
| Symptom | Likely cause | Fix |
|---|---|---|
| PHPUnit cannot find a test or class | Wrong working directory or configuration, undiscovered test path, or autoloading/namespace mismatch | Run from the project root expected by the configuration, pass the intended config explicitly, and compare the class namespace and directory with nearby tests. |
| Database connection fails | SIMPLETEST_DB is missing, malformed, unreachable, or points to an unavailable database |
Check the test database settings for your version and ensure the database service is running. Kernel and higher-level tests require database access. |
| Functional test cannot reach the site | SIMPLETEST_BASE_URL is wrong or the web server is not serving the test site |
Start the project’s web server, confirm the base URL from the same environment where PHPUnit runs, and check container networking if using DDEV or another container setup. |
| Kernel test reports missing service, module, or schema | The test has not enabled or installed a dependency required by the code under test | Enable the smallest required extension set and install required schema or configuration in test setup. |
| Functional test passes but JavaScript behavior is broken | BrowserTestBase does not exercise actual JavaScript or Ajax |
Move the assertion to a functional JavaScript test or an appropriate browser workflow using a real browser. |
| Functional JavaScript test hangs or cannot start a browser | WebDriver/browser setup is missing, incompatible, or unreachable | Check the driver and browser versions against the project setup, verify the driver endpoint, and follow the current Drupal and DDEV guidance. |
| Ajax assertion is flaky | The test asserts before the browser-side request or DOM update completes | Wait for the Ajax request or for the specific expected element/state. Avoid arbitrary tiny sleeps. |
| Test changes local configuration or leaves confusing artifacts | The test is using a shared or incorrectly configured environment | Use isolated test databases and test setup. Configure browser-test output in the supported way for the project’s Drupal and PHPUnit versions. |
| Tests fail after a core update | The project may have an outdated configuration or a core update may have overwritten configuration stored under core/ |
Review the project-level PHPUnit configuration and current version guidance; keep custom settings outside Composer-managed core files. |
9. Performance, reliability, and cost
- Execution time: Unit tests avoid Drupal bootstrapping and are the most focused layer. Kernel tests add a Drupal kernel and database. Functional and functional JavaScript tests require more setup, and browser-based tests typically have more moving parts. Keep each test at the lowest layer that can verify its behavior.
- Reliability: Make setup explicit, isolate databases, use deterministic fixtures, and wait for observable browser conditions. A test that relies on external services, shared state, or a short fixed delay is more likely to fail for reasons unrelated to the behavior being tested.
- Maintenance: Assert meaningful outcomes rather than incidental markup. Higher-level tests exercise more integration points, so keep their count focused on important user-visible journeys.
- Cost: Drupal’s test guidance does not establish a universal monetary cost or runtime benchmark. Budget for the infrastructure your chosen layer needs: a database for kernel tests, a reachable web server for functional tests, and browser/WebDriver infrastructure for functional JavaScript tests. Run the most relevant focused tests during development and the project’s broader suite in its established CI workflow.
10. FAQ
How to properly test with Drupal?
Match each assertion to the behavior: unit for isolated logic, kernel for Drupal services or entities, functional for integrated server-rendered behavior, and functional JavaScript for real browser JavaScript or Ajax.
Can a functional Drupal test verify JavaScript?
No. Drupal’s BrowserTestBase tests simulate browser behavior but do not exercise actual JavaScript or Ajax. Use WebDriverTestBase or a suitable browser-testing tool for that behavior.
Do I need a database for every Drupal test?
No. Isolated unit tests can run without a working Drupal installation. Kernel tests need a database connection, and browser tests need the Drupal test site and web-serving setup.
Should I use Behat, Cypress, or Nightwatch instead of PHPUnit?
Choose based on the scenario and existing project workflow. Drupal documents all of these approaches, but they serve different use cases and do not replace every PHPUnit layer.
Where should PHPUnit configuration live?
Use the location and configuration expected by the project, and check the current Drupal guide for your versions. Avoid keeping project-specific settings in Composer-managed core files that an update may overwrite.


