Untested code is a liability. In Drupal specifically, where a single misconfigured service or a broken hook implementation can take down an entire site, automated testing is not a luxury โ€” it's the difference between confident deployments and deployment anxiety. PHPUnit is the foundation of Drupal's entire testing architecture, and understanding how to use it effectively is a core skill for any serious Drupal developer.

1. Why Testing Matters More in Drupal Than You Think

Drupal's architecture โ€” with its hook system, service container, event dispatcher, and plugin manager โ€” creates significant surface area for regressions. When you update a contrib module, change a service definition, or refactor a custom module, the consequences can ripple through the system in unexpected ways.

Drupal itself has over 85,000 lines of PHPUnit tests. That test suite is what gives the core maintainers the confidence to release major versions and security updates without breaking millions of sites. Your custom code deserves the same confidence.

2. PHPUnit in the Drupal Context

Drupal integrates PHPUnit through three test types, each targeting a different layer:

  • Unit tests (@group unit) โ€” Test a single PHP class in complete isolation, with no Drupal bootstrap. Fastest to run (milliseconds per test).
  • Kernel tests (@group kernel) โ€” Bootstrap a partial Drupal environment with the service container and database. Test services, hooks, and entity operations.
  • Functional tests (@group functional) โ€” Boot a full Drupal installation and simulate browser interactions. Slowest but most comprehensive.

Run your full test suite from the Drupal root:

./vendor/bin/phpunit --configuration core/phpunit.xml.dist \
  web/modules/custom/my_module --testdox

3. Writing Your First Drupal Unit Test

Unit tests live in modules/custom/my_module/tests/src/Unit/. Here's a minimal example testing a utility service:

slugGenerator = new SlugGenerator();
  }

  /**
   * @covers ::generate
   * @dataProvider slugProvider
   */
  public function testGenerate(string $input, string $expected): void {
    $this->assertSame($expected, $this->slugGenerator->generate($input));
  }

  public static function slugProvider(): array {
    return [
      'basic title'       => ['Hello World',        'hello-world'],
      'special characters'=> ['Drupal & PHP!',       'drupal-php'],
      'extra spaces'      => ['  Leading Spaces  ',  'leading-spaces'],
    ];
  }
}

Key points: extend UnitTestCase, use @covers annotations, use @dataProvider for multiple input scenarios.

4. Testing Services with Dependency Injection

Most Drupal services have dependencies injected via the container. To test them in isolation, pass mock dependencies through the constructor:

class ContentEnricherTest extends UnitTestCase {

  public function testEnrichAddsMetadata(): void {
    // Create a mock of the EntityTypeManagerInterface
    $entityTypeManager = $this->createMock(EntityTypeManagerInterface::class);

    // Configure what the mock returns
    $entityTypeManager->method('getStorage')
      ->willReturn($this->createMock(EntityStorageInterface::class));

    $enricher = new ContentEnricher($entityTypeManager);
    $result = $enricher->enrich(['title' => 'Test Node']);

    $this->assertArrayHasKey('processed_at', $result);
  }
}

5. Mocking Drupal Services

Drupal's global \Drupal static service locator is the enemy of unit testing โ€” it requires a full bootstrap to function. When testing code that calls \Drupal::service() or \Drupal::currentUser(), use the container mock helper:

// In setUp(), register a mock service in the test container
$container = new ContainerBuilder();
$languageManager = $this->createMock(LanguageManagerInterface::class);
$languageManager->method('getCurrentLanguage')
  ->willReturn(new Language(['id' => 'en']));
$container->set('language_manager', $languageManager);
\Drupal::setContainer($container);

This pattern lets you test code that uses \Drupal::service('language_manager') without a full Drupal bootstrap, keeping the test fast and isolated.

6. Kernel Tests vs Unit Tests

When your code genuinely requires the Drupal service container, database, or entity system, use a Kernel test instead of fighting to mock everything:

class NodeCreationKernelTest extends KernelTestBase {

  protected static $modules = ['node', 'user', 'field', 'my_module'];

  protected function setUp(): void {
    parent::setUp();
    $this->installEntitySchema('node');
    $this->installEntitySchema('user');
    $this->installConfig(['my_module']);
  }

  public function testNodeCreationTriggersHook(): void {
    $node = Node::create(['type' => 'article', 'title' => 'Test']);
    $node->save();
    // Assert hook_node_insert was triggered correctly
    $this->assertTrue(\Drupal::state()->get('my_module.hook_fired'));
  }
}

Kernel tests are slower than unit tests but faster than functional tests โ€” they're the right tool for testing hooks, services that interact with the entity system, and database operations.

7. Functional Tests with WebAssert

For testing actual page output, form submissions, and authenticated user flows, use Drupal's BrowserTestBase:

class ContactFormTest extends BrowserTestBase {

  protected static $modules = ['contact', 'my_module'];
  protected $defaultTheme = 'stark';

  public function testContactFormSubmission(): void {
    $this->drupalGet('/contact');
    $this->assertSession()->statusCodeEquals(200);

    $this->submitForm([
      'first_name' => 'Test',
      'email'      => 'test@example.com',
      'message'    => 'This is a test message for the form.',
    ], 'Send Message');

    $this->assertSession()->pageTextContains('Thank you');
  }
}

8. Integrating Tests into CI/CD

Tests only add value if they run automatically. Add PHPUnit to your GitLab CI or GitHub Actions pipeline:

# .gitlab-ci.yml
phpunit:
  stage: test
  image: drupal:11-php8.3-apache
  script:
    - composer install --no-interaction
    - cp .env.example .env.test
    - ./vendor/bin/phpunit --configuration core/phpunit.xml.dist
        web/modules/custom --log-junit reports/phpunit.xml
  artifacts:
    reports:
      junit: reports/phpunit.xml

Run unit and kernel tests on every push, reserve functional tests for merge request pipelines where the extra time is justified by the confidence they provide.

Conclusion

PHPUnit testing in Drupal is not about achieving 100% code coverage โ€” it's about building a safety net that catches regressions before they reach users. Start with unit tests for your utility classes and service methods, add kernel tests for hook implementations and entity operations, and use functional tests to verify critical user-facing flows. Even a modest test suite that covers your most business-critical custom code will dramatically reduce the anxiety of updates, refactoring, and new feature development.