Writing Drupal Tests with PHPUnit: Unit, Kernel, and Functional Tests

Drupal's testing infrastructure is built on PHPUnit, but it layers three distinct test types on top of it — each with different bootstrapping, speed, and fidelity trade-offs. Most Drupal developers have run tests but haven't written them deliberately. This guide covers what each test type is actually good for, how to set up a working test environment, and real module test examples that demonstrate the patterns you'll use daily.

The Three Test Types

TypeBootstrapDatabaseHTTPSpeedBest for
UnitNoneNoNoVery fast (<1 s/test)Pure PHP logic, value objects, utilities
KernelDrupal kernel + DBYesNoModerate (1–5 s/test)Services, plugins, entity CRUD, config
Functional (BrowserTest)Full Drupal installYesSimulatedSlow (5–30 s/test)Forms, routing, access control, UI flows

The rule of thumb: write unit tests wherever possible, kernel tests when you need Drupal services or the database, and functional tests only when you need to verify routing, form submission, or access control end-to-end.

Setup

Install dev dependencies

composer require --dev drupal/core-dev --update-with-all-dependencies

Configure phpunit.xml

cp web/core/phpunit.xml.dist phpunit.xml

Edit the relevant environment variables in phpunit.xml:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="web/core/tests/bootstrap.php"
         colors="true">

  <php>
    <!-- Site URL for functional tests -->
    <env name="SIMPLETEST_BASE_URL"   value="http://localhost:8080"/>
    <!-- Test database (separate from dev DB -- gets wiped!) -->
    <env name="SIMPLETEST_DB"         value="mysql://drupal:drupal@db/drupal_test"/>
    <!-- Functional test HTML output -->
    <env name="BROWSERTEST_OUTPUT_DIRECTORY" value="/var/www/html/web/sites/simpletest/browser_output"/>
    <env name="MINK_DRIVER_ARGS_WEBDRIVER" value='["chrome", {"browserName":"chrome","goog:chromeOptions":{"args":["--disable-gpu","--headless","--no-sandbox"]}},"http://chromedriver:4444/wd/hub"]'/>
  </php>

  <testsuites>
    <testsuite name="unit">
      <directory>web/modules/custom/*/tests/src/Unit</directory>
    </testsuite>
    <testsuite name="kernel">
      <directory>web/modules/custom/*/tests/src/Kernel</directory>
    </testsuite>
    <testsuite name="functional">
      <directory>web/modules/custom/*/tests/src/Functional</directory>
    </testsuite>
  </testsuites>

</phpunit>

Directory Structure

web/modules/custom/my_module/
├── my_module.info.yml
├── src/
│   └── ...
└── tests/
    └── src/
        ├── Unit/
        │   └── PriceCalculatorTest.php
        ├── Kernel/
        │   └── ProductStorageTest.php
        └── Functional/
            └── ProductFormTest.php

Unit Tests

Unit tests extend PHPUnit\Framework\TestCase. No Drupal bootstrap, no database. Anything that needs the service container must be mocked.

<?php
// tests/src/Unit/PriceCalculatorTest.php
declare(strict_types=1);

namespace Drupal\Tests\my_module\Unit;

use Drupal\my_module\PriceCalculator;
use PHPUnit\Framework\TestCase;

/**
 * @coversDefaultClass \Drupal\my_module\PriceCalculator
 * @group my_module
 */
class PriceCalculatorTest extends TestCase {

  /**
   * @covers ::calculateWithTax
   * @dataProvider taxProvider
   */
  public function testCalculateWithTax(int $price, float $rate, int $expected): void {
    $calculator = new PriceCalculator();
    $this->assertSame($expected, $calculator->calculateWithTax($price, $rate));
  }

  public static function taxProvider(): array {
    return [
      'standard DK VAT 25%'  => [10000, 0.25, 12500],
      'zero rate'            => [10000, 0.00, 10000],
      'fractional rounding'  => [999, 0.25, 1249],  // 1248.75 → floor
    ];
  }

  public function testNegativePriceThrows(): void {
    $this->expectException(\InvalidArgumentException::class);
    $this->expectExceptionMessage('Price must be non-negative');
    (new PriceCalculator())->calculateWithTax(-1, 0.25);
  }
}
<?php
// src/PriceCalculator.php
namespace Drupal\my_module;

class PriceCalculator {

  public function calculateWithTax(int $priceMinorUnits, float $taxRate): int {
    if ($priceMinorUnits < 0) {
      throw new \InvalidArgumentException('Price must be non-negative');
    }
    return (int) floor($priceMinorUnits * (1 + $taxRate));
  }
}
vendor/bin/phpunit --testsuite unit

Unit Testing with Mocked Drupal Services

<?php
namespace Drupal\Tests\my_module\Unit;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\my_module\FeatureFlags;
use PHPUnit\Framework\TestCase;

/**
 * @coversDefaultClass \Drupal\my_module\FeatureFlags
 * @group my_module
 */
class FeatureFlagsTest extends TestCase {

  public function testIsEnabledReturnsTrueWhenConfigSet(): void {
    $config = $this->createMock(ImmutableConfig::class);
    $config->method('get')
           ->with('features.new_checkout')
           ->willReturn(TRUE);

    $factory = $this->createMock(ConfigFactoryInterface::class);
    $factory->method('get')
            ->with('my_module.settings')
            ->willReturn($config);

    $flags = new FeatureFlags($factory);
    $this->assertTrue($flags->isEnabled('new_checkout'));
  }
}

Kernel Tests

Kernel tests extend Drupal\KernelTests\KernelTestBase. They boot a minimal Drupal kernel with a real database (created fresh for each test class). Use them when you need the entity system, config system, or any Drupal service.

<?php
// tests/src/Kernel/ProductStorageTest.php
declare(strict_types=1);

namespace Drupal\Tests\my_module\Kernel;

use Drupal\KernelTests\KernelTestBase;

/**
 * Tests the Product entity storage.
 *
 * @group my_module
 */
class ProductStorageTest extends KernelTestBase {

  /**
   * Modules to enable for this test.
   *
   * @var array
   */
  protected static $modules = ['my_module', 'user', 'system', 'field', 'text'];

  protected function setUp(): void {
    parent::setUp();
    // Install the schema for modules we depend on
    $this->installEntitySchema('user');
    $this->installEntitySchema('product');  // Our custom entity
    $this->installConfig(['my_module']);
    $this->installSchema('system', ['sequences']);
  }

  public function testCreateAndLoadProduct(): void {
    $storage = $this->container->get('entity_type.manager')
                               ->getStorage('product');

    // Create a product entity
    $product = $storage->create([
      'title'  => 'Test Widget',
      'sku'    => 'TW-001',
      'price'  => 4999,
      'status' => 1,
    ]);
    $product->save();

    $this->assertNotNull($product->id());

    // Load it back
    $loaded = $storage->load($product->id());
    $this->assertSame('Test Widget', $loaded->label());
    $this->assertSame('TW-001', $loaded->get('sku')->value);
    $this->assertSame(4999, (int) $loaded->get('price')->value);
  }

  public function testQueryByStatus(): void {
    $storage = $this->container->get('entity_type.manager')
                               ->getStorage('product');

    $storage->create(['title' => 'Published', 'sku' => 'A', 'price' => 100, 'status' => 1])->save();
    $storage->create(['title' => 'Draft',     'sku' => 'B', 'price' => 100, 'status' => 0])->save();

    $ids = $storage->getQuery()
                   ->condition('status', 1)
                   ->accessCheck(FALSE)
                   ->execute();

    $this->assertCount(1, $ids);
    $product = $storage->load(reset($ids));
    $this->assertSame('Published', $product->label());
  }
}
vendor/bin/phpunit --testsuite kernel

Testing Custom Services in Kernel Tests

<?php
public function testEmailNotificationService(): void {
  // Override the mailer service with a spy
  $mailer = $this->createMock(\Drupal\Core\Mail\MailManagerInterface::class);
  $mailer->expects($this->once())
         ->method('mail')
         ->with('my_module', 'order_confirmation', $this->anything());

  $this->container->set('plugin.manager.mail', $mailer);

  // Trigger the code path that sends mail
  $service = $this->container->get('my_module.order_service');
  $service->processOrder(1);
}

Functional Tests

Functional tests extend Drupal\Tests\BrowserTestBase. They install a full Drupal site into the test database and simulate browser interactions. They are slow but necessary for testing routing, forms, access, and the full request cycle.

<?php
// tests/src/Functional/ProductFormTest.php
declare(strict_types=1);

namespace Drupal\Tests\my_module\Functional;

use Drupal\Tests\BrowserTestBase;

/**
 * Tests the product add/edit forms.
 *
 * @group my_module
 */
class ProductFormTest extends BrowserTestBase {

  protected static $modules = ['my_module'];

  protected $defaultTheme = 'stark';

  public function testAdminCanCreateProduct(): void {
    // Create a user with the right permissions
    $admin = $this->drupalCreateUser([
      'administer products',
      'access administration pages',
    ]);
    $this->drupalLogin($admin);

    // Navigate to the add form
    $this->drupalGet('admin/content/products/add');
    $this->assertSession()->statusCodeEquals(200);
    $this->assertSession()->fieldExists('title[0][value]');

    // Submit the form
    $this->submitForm([
      'title[0][value]' => 'Test Widget',
      'sku[0][value]'   => 'TW-001',
      'price[0][value]' => '49.99',
      'status[value]'   => 1,
    ], 'Save');

    // Verify we were redirected and the entity was saved
    $this->assertSession()->statusCodeEquals(200);
    $this->assertSession()->pageTextContains('Product Test Widget has been created.');
  }

  public function testAnonymousCannotAccessAdminPage(): void {
    $this->drupalGet('admin/content/products');
    $this->assertSession()->statusCodeEquals(403);
  }

  public function testProductListingShowsPublishedOnly(): void {
    // Create published and unpublished products via API
    $storage = \Drupal::entityTypeManager()->getStorage('product');
    $storage->create(['title' => 'Visible',   'sku' => 'A', 'price' => 100, 'status' => 1])->save();
    $storage->create(['title' => 'Invisible', 'sku' => 'B', 'price' => 100, 'status' => 0])->save();

    $this->drupalGet('products');
    $this->assertSession()->pageTextContains('Visible');
    $this->assertSession()->pageTextNotContains('Invisible');
  }
}

Useful BrowserTestBase Assertions

// HTTP status
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->statusCodeEquals(403);

// Page content
$this->assertSession()->pageTextContains('Expected text');
$this->assertSession()->pageTextNotContains('Absent text');

// Fields and elements
$this->assertSession()->fieldExists('title[0][value]');
$this->assertSession()->elementExists('css', '.product-card');
$this->assertSession()->linkExists('Read more');

// Response headers
$this->assertSession()->responseHeaderEquals('Content-Type', 'application/json');

// Navigation
$this->drupalGet('/path/to/page');
$this->clickLink('Click me');
$this->submitForm(['field' => 'value'], 'Submit button text');

Running Tests

# All tests in a custom module
vendor/bin/phpunit web/modules/custom/my_module/

# Just unit tests
vendor/bin/phpunit --testsuite unit

# Specific test class
vendor/bin/phpunit web/modules/custom/my_module/tests/src/Kernel/ProductStorageTest.php

# Tests in a group
vendor/bin/phpunit --group my_module

# Verbose output
vendor/bin/phpunit -v web/modules/custom/my_module/

# With code coverage (requires Xdebug or pcov)
vendor/bin/phpunit --coverage-html /tmp/coverage web/modules/custom/my_module/

Running in Docker

docker compose exec php \
  vendor/bin/phpunit \
  --configuration /var/www/html/phpunit.xml \
  --testsuite unit \
  web/modules/custom/my_module/

GitHub Actions Integration

# .github/workflows/tests.yml
name: Drupal Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: drupal_test
          MYSQL_USER: drupal
          MYSQL_PASSWORD: drupal
        ports: ["3306:3306"]
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pdo_mysql, gd, zip
          coverage: pcov

      - run: composer install --prefer-dist --no-progress

      - name: Run unit tests
        env:
          SIMPLETEST_DB: "mysql://drupal:drupal@127.0.0.1/drupal_test"
          SIMPLETEST_BASE_URL: "http://localhost"
        run: vendor/bin/phpunit --testsuite unit --log-junit test-results.xml

      - name: Run kernel tests
        env:
          SIMPLETEST_DB: "mysql://drupal:drupal@127.0.0.1/drupal_test"
        run: vendor/bin/phpunit --testsuite kernel

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: test-results.xml

Summary Checklist

  • Install test dependencies with composer require --dev drupal/core-dev --update-with-all-dependencies
  • Copy web/core/phpunit.xml.dist to project root; set SIMPLETEST_DB and SIMPLETEST_BASE_URL
  • Use Unit tests (PHPUnit\Framework\TestCase) for pure PHP logic — they run in milliseconds
  • Use Kernel tests (KernelTestBase) for entity CRUD, services, and config — list all required modules in $modules and call installEntitySchema() in setUp()
  • Use Functional tests (BrowserTestBase) for form submission, routing, and access control — always set $defaultTheme = 'stark'
  • Annotate test methods with @covers and @group@group lets you run a module's tests in isolation
  • Mock Drupal services in unit tests with createMock(); override them in the service container for kernel tests with $this->container->set()
  • Use @dataProvider to cover multiple edge cases with one test method
  • Always call accessCheck(FALSE) on entity queries inside tests — test users lack session context
  • Run unit tests in CI on every push; gate merges on kernel tests; run functional tests nightly or pre-release

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.