Install
$ agentstack add skill-trebormc-drupal-ai-agents-drupal-unit-test ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Environment
All commands via ssh web. Use $DDEV_DOCROOT for paths. Detect test gaps: ssh web drush audit:run phpunit --filter="module:MODULE" --format=json
Drupal 10+11 Compatibility (CRITICAL)
Use PHPDoc annotations only — NEVER PHP 8 attributes. Drupal 10 = PHPUnit 9.x (no attribute support).
| Use THIS | NOT this | |---|---| | @coversDefaultClass \My\Class | #[CoversClass(MyClass::class)] | | @covers ::methodName | #[Covers('methodName')] | | @group mymodule | #[Group('mymodule')] | | @dataProvider providerName | #[DataProvider('providerName')] |
Workflow
- Read source class in
src/ - Check existing tests in
tests/src/Unit/ - Generate test class following templates below
- Run test (Form ROOT — requires project phpunit.xml; if missing, use Form CORE from the drupal-testing skill):
ssh web ./vendor/bin/phpunit $DDEV_DOCROOT/modules/custom/MODULE/tests/src/Unit/Service/MyServiceTest.php
- Run PHPCS — always try Audit module first:
``bash # Preferred: Audit module (check if installed first) ssh web drush audit:run phpcs --filter="module:MODULE" --format=json # Fallback only if Audit module not installed: ssh web ./vendor/bin/phpcs --standard=Drupal,DrupalPractice $DDEV_DOCROOT/modules/custom/MODULE/tests/src/Unit/ ``
File Structure & Namespace
$DDEV_DOCROOT/modules/custom/MODULE/tests/src/Unit/
├── Service/MyServiceTest.php # Drupal\Tests\MODULE\Unit\Service
├── Plugin/Block/MyBlockTest.php # Drupal\Tests\MODULE\Unit\Plugin\Block
├── Form/MyFormTest.php # Drupal\Tests\MODULE\Unit\Form
└── Controller/MyControllerTest.php # Drupal\Tests\MODULE\Unit\Controller
Template: Service Test (Complete Example)
configFactory = $this->createMock(ConfigFactoryInterface::class);
$this->service = new MyService($this->configFactory);
}
/**
* @covers ::process
*/
public function testProcessValidInput(): void {
$config = $this->createMock(ImmutableConfig::class);
$config->method('get')->willReturn('value');
$this->configFactory->method('get')->willReturn($config);
$result = $this->service->process('test');
$this->assertIsArray($result);
$this->assertNotEmpty($result);
}
/**
* @covers ::process
* @dataProvider processDataProvider
*/
public function testProcessScenarios(string $input, bool $expectEmpty): void {
$config = $this->createMock(ImmutableConfig::class);
$config->method('get')->willReturn('default');
$this->configFactory->method('get')->willReturn($config);
$result = $this->service->process($input);
$this->assertEquals($expectEmpty, empty($result));
}
/**
* @return array
* Test scenarios.
*/
public static function processDataProvider(): array {
return [
'valid input' => ['valid', FALSE],
'another case' => ['other', FALSE],
];
}
}
Template: Plugin setUp (Reflection for DI)
protected function setUp(): void {
parent::setUp();
$this->block = new MyBlock([], 'my_block', ['id' => 'my_block', 'provider' => 'mymodule']);
// Inject mock via reflection (ONLY for DI, never for testing logic).
$this->entityTypeManager = $this->createMock(EntityTypeManagerInterface::class);
$ref = new \ReflectionClass($this->block);
$prop = $ref->getProperty('entityTypeManager');
$prop->setAccessible(TRUE);
$prop->setValue($this->block, $this->entityTypeManager);
}
Template: Form Test Methods
/** @covers ::buildForm */
public function testBuildFormStructure(): void {
$result = $this->form->buildForm([], new FormState());
$this->assertIsArray($result);
$this->assertArrayHasKey('actions', $result);
}
/** @covers ::validateForm */
public function testValidateFormInvalidData(): void {
$form = [];
$form_state = new FormState();
$form_state->setValues(['name' => '']);
$this->form->validateForm($form, $form_state);
$this->assertTrue($form_state->hasAnyErrors());
}
Common Mock Patterns
// Config Factory
$config = $this->createMock(ImmutableConfig::class);
$config->method('get')->willReturnCallback(fn(string $key) => $values[$key] ?? NULL);
$this->configFactory->method('get')->willReturn($config);
// Entity Query (always include accessCheck)
$query = $this->createMock(QueryInterface::class);
$query->method('accessCheck')->willReturnSelf();
$query->method('condition')->willReturnSelf();
$query->method('execute')->willReturn(['id1', 'id2']);
$storage = $this->createMock(EntityStorageInterface::class);
$storage->method('getQuery')->willReturn($query);
$this->entityTypeManager->method('getStorage')->willReturn($storage);
// Logger with assertion
$this->logger = $this->createMock(LoggerInterface::class);
$this->logger->expects($this->once())->method('error')
->with($this->stringContains('failed'));
// String translation — already available from UnitTestCase:
// $this->getStringTranslationStub()
Advanced Mock: EntityTypeManager Chain
// Full EntityTypeManager → Storage → Entity mock chain
$entity = $this->createMock(EntityInterface::class);
$entity->method('id')->willReturn('1');
$entity->method('label')->willReturn('Test');
$storage = $this->createMock(EntityStorageInterface::class);
$storage->expects($this->once())
->method('load')
->with(1)
->willReturn($entity);
$storage->method('loadMultiple')
->willReturn(['1' => $entity]);
$entityTypeManager = $this->createMock(EntityTypeManagerInterface::class);
$entityTypeManager->expects($this->once())
->method('getStorage')
->with('node')
->willReturn($storage);
$service = new MyService($entityTypeManager);
$result = $service->loadEntity(1);
$this->assertNotNull($result);
PHPUnit Configuration (phpunit.xml)
Place this file at the PROJECT ROOT. It is what enables the simple "Form ROOT" commands (no -c flag, no env vars).
WARNING — BEFORE copying this template: it uses web/ as the docroot. Check the real docroot with grep "^docroot:" .ddev/config.yaml and replace EVERY web/ below if it differs (e.g. docroot/):
web/modules/custom/*/tests/src/Unit
web/modules/custom/*/tests/src/Kernel
web/modules/custom/*/tests/src/Functional
For PHPCS, PHPStan, Rector, and GrumPHP configuration, see the quality-tools-setup rule.
Common Testing Pitfalls
- Testing implementation, not behavior — Assert on results (WHAT), not internal calls (HOW). Don't
expects($this->exactly(N))on internal helpers. - Over-mocking — Only mock external deps (DB, HTTP, filesystem). 4+ mocks = code needs refactoring. Use real value objects.
- Shared state — Never use
staticprops between tests. UsesetUp()for fresh state each test. - Wrong test type — Don't use
BrowserTestBasefor pure logic. UseUnitTestCasewhen no Drupal bootstrap needed. - Time-dependent tests — Never
sleep(). Inject time as dependency, test with controlled timestamps.
Test Debugging Commands
# Run single test with verbose output
ssh web ./vendor/bin/phpunit --filter testMethodName path/to/Test.php -v
# Run tests with debug info
ssh web ./vendor/bin/phpunit $DDEV_DOCROOT/modules/custom/mymodule --debug
# List all tests without running
ssh web ./vendor/bin/phpunit --list-tests $DDEV_DOCROOT/modules/custom/mymodule
# Run with testdox output
ssh web ./vendor/bin/phpunit --testdox $DDEV_DOCROOT/modules/custom/mymodule
Troubleshooting
| Error | Cause | Fix | |-------|-------|-----| | Class not found | Stale autoloader or wrong namespace | ssh web composer dump-autoload; verify namespace matches the directory path | | Cannot mock final class | createMock() on a final class | Mock the interface instead; if there is none, test through a wrapper or use a Kernel test | | setUp needs more than 4-5 mocks | Class is too coupled for unit testing | Switch to a Kernel test (drupal-kernel-test skill) | | Data provider error on Drupal 11 | Provider method is not static | Make all @dataProvider methods public static function | | SIMPLETEST_DB / DB connection errors | Unit test accidentally touching Drupal | Pure unit tests need no DB — remove the dependency or switch to Kernel test |
Related Skills
- drupal-testing — Full test lifecycle: kernel tests, functional tests, test execution, coverage (use for non-unit test types)
- quality-checks — Code quality validation after writing tests
- drupal-debugging — Test debugging commands and troubleshooting
Rules
- Base class:
Drupal\Tests\UnitTestCase(neverPHPUnit\Framework\TestCase) - PHPDoc annotations only — no PHP 8 attributes
declare(strict_types=1)first line after<?php- All dependencies mocked — no DB, filesystem, or HTTP
- No
\Drupal::service()in tests — DI via constructor or reflection - No
sleep()— use controlled time objects - 2-space indentation (Drupal standard)
- Test public API only — reflection only for injecting mock dependencies
- One assertion concept per test method
- Descriptive names:
testProcessReturnsEmptyArrayWhenNoData
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: trebormc
- Source: trebormc/drupal-ai-agents
- License: Apache-2.0
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.