Writing Unit Tests for Drupal Forms with PHPUnitfor Drupal 8 , 9 , 10 , and 11

Last updated :  

A form with 300 lines of validation logic is a form you're afraid to touch. Change one condition to fix a bug, and you have no way of knowing whether you just silently broke three other rules. Unit tests are the safety net: write them once against your form's pure logic, and every future change gets checked against them in seconds, automatically, forever.

What you'll learn in this lesson

  • Why unit tests for forms deliberately avoid touching a real Drupal site at all
  • How to extend UnitTestCase and mock the objects your form depends on
  • The real, modern PHPUnit class hierarchy Drupal uses — and why simpletest/DrupalWebTestCase from Drupal 7 no longer exists
  • How to actually run a test with the --group flag

Why "unit" means "no database, no bootstrap"

A unit test's entire point is speed and isolation: it should test one class's logic in complete isolation from Drupal's database, services container, and file system. That's what makes a test suite with hundreds of tests finish in seconds instead of minutes. If a class you're testing depends on a service, you don't wire up the real service — you hand it a mock that returns exactly the canned values your test needs.

A real unit test, from the Drupal Examples module

namespace Drupal\Tests\phpunit_example\Unit;

use Drupal\phpunit_example\DisplayInfoInterface;
use Drupal\phpunit_example\DisplayManager;
use Drupal\Tests\UnitTestCase;

/**
 * DisplayManager unit test with doubles.
 *
 * @group phpunit_example
 * @group examples
 */
class DisplayManagerTest extends UnitTestCase {

  public function testSimpleMockDisplayManager() {
    // Get a mock object belonging to our desired interface.
    $mock = $this->createMock(DisplayInfoInterface::class);
    $this->assertTrue($mock instanceof DisplayInfoInterface);

    // 'Program' our mock object to return a value for getDisplayName().
    $mock->expects($this->any())
      ->method('getDisplayName')
      ->willReturn('the display name');

    // Create the class we're actually testing.
    $dm = new DisplayManager();
    $dm->addDisplayableItem($mock);

    $this->assertEquals(1, $dm->countDisplayableItems());
    $this->assertSame($mock, $dm->item('the display name'));
    $this->assertNull($dm->item('nonexistent'));
  }

}

Notice this test never touches a form directly — it tests a plain PHP class (DisplayManager) that a form's validation or submit logic might depend on. That's the real shape unit-testable form logic takes: pull the interesting decision-making out of buildForm()/validateForm() into small, injectable, mockable classes, then unit-test those classes directly rather than trying to unit-test the form object itself.

createMock() — a stand-in you fully control

$mock = $this->createMock(DisplayInfoInterface::class);
$mock->expects($this->any())
  ->method('getDisplayName')
  ->willReturn('the display name');

createMock(), inherited from PHPUnit's own TestCase (which UnitTestCase extends), builds a fake object that satisfies an interface's type-hint without any real implementation behind it. ->method('getDisplayName')->willReturn(...) "programs" exactly one method to return a fixed value whenever it's called — this is how you isolate the class under test from every one of its dependencies.

Watch for stale Drupal 7 terminology: older tutorials (and some AI-generated ones) still reference DrupalWebTestCase or the simpletest module for writing Drupal tests. Both were removed in Drupal 8. Modern Drupal 10/11 testing is built entirely on PHPUnit, with three base classes for three different needs: UnitTestCase (this lesson — no bootstrap at all), KernelTestBase (a partial, database-backed bootstrap), and BrowserTestBase (a full browser simulation — covered next lesson).

Running the test

cd web
../vendor/bin/phpunit --group phpunit_example

The @group annotation in the test class's docblock is what --group filters on — it's how you run just this module's tests instead of your entire site's suite while you're actively developing.

Quick check: if your form's validateForm() calls \Drupal::entityTypeManager() directly inside the method body, can you unit-test that validation logic with UnitTestCase? (Answer: not directly — UnitTestCase has no service container, so that static call would fatal. You'd need to refactor the entity type manager in as a constructor-injected dependency first, so a test can hand it a mock instead — exactly the "pull logic into small, injectable classes" principle above.)

Key takeaways

  • UnitTestCase tests run with zero Drupal bootstrap — no database, no service container — which makes them the fastest tests you can write, but only usable for logic that doesn't call Drupal services directly.
  • Mock dependencies with $this->createMock(SomeInterface::class) rather than instantiating real Drupal services inside a unit test.
  • To make form logic unit-testable at all, extract the interesting decisions into small, constructor-injected classes rather than leaving them inline inside buildForm()/validateForm().
  • DrupalWebTestCase and the simpletest module are Drupal 7 relics — if you see either in a tutorial or Stack Overflow answer, it's out of date for Drupal 10/11.
  • Run a specific module's tests with vendor/bin/phpunit --group your_module_name, matching the @group annotation in the test class's docblock.

Coming up next

Unit tests prove your logic works in isolation — but a form only really works when a browser submits it, JavaScript fires, and a real page comes back. Next, we bring in BrowserTestBase to write functional tests that simulate an actual user filling out and submitting your form end to end.