By Brian Danin |
Automated testing is often viewed as a “nice to have” in Drupal development—something teams plan to implement “when there’s time.” But treating testing as optional is a costly mistake. The reality is that automated testing is fundamental to building maintainable Drupal applications, reducing bugs, and enabling teams to refactor and upgrade with confidence.
This guide will walk you through the essentials of automated testing in Drupal, from basic concepts to practical implementation, helping you build a testing practice that actually fits into your development workflow.
Drupal Testing Strategy
Use the fastest test type that can verify your requirement
JavaScript Tests
Test with full JavaScript execution
Functional Tests
Test complete user interactions
Kernel Tests
Test with Drupal subsystems
Unit Tests
Test individual classes in isolation
Pro Tip: Start with unit tests and move up the pyramid only when necessary
Why Automated Testing Matters for Drupal
Before diving into the “how,” let’s address the “why.” Drupal projects face unique challenges that make automated testing particularly valuable:
The Complexity Challenge
Drupal sites aren’t simple. They involve:
- Multiple layers of abstraction (entities, plugins, services)
- Complex permission systems
- Configuration dependencies
- Contributed module interactions
- Custom business logic woven throughout
Manual testing can’t reliably catch all the ways these pieces interact. Automated tests provide systematic verification that your code works as intended across different scenarios.
The Upgrade Imperative
Drupal has a regular major version cycle. Moving from Drupal 9 to Drupal 10, or updating contributed modules, can break functionality in subtle ways. Without automated tests, you’re essentially hoping nothing broke—or relying on time-consuming manual QA.
Automated tests let you upgrade with confidence, immediately identifying what broke and where.
The Refactoring Safety Net
Legacy Drupal code often needs refactoring: improving performance, modernizing patterns, or restructuring for maintainability. Without tests, refactoring is dangerous—you might fix one thing and break three others.
Tests provide a safety net, verifying that refactored code still behaves correctly.
The Collaboration Factor
When multiple developers work on a codebase, changes can have unexpected consequences. Automated tests act as a contract: “This is how this code should behave.” They catch regressions before they reach production.
Understanding Drupal’s Testing Framework
Drupal uses PHPUnit as its testing framework, extended with Drupal-specific base classes that provide common functionality. Understanding the different types of tests available helps you choose the right approach for each situation.
Test Types in Drupal
Drupal provides several test base classes, each suited for different scenarios:
1. Unit Tests (UnitTestCase)
Purpose: Test individual PHP classes in isolation, without bootstrapping Drupal.
Use When:
- Testing utility classes
- Validating business logic that doesn’t depend on Drupal APIs
- Checking pure functions and algorithms
Advantages:
- Extremely fast (milliseconds)
- No database required
- Forces good architectural separation
Limitations:
- Can’t test code that depends on Drupal services, entities, or database
Example Use Case: Testing a custom date calculation utility or a string formatting class.
2. Kernel Tests (KernelTestBase)
Purpose: Test code that needs some Drupal subsystems but not the full framework.
Use When:
- Testing custom services
- Validating entity operations
- Checking field formatters or widgets
- Testing plugins (blocks, filters, formatters)
Advantages:
- Much faster than functional tests
- Access to database and essential services
- Can install specific modules as needed
Limitations:
- No HTTP layer
- No rendered output testing
- Limited to API-level verification
Example Use Case: Testing a custom field formatter that transforms field data before display.
3. Functional Tests (BrowserTestBase)
Purpose: Test complete user interactions through a simulated browser.
Use When:
- Testing forms and form validation
- Verifying page rendering and access control
- Checking multi-step workflows
- Validating user-facing functionality
Advantages:
- Full Drupal bootstrap
- Can click links, submit forms, verify rendered output
- Tests behavior as users experience it
Limitations:
- Slower (seconds per test)
- More complex to set up
- Can be brittle if not well-designed
Example Use Case: Testing a custom node creation form with complex validation rules.
4. JavaScript Tests (JavascriptTestBase)
Purpose: Test functionality that requires JavaScript execution.
Use When:
- Testing AJAX interactions
- Validating dynamic UI components
- Checking client-side validation
Advantages:
- Real browser environment
- Full JavaScript execution
Limitations:
- Significantly slower
- Requires browser driver (headless Chrome)
- More complex debugging
Example Use Case: Testing an AJAX-powered filter interface that updates content without page reload.
Note: For comprehensive end-to-end testing across multiple browsers with advanced features like visual regression and network mocking, consider using Playwright to complement Drupal’s built-in testing framework.
Choosing the Right Test Type
A common principle: Use the fastest test type that can verify your requirement.
Unit Tests → Kernel Tests → Functional Tests → JavaScript Tests
(Fastest) (Slowest)
Don’t use functional tests for logic you could verify with kernel tests. Don’t use kernel tests for pure logic that could be unit tested.
Setting Up Your Testing Environment
Before writing tests, ensure your environment is configured correctly.
Prerequisites
- PHP and PHPUnit: Drupal 10 requires PHP 8.1+ and uses PHPUnit 9.
- Drupal installed via Composer: Testing depends on autoloading and development dependencies.
- phpunit.xml configuration: Tells PHPUnit how to run Drupal tests.
Configuring phpunit.xml
Drupal core includes core/phpunit.xml.dist as a template. Copy it to your project root as phpunit.xml and customize:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/9.5/phpunit.xsd"
bootstrap="web/core/tests/bootstrap.php"
colors="true">
<php>
<!-- Database connection for tests -->
<env name="SIMPLETEST_DB" value="mysql://user:pass@localhost/drupal_test"/>
<!-- Base URL for functional tests -->
<env name="SIMPLETEST_BASE_URL" value="http://localhost"/>
<!-- Disable deprecation notices to reduce noise -->
<env name="SYMFONY_DEPRECATIONS_HELPER" value="disabled"/>
</php>
<testsuites>
<testsuite name="custom">
<directory>./web/modules/custom/*/tests</directory>
</testsuite>
</testsuites>
</phpunit>
Key Configuration Points:
SIMPLETEST_DB: Separate database for tests (never use your development database)SIMPLETEST_BASE_URL: URL where test site runs- Test suite paths: Where PHPUnit looks for tests
Creating a Test Database
Tests need an isolated database:
mysql -u root -p -e "CREATE DATABASE drupal_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -u root -p -e "GRANT ALL ON drupal_test.* TO 'user'@'localhost';"
Important: Tests will create and destroy tables. Never point this at your development database.
Writing Your First Test
Let’s start with a practical example: testing a custom service that calculates content readability scores.
The Service (Simplified)
<?php
namespace Drupal\my_module\Service;
class ReadabilityCalculator {
public function calculateScore(string $text): int {
$wordCount = str_word_count($text);
$sentenceCount = substr_count($text, '.') + substr_count($text, '!') + substr_count($text, '?');
if ($sentenceCount === 0) {
return 0;
}
$avgWordsPerSentence = $wordCount / $sentenceCount;
// Simplified scoring
if ($avgWordsPerSentence <= 15) {
return 90; // Easy
} elseif ($avgWordsPerSentence <= 20) {
return 70; // Medium
} else {
return 50; // Hard
}
}
}
The Kernel Test
Create web/modules/custom/my_module/tests/src/Kernel/ReadabilityCalculatorTest.php:
<?php
namespace Drupal\Tests\my_module\Kernel;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests the ReadabilityCalculator service.
*
* @group my_module
*/
class ReadabilityCalculatorTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['my_module'];
/**
* The readability calculator service.
*
* @var \Drupal\my_module\Service\ReadabilityCalculator
*/
protected $calculator;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Install module configuration if needed
$this->installConfig(['my_module']);
// Get the service from container
$this->calculator = $this->container->get('my_module.readability_calculator');
}
/**
* Tests easy readability score.
*/
public function testEasyReadability() {
$text = "This is easy. It has short sentences. Each sentence is simple.";
$score = $this->calculator->calculateScore($text);
$this->assertEquals(90, $score, 'Text with short sentences should score 90.');
}
/**
* Tests medium readability score.
*/
public function testMediumReadability() {
$text = "This sentence is a bit longer with more words. It still maintains reasonable length. The complexity is moderate.";
$score = $this->calculator->calculateScore($text);
$this->assertEquals(70, $score, 'Text with medium-length sentences should score 70.');
}
/**
* Tests hard readability score.
*/
public function testHardReadability() {
$text = "This is a significantly longer sentence that contains many words and clauses, making it more challenging to read and understand for most audiences. Such sentences require more cognitive effort and concentration from readers.";
$score = $this->calculator->calculateScore($text);
$this->assertEquals(50, $score, 'Text with long sentences should score 50.');
}
/**
* Tests edge case with no sentences.
*/
public function testNoSentences() {
$text = "just words with no punctuation";
$score = $this->calculator->calculateScore($text);
$this->assertEquals(0, $score, 'Text without sentences should score 0.');
}
}
Running the Test
./vendor/bin/phpunit web/modules/custom/my_module/tests/src/Kernel/ReadabilityCalculatorTest.php
You should see output indicating which tests passed or failed.
Test Anatomy
Let’s break down key components:
- Namespace and Class: Follow Drupal’s PSR-4 structure
@groupannotation: Allows running related tests together$modulesarray: Lists modules to enable for testingsetUp()method: Runs before each test, initializes test environment- Test methods: Prefixed with
test, each verifies specific behavior - Assertions: Verify expected outcomes (
assertEquals,assertTrue, etc.)
Testing Common Drupal Patterns
Testing Custom Entities
When testing custom entities, use kernel tests to verify:
- Entity creation and saving
- Field value handling
- Entity validation
- Access control
Example:
public function testCustomEntityCreation() {
$entity = MyCustomEntity::create([
'name' => 'Test Entity',
'field_status' => 'active',
]);
$violations = $entity->validate();
$this->assertCount(0, $violations, 'Entity validation should pass.');
$entity->save();
$this->assertNotEmpty($entity->id(), 'Entity should receive an ID after saving.');
}
Testing Forms
Use functional tests for forms:
public function testCustomForm() {
$this->drupalLogin($this->createUser(['access content']));
$this->drupalGet('/my-custom-form');
$this->assertSession()->statusCodeEquals(200);
$this->submitForm([
'email' => 'test@example.com',
'message' => 'Test message',
], 'Submit');
$this->assertSession()->pageTextContains('Your message has been sent.');
}
Testing Permissions and Access
Verify access control logic:
public function testContentAccess() {
// Create a node
$node = $this->createNode(['type' => 'article']);
// Anonymous user should not have access
$this->drupalGet($node->toUrl());
$this->assertSession()->statusCodeEquals(403);
// Authenticated user should have access
$user = $this->createUser(['access content']);
$this->drupalLogin($user);
$this->drupalGet($node->toUrl());
$this->assertSession()->statusCodeEquals(200);
}
Testing Plugins
For plugin testing (blocks, formatters, filters), kernel tests work well:
public function testCustomBlockPlugin() {
$block = $this->container->get('plugin.manager.block')
->createInstance('my_custom_block', []);
$build = $block->build();
$this->assertArrayHasKey('#markup', $build);
$this->assertStringContainsString('Expected content', $build['#markup']);
}
Best Practices for Drupal Testing
1. Test Behavior, Not Implementation
Focus on what your code does, not how it does it. This makes tests resilient to refactoring.
Good: Test that a form validation message appears when invalid data is submitted.
Bad: Test that a specific private method is called during validation.
2. Keep Tests Independent
Each test should run successfully regardless of order. Don’t rely on state from previous tests.
Use setUp() for common initialization and tearDown() for cleanup if needed.
3. Use Descriptive Test Names
Test method names should clearly indicate what’s being tested:
// Good
public function testUserCannotAccessUnpublishedContent()
// Bad
public function testAccess()
4. Don’t Test Framework Code
Don’t test that Drupal core or contributed modules work correctly. Test your custom logic.
Don’t test: That entity save works. Do test: That your custom validation logic correctly prevents saving invalid entities.
5. Use Data Providers for Multiple Scenarios
When testing the same logic with different inputs, use PHPUnit data providers:
/**
* @dataProvider scoreDataProvider
*/
public function testScoreCalculation($text, $expectedScore) {
$score = $this->calculator->calculateScore($text);
$this->assertEquals($expectedScore, $score);
}
public function scoreDataProvider() {
return [
['Short. Simple. Easy.', 90],
['This is a medium length sentence.', 70],
['This is a significantly longer sentence with many words.', 50],
];
}
6. Test Edge Cases
Don’t just test the happy path. Consider:
- Empty inputs
- Invalid data
- Boundary conditions
- Permission edge cases
7. Mock External Dependencies
When your code depends on external APIs or services, mock them to keep tests fast and reliable:
$httpClient = $this->createMock(ClientInterface::class);
$httpClient->method('request')
->willReturn(new Response(200, [], '{"status": "ok"}'));
Integrating Tests into Your Workflow
Writing tests is only useful if they’re actually run regularly.
Run Tests Locally
Before committing code:
# Run all custom module tests
./vendor/bin/phpunit --testsuite custom
# Run specific test
./vendor/bin/phpunit path/to/TestFile.php
# Run tests in a specific group
./vendor/bin/phpunit --group my_module
Continuous Integration
Integrate testing into your CI/CD pipeline (GitLab CI, GitHub Actions, etc.):
# Example GitLab CI configuration
test:
script:
- composer install
- cp phpunit.xml.dist phpunit.xml
- ./vendor/bin/phpunit --testsuite custom
This ensures tests run on every commit or merge request, catching issues early.
Code Coverage
Track which code is covered by tests:
./vendor/bin/phpunit --coverage-html coverage
Aim for meaningful coverage of critical paths, not arbitrary percentage goals.
Common Challenges and Solutions
“Tests Are Too Slow”
Solution: Use the fastest test type possible. Unit and kernel tests should be fast. If functional tests are slow, consider:
- Reducing the number of modules enabled
- Using kernel tests where possible
- Running only affected tests during development
“Tests Are Brittle”
Solution: Brittle tests break frequently due to unrelated changes. To reduce brittleness:
- Test behavior, not implementation details
- Avoid testing framework internals
- Use explicit waits in JavaScript tests
- Don’t rely on specific HTML structure when testing rendered output
“Hard to Test Legacy Code”
Solution: Legacy code often wasn’t designed for testing. Strategies:
- Add tests for new functionality
- When fixing bugs, write a test that reproduces the bug first
- Gradually refactor code to be more testable (dependency injection, smaller methods)
- Use characterization tests: tests that document current behavior before refactoring
“Not Sure What to Test”
Solution: Start with:
- New features you’re adding
- Bugs you’ve encountered
- Business-critical functionality
- Code you’re refactoring
You don’t need 100% coverage from day one. Build testing practice gradually.
Getting Started: A Practical Action Plan
If you’re new to automated testing in Drupal, here’s a phased approach:
Phase 1: Foundation (Week 1)
- Set up
phpunit.xmland test database - Run existing Drupal core tests to verify setup
- Write one simple unit test for a utility function
- Write one kernel test for a custom service
Phase 2: Integration (Weeks 2-3)
- Write functional tests for a custom form
- Test a custom plugin (block, field formatter, etc.)
- Add tests to your CI/CD pipeline
- Document testing approach for your team
Phase 3: Habit Building (Ongoing)
- Write tests for all new features
- Add tests when fixing bugs
- Gradually increase coverage of critical paths
- Refactor code to improve testability
Conclusion: Testing as a Development Accelerator
The most common objection to automated testing is “We don’t have time.” But this perspective is backwards. You don’t have time NOT to test.
Time spent writing tests is time saved in:
- Debugging production issues
- Manual QA before releases
- Fearful, cautious refactoring
- Investigating “what broke?” after upgrades
Automated testing isn’t about perfection—it’s about confidence. Confidence that your code works, that changes won’t break existing functionality, and that your Drupal site can evolve without accumulating technical debt.
Start small. Test one function. Test one form. Build the habit. Over time, you’ll wonder how you ever developed without tests.
Your future self will thank you.
Need help implementing automated testing in your Drupal projects? Contact us to discuss testing strategies and quality assurance practices for your development workflow.