A unit test is the smallest, fastest, most focused kind of automated test Drupal supports — no database, no HTTP requests, no service container. Just plain PHP calling plain PHP and checking the result. If your class's logic doesn't genuinely need Drupal running to verify, a unit test is the right tool, and it runs in milliseconds instead of the seconds a functional test costs.
What you'll learn in this lesson
- What makes a test a "unit" test, structurally, versus a functional or kernel test
- How to test one method with a single assertion, and why that alone isn't enough
- How
@dataProviderlets one test method verify many input combinations without duplicating code - How to test that bad input correctly throws an exception
The simplest possible unit test
namespace Drupal\Tests\phpunit_example\Unit;
use Drupal\Tests\UnitTestCase;
use Drupal\phpunit_example\AddClass;
class AddClassTest extends UnitTestCase {
public function testAdd() {
$sut = new AddClass();
$this->assertEquals($sut->add(2, 3), 5);
}
}
Extending Drupal\Tests\UnitTestCase (itself a thin wrapper around PHPUnit's own TestCase) is what makes Drupal's test runner discover this class at all. $sut stands for "system under test" — a naming convention worth adopting, since it makes it instantly clear which object in the test is the thing actually being verified versus supporting setup. This test is honest about its own weakness: testing a single pair of numbers proves almost nothing about add()'s real behavior.
Testing many inputs without duplicating code: @dataProvider
/**
* @dataProvider addDataProvider
*/
public function testAddWithDataProvider($expected, $a, $b) {
$sut = new AddClass();
$this->assertEquals($expected, $sut->add($a, $b));
}
public static function addDataProvider() {
return [
[5, 2, 3],
[50, 20, 30],
];
}
The @dataProvider addDataProvider annotation tells PHPUnit: "before running this test, call addDataProvider(), and run testAddWithDataProvider() once for every inner array it returns." Each inner array's values line up positionally with the test method's parameters — ($expected, $a, $b). PHPUnit labels each run in its output ("with data set #0", "#1"), so a failure tells you exactly which input combination broke, and one failing data set doesn't stop the others from running and reporting their own results.
Testing that bad input throws correctly
/**
* @dataProvider addBadDataProvider
*/
public function testAddWithBadDataProvider($a, $b) {
$sut = new AddClass();
$this->expectException(\InvalidArgumentException::class);
$sut->add($a, $b);
}
public static function addBadDataProvider() {
$bad_data_types = ['string', FALSE, ['foo'], new \stdClass()];
$bad_data = [];
foreach ($bad_data_types as $bad_datum_a) {
foreach ($bad_data_types as $bad_datum_b) {
$bad_data[] = [$bad_datum_a, $bad_datum_b];
}
}
return $bad_data;
}
$this->expectException(\InvalidArgumentException::class), called before the code that should throw, tells PHPUnit this test only passes if that exact exception type (or a subclass) is thrown during the rest of the method. If add() silently returned something instead of throwing, this test would fail — which is exactly the point: it proves the method actively rejects bad input rather than just happening to not crash on good input.
$this: notice both provider methods are declared public static function. PHPUnit calls data providers before the test class is even instantiated for that particular run, so a data provider has no $this context to work with — keep them as pure, self-contained functions that just build and return an array.See it for yourself
Run vendor/bin/phpunit --filter AddClassTest on your DDEV/local environment and read the output carefully — you'll see far more than 3 dots (one method), because PHPUnit expands each data-provider method into one test run per data set.
Quick check:
testAddWithBadDataProvider()tests four bad-data types crossed against themselves (16 combinations) plus 8 mixed good/bad combinations — why go to that trouble instead of one test with a single bad value? Because a bug might only surface for a specific combination (say, a string paired with an object, but not a string paired with FALSE) — testing every combination catches edge cases a single hand-picked example would miss entirely.
Key takeaways
- A unit test extends
Drupal\Tests\UnitTestCaseand exercises plain PHP logic with no database, HTTP, or service container involved. @dataProviderruns one test method once per row an accompanying static method returns — a compact way to cover many input combinations.$this->expectException(SomeException::class), called before the code that should throw, is the clean way to assert exception behavior.- Data provider methods must be
static— PHPUnit calls them before the test class instance exists.
Coming up next
Writing tests is only half the workflow — you also need to actually run them reliably, whether locally or in CI. Next, we'll look at executing your test suite from the command line with Drush and PHPUnit's own runner.