# Drupal Unit Test

> >-

- **Type:** Skill
- **Install:** `agentstack add skill-trebormc-drupal-ai-agents-drupal-unit-test`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [trebormc](https://agentstack.voostack.com/s/trebormc)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** Apache-2.0
- **Upstream author:** [trebormc](https://github.com/trebormc)
- **Source:** https://github.com/trebormc/drupal-ai-agents/tree/main/.claude/skills/drupal-unit-test

## Install

```sh
agentstack add skill-trebormc-drupal-ai-agents-drupal-unit-test
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## 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

1. Read source class in `src/`
2. Check existing tests in `tests/src/Unit/`
3. Generate test class following templates below
4. 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`
5. 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)

```php
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)

```php
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

```php
/** @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

```php
// 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

```php
// 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/`):

```xml

  
    
      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

1. **Testing implementation, not behavior** — Assert on results (WHAT), not internal calls (HOW). Don't `expects($this->exactly(N))` on internal helpers.
2. **Over-mocking** — Only mock external deps (DB, HTTP, filesystem). 4+ mocks = code needs refactoring. Use real value objects.
3. **Shared state** — Never use `static` props between tests. Use `setUp()` for fresh state each test.
4. **Wrong test type** — Don't use `BrowserTestBase` for pure logic. Use `UnitTestCase` when no Drupal bootstrap needed.
5. **Time-dependent tests** — Never `sleep()`. Inject time as dependency, test with controlled timestamps.

## Test Debugging Commands

```bash
# 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

1. Base class: `Drupal\Tests\UnitTestCase` (never `PHPUnit\Framework\TestCase`)
2. PHPDoc annotations only — no PHP 8 attributes
3. `declare(strict_types=1)` first line after `<?php`
4. All dependencies mocked — no DB, filesystem, or HTTP
5. No `\Drupal::service()` in tests — DI via constructor or reflection
6. No `sleep()` — use controlled time objects
7. 2-space indentation (Drupal standard)
8. Test public API only — reflection only for injecting mock dependencies
9. One assertion concept per test method
10. 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](https://github.com/trebormc)
- **Source:** [trebormc/drupal-ai-agents](https://github.com/trebormc/drupal-ai-agents)
- **License:** Apache-2.0

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-trebormc-drupal-ai-agents-drupal-unit-test
- Seller: https://agentstack.voostack.com/s/trebormc
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
