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
UnitTestCaseand mock the objects your form depends on - The real, modern PHPUnit class hierarchy Drupal uses — and why
simpletest/DrupalWebTestCasefrom Drupal 7 no longer exists - How to actually run a test with the
--groupflag
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.
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 withUnitTestCase? (Answer: not directly —UnitTestCasehas 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
UnitTestCasetests 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(). DrupalWebTestCaseand thesimpletestmodule 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@groupannotation 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.