You've written a lot of code across this course — hooks, blocks, forms, custom fields, plugins. Now for a question every developer eventually has to answer: how do you actually know your code still works after you (or someone else) changes something six months from now? The answer, in Drupal as everywhere else in professional PHP, is automated tests. This lesson covers the fastest, simplest kind: the unit test.
What you'll learn in this lesson
- What makes a test a "unit" test specifically, and why that makes it fast
- How PHPUnit discovers your test classes automatically
- The
@dataProviderpattern for running one test against many inputs - How to assert that bad input correctly throws an exception
- How Drupal 10 and 11 actually run tests today (it's not what older tutorials show you)
/admin/config/development/testing where you could tick boxes and run tests in the browser. That page genuinely no longer exists in Drupal 10 and 11 — it was removed along with the old SimpleTest system. Every test in this course now runs from the command line instead. This isn't a workaround; it's simply how modern Drupal testing works, and it's actually faster and more scriptable than the old browser UI ever was.What is a unit test, exactly?
A unit test verifies one small piece of PHP logic — usually a single class or method — in complete isolation. No database connection, no Drupal bootstrap, no HTTP request. Just plain PHP calling plain PHP and checking the result. Because there's nothing to set up, unit tests run in milliseconds, which means you can run hundreds of them every time you save a file without losing your train of thought.
We're going to study a real test from the Examples project: AddClassTest, which tests a deliberately tiny class called AddClass that does nothing but add two numbers together (with some validation). The class under test is almost too simple to be interesting — and that's exactly the point. It lets us focus entirely on the testing patterns, which are the same patterns you'll reach for constantly once your own classes are more complex.
The source file
Path: modules/phpunit_example/tests/src/Unit/AddClassTest.php
<?php
namespace Drupal\Tests\phpunit_example\Unit;
use Drupal\Tests\UnitTestCase;
use Drupal\phpunit_example\AddClass;
/**
* AddClass units tests.
*
* This test case demonstrates the following PHPUnit annotations:
* - dataProvider
* - expectedException.
*
* PHPUnit looks for classes with names ending in 'Test'. Then it
* looks to see whether that class is a subclass of
* \PHPUnit_Framework_TestCase. Drupal supplies us with
* Drupal\Tests\UnitTestCase, which is a subclass of
* \PHPUnit_Framework_TestCase. So yay, PHPUnit will find this class.
*
* In unit testing, there should be as few dependencies as possible.
* We want the smallest number of moving parts to be interacting in
* our test, or we won't be sure where the errors are, or whether our
* tests passed by accident.
*
* So with that in mind, it's up to us to build out whatever
* dependencies we need. In the case of AddClass, our needs are meager;
* we only want an instance of AddClass so we can test its add() method.
*
* @ingroup phpunit_example
*
* @group phpunit_example
* @group examples
*/
class AddClassTest extends UnitTestCase {
/**
* Very simple test of AddClass::add().
*
* This is a very simple unit test of a single method. It has
* a single assertion, and that assertion is probably going to
* pass. It ignores most of the problems that could arise in the
* method under test, so therefore: It is not a very good test.
*/
public function testAdd() {
$sut = new AddClass();
$this->assertEquals($sut->add(2, 3), 5);
}
/**
* Test AddClass::add() with a data provider method.
*
* This method is very similar to testAdd(), but uses a data provider method
* to test with a wider range of data.
*
* You can tell PHPUnit which method is the data provider using the
* '@dataProvider' annotation.
*
* The data provider method just returns a big array of arrays of arguments.
* That is, for each time you want this test method run, the data provider
* should create an array of arguments for this method. In this case, it's
* $expected, $a, and $b. So one set of arguments would look a bit like this
* pseudocode:
*
* @code
* array( valueForExpected, valueForA, valueForB )
* @endcode
*
* It would then wrap this up in a higher-level array, so that PHPUnit can
* loop through them, like this pseudocode:
*
* @code
* return array( array(first, set), array (next, set) );
* @endcode
*
* This test has a better methodology than testAdd(), because it can easily
* be adapted by other developers, and because it tries more than one data
* set. This test is much better than testAdd(), although it still only
* tests 'good' data. When combined with testAddWithBadDataProvider(),
* we get a better picture of the behavior of the method under test.
*
* @dataProvider addDataProvider
*
* @see self::addDataProvider()
*/
public function testAddWithDataProvider($expected, $a, $b) {
$sut = new AddClass();
$this->assertEquals($expected, $sut->add($a, $b));
}
/**
* Test AddClass::add() with data that should throw an exception.
*
* This method is similar to testAddWithDataProvider(), but the data
* provider gives us data that should throw an exception.
*
* This test uses the setExpectedException() method to tell PHPUnit that
* a thrown exception should pass the test. You specify a
* fully-qualified exception class name. If you specify \Exception, PHPUnit
* will pass any exception, whereas a more specific subclass of \Exception
* will require that exception type to be thrown.
*
* Alternately, you can use try and catch blocks with assertions in order
* to test exceptions. We won't demonstrate that here; it's a much better
* idea to test your exceptions with setExpectedException().
*
* @dataProvider addBadDataProvider
*
* @see self::addBadDataProvider()
*/
public function testAddWithBadDataProvider($a, $b) {
$sut = new AddClass();
$this->expectException(\InvalidArgumentException::class);
$sut->add($a, $b);
}
/**
* Data provider for testAddWithDataProvider().
*
* Data provider methods take no arguments and return an array of data
* to use for tests. Each element of the array is another array, which
* corresponds to the arguments in the test method's signature.
*
* Note also that PHPUnit tries to run tests using methods that begin
* with 'test'. This means that data provider method names should not
* begin with 'test'. Also, by convention, they should end with
* 'DataProvider'.
*
* @return array
* Nested arrays of values to check:
* - $a
* - $b
* - $expected
*
* @see self::testAddWithDataProvider()
*/
public static function addDataProvider() {
return [
[5, 2, 3],
[50, 20, 30],
];
}
/**
* Data provider for testAddWithBadDataProvider().
*
* Since AddClass::add() can throw exceptions, it's time
* to give it some data that will cause these exceptions.
*
* add() should throw exceptions if either of it's arguments are
* not numeric, and we will generate some test data to prove that
* this is what it actually does.
*
* @see self::testAddWithBadDataProvider()
*/
public static function addBadDataProvider() {
$bad_data = [];
// Set up an array with data that should cause add()
// to throw an exception.
$bad_data_types = ['string', FALSE, ['foo'], new \stdClass()];
// Create some data where both $a and $b are bad types.
foreach ($bad_data_types as $bad_datum_a) {
foreach ($bad_data_types as $bad_datum_b) {
$bad_data[] = [$bad_datum_a, $bad_datum_b];
}
}
// Create some data where $a is good and $b is bad.
foreach ($bad_data_types as $bad_datum_b) {
$bad_data[] = [1, $bad_datum_b];
}
// Create some data where $b is good and $a is bad.
foreach ($bad_data_types as $bad_datum_a) {
$bad_data[] = [$bad_datum_a, 1];
}
return $bad_data;
}
}
How it works
Where test classes live, and how PHPUnit finds them
Drupal enforces a strict PSR-4 folder and namespace convention for tests. Unit test classes live under tests/src/Unit/ inside the module folder, with a namespace matching Drupal\Tests\<module_name>\Unit. PHPUnit auto-discovers any class whose name ends in Test and which extends a recognized base test class — no manifest file, no registration step. Get the folder or namespace wrong, though, and your tests are simply never found. No error, no warning — they just silently don't run, which is a frustrating first bug for a lot of newcomers.
Extending UnitTestCase
class AddClassTest extends UnitTestCase {
Drupal\Tests\UnitTestCase is Drupal's thin wrapper around PHPUnit's own TestCase. It adds a few Drupal-flavored helpers for faking services without booting the real container, but crucially it does not bootstrap Drupal itself. That's the whole reason unit tests are so fast: there's no database connection to open and no service container to build, so each test runs in milliseconds rather than seconds.
The @group annotations
* @group phpunit_example
* @group examples
These docblock tags let you run a targeted slice of your test suite from the command line instead of everything at once. Tag a class with multiple groups and you can run it either narrowly (just this module) or broadly (every Examples module test) depending on which group you pass.
The "system under test" convention
$sut = new AddClass();
Notice every test method creates a fresh $sut ("system under test") variable. This is a widely used naming convention, not a Drupal-specific rule — it just makes it immediately obvious, at a glance, which object the test is actually exercising. Because AddClass takes no constructor arguments, there's no dependency injection or mocking needed here; it's as simple as new gets.
testAdd() — the simplest possible test
public function testAdd() {
$sut = new AddClass();
$this->assertEquals($sut->add(2, 3), 5);
}
assertEquals() is PHPUnit's workhorse assertion — it checks two values are equal. This test is honest about its own weakness (the source comments even say so): testing a single pair of numbers proves almost nothing about the method's real behavior. It exists purely to show you the minimal shape of a test before the next two methods make it genuinely useful.
testAddWithDataProvider() — one test, many inputs
/**
* @dataProvider addDataProvider
*/
public function testAddWithDataProvider($expected, $a, $b) {
$sut = new AddClass();
$this->assertEquals($expected, $sut->add($a, $b));
}
The @dataProvider addDataProvider annotation tells PHPUnit: "before running this test, call addDataProvider(), and run this method once for every row it returns." The parameter list ($expected, $a, $b) lines up positionally with each inner array the provider returns. PHPUnit labels each run in its output ("with data set #0", "#1", and so on), so a failure tells you exactly which input broke things. This is strictly better than writing several separate assertEquals() calls in one test method, because one failing row doesn't stop the others from running and reporting their own results.
addDataProvider() — supplying the good inputs
public static function addDataProvider() {
return [
[5, 2, 3],
[50, 20, 30],
];
}
Two rules to remember: data provider methods must be static (required since PHPUnit 10, which Drupal 11 ships with), and by convention their name should not start with test — otherwise PHPUnit would try to run the provider itself as a test. Each inner array is one full set of arguments, in the same order as the test method's parameters.
testAddWithBadDataProvider() — proving exceptions actually throw
public function testAddWithBadDataProvider($a, $b) {
$sut = new AddClass();
$this->expectException(\InvalidArgumentException::class);
$sut->add($a, $b);
}
$this->expectException() tells PHPUnit that whatever code runs after this line must throw the given exception class, or the test fails. This is the modern, clean way to test exception behavior — resist the temptation to wrap the call in a manual try/catch with your own pass/fail logic; expectException() does that job better and reads more clearly.
addBadDataProvider() — generating edge cases programmatically
$bad_data_types = ['string', FALSE, ['foo'], new \stdClass()];
foreach ($bad_data_types as $bad_datum_a) {
foreach ($bad_data_types as $bad_datum_b) {
$bad_data[] = [$bad_datum_a, $bad_datum_b];
}
}
Rather than typing out every bad combination by hand, this provider builds them with nested loops: every combination of two bad types (16 pairs), one good argument paired with each bad type (4 more), and the reverse (4 more) — 24 distinct exception-triggering cases from a few lines of code. When the number of edge cases is large, or likely to grow, generating them programmatically like this scales far better than hand-written lists.
What the code under test actually does
public function add($a, $b) {
foreach ([$a, $b] as $argument) {
if (!is_numeric($argument)) {
throw new \InvalidArgumentException('Arguments must be numeric.');
}
}
return $a + $b;
}
This lives in AddClass.php, and it's about as simple as real code gets: validate both arguments with is_numeric(), throw if either fails, otherwise add them. Simple as it is, the test class above exercises every single branch of it — the happy path, several valid input sets, and every realistic way it can be misused.
Actually running these tests
From your DDEV project root:
ddev exec vendor/bin/phpunit web/modules/contrib/examples/modules/phpunit_example/tests/src/Unit/
Or, using the group tag from earlier:
ddev exec vendor/bin/phpunit --group phpunit_example
Because unit tests never touch the database or boot Drupal's service container, this whole suite finishes in well under a second — which is exactly why you should run unit tests constantly during development, not just before a commit.
See it for yourself
Visit the phpunit_example module's own description page on your DDEV site — it documents this exact command-line workflow directly, which is a nice confirmation that what you just read matches how the module's own authors expect it to be run.
Quick check: if you renamed
addDataProvider()togetAddData()and updated the@dataProviderannotation to match, would the tests still work? Yes — the only naming rule PHPUnit actually enforces is that a data provider's name must not start withtest. Everything else is convention, not requirement.
Key takeaways
- Place unit test classes under
tests/src/Unit/with namespaceDrupal\Tests\<module_name>\Unit, extendingDrupal\Tests\UnitTestCase— PHPUnit auto-discovers any class whose name ends inTest. - Use
@dataProviderto link a test method to a static provider method, running one test against many input sets without duplicating assertion code. - Use
$this->expectException(\SomeException::class)before the code that should throw, rather than a manualtry/catch, to cleanly assert exception behavior. - Data provider methods must be
static, must not start withtest, and must return a nested array whose inner arrays map positionally to the test method's parameters. - Generate large sets of bad-input combinations programmatically (loops over arrays of bad types) instead of hand-writing them — it scales better and is easier to extend.
- Unit tests have zero Drupal bootstrap cost, so run them constantly during development and in every CI pipeline — they're your fastest safety net.
Coming up next
Unit tests are fast because they refuse to touch the database or Drupal's service container — which is great for testing pure logic, but useless the moment your code actually needs an entity, a config object, or a real service. In the next lesson, we'll look at kernel tests: the middle ground that gives you a real (if minimal) Drupal environment to test against, without the full overhead of a browser-driven functional test.