Skip to main content
Illustration of code modules passing through test gates, with check marks and one caught failure
Topic: Technology & innovation

Getting started with automated testing in Drupal

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

Write Fewer
Slower & More Complex
Faster & More Isolated

JavaScript Tests

JavascriptTestBase ⏱️ Slower

Test with full JavaScript execution

Functional Tests

BrowserTestBase ⏱️ Moderate (seconds)

Test complete user interactions

Kernel Tests

KernelTestBase ⚡ Fast (seconds)

Test with Drupal subsystems

Unit Tests

UnitTestCase ⚡ Milliseconds

Test individual classes in isolation

Write More

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

  1. PHP and PHPUnit: Drupal 10 requires PHP 8.1+ and uses PHPUnit 9.
  2. Drupal installed via Composer: Testing depends on autoloading and development dependencies.
  3. 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:

  1. Namespace and Class: Follow Drupal's PSR-4 structure
  2. @group annotation: Allows running related tests together
  3. $modules array: Lists modules to enable for testing
  4. setUp() method: Runs before each test, initializes test environment
  5. Test methods: Prefixed with test, each verifies specific behavior
  6. 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)

  1. Set up phpunit.xml and test database
  2. Run existing Drupal core tests to verify setup
  3. Write one simple unit test for a utility function
  4. Write one kernel test for a custom service

Phase 2: Integration (weeks 2-3)

  1. Write functional tests for a custom form
  2. Test a custom plugin (block, field formatter, etc.)
  3. Add tests to your CI/CD pipeline
  4. Document testing approach for your team

Phase 3: Habit building (ongoing)

  1. Write tests for all new features
  2. Add tests when fixing bugs
  3. Gradually increase coverage of critical paths
  4. 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.

More from Technology & innovation

View all

Ready to get started?

Contact us to discuss your business needs