Writing tests
Your module's tests live in the Tests/ folder and travel with the module:
cp-content/modules/Example/Tests/
Unit/ pure logic that needs neither the core nor the database
Integration/ those that need a booted core and a real database
The namespace is Modules\Example\Tests\.... The test framework is PHPUnit 11.
Unit test
For pure classes (converter, calculator, policy), write tests without booting the core. It is fast and catches most bugs.
namespace Modules\Example\Tests\Unit;
use Modules\Example\Service\SlugCleaner;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(SlugCleaner::class)]
final class SlugCleanerTest extends TestCase
{
public function testStripsPathSegments(): void
{
self::assertSame('abc', (new SlugCleaner())->clean('../abc'));
}
}
Integration test
If you are going to test controller, repository or service wiring, use IntegrationTestCase. This base class:
- boots a core in the test environment (
APP_ENV=test), - resets the database schema according to the current mapping (each test method gets a clean schema),
- gives access to the running container (
$this->container(),$this->em()).
namespace Modules\Example\Tests\Integration;
use App\Tests\Support\IntegrationTestCase;
use Modules\Example\Entity\Item;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(Item::class)]
final class ItemPersistenceTest extends IntegrationTestCase
{
public function testItemIsStored(): void
{
$item = new Item();
$item->title = 'Trial';
$this->em()->persist($item);
$this->em()->flush();
$this->em()->clear();
self::assertSame('Trial', $this->em()->find(Item::class, $item->getId())?->title);
}
}
Integration tests reset the schema: they drop and rebuild the tables. Make sure the test environment's DATABASE_URL points to a separate test database, not your production or development data.
Testing the HTTP contract
For an admin controller, the things that really matter are the HTTP contract: permission, CSRF and input validation. The Forum module's upload test is a good example: does the form clearly expose the upload endpoint, does an authorized member get a stored asset address, and is it rejected if there is no CSRF, no file or the file is not an image.
To push a request onto the stack together with a session, use the base class helpers and call the controller directly.
What should be tested?
| Area | Example question |
|---|---|
| Permission | Does a user without the capability get 403? Is it shown in the menu? |
| CSRF | Is a POST without a token rejected? |
| Input limit | How are very long or empty input and malicious content handled? |
| Output escaping | Is a title containing <script> escaped in the template? |
| Is the migration idempotent | Does the SQL file not fail when run twice? |
| Upgrade | Does moving from the old schema to the new one preserve data? |
| Anonymous request | Does a public page leave a session cookie? (the cache precondition) |
| Quarantine | If your hook fails, is the page still rendered? |
Running
composer test # the whole test suite
cp-includes/vendor/bin/phpunit cp-content/modules/Example/Tests/Unit/SlugCleanerTest.php # a single file
During development run only the test you wrote or touched; the whole suite is long. Run the whole suite before deployment or in CI.
Static analysis
The project has PHPStan (with the phpstan-phpunit extension) and a composer lint command for code style. In new code use declare(strict_types=1); and write types explicitly.
Tests that depend on core behaviour
If you are writing a test that depends on Core\Update\... or installation behaviour from the core, do not hard-code version numbers: compare with CpVersion::VERSION; the core increases with every release.