Best Practices & Patterns
When to Use
Writing maintainable, fast, reliable tests that verify behavior, not implementation.
Best Practices
Test Organization
- One assertion per concept: Don't mix unrelated assertions in one test method
- Descriptive test names:
testUnpublishedNodeNotVisibleToAnonymous()nottestNode() - Use #[Group] attributes: Tag tests by module, feature, speed
- Test files mirror source:
src/Service/DataProcessor.php->tests/src/Unit/Service/DataProcessorTest.php
Test Data
- Use traits for fixtures: NodeCreationTrait, UserCreationTrait -- don't reinvent
- Randomize when possible:
randomMachineName()prevents test pollution - Explicit over implicit:
createUser(['view content'])better thancreateUser()(permissions clear) - Clean up in tearDown: For Kernel tests, explicitly delete created entities if not using Browser test auto-cleanup
Performance
- Minimize module list: Only install modules you need -- every module adds overhead
- Prefer Kernel over Browser: 10x faster, use Browser only when HTTP context required
- Prefer Unit over Kernel: 100x faster, use Kernel only when container/database required
- Parallel execution: Use
--process-isolationfor independent tests (slower per test, faster overall) - Avoid full installs in Kernel: Use
enableModules(),installConfig()selectively
Security
- Test permission boundaries: Verify users WITHOUT permission are denied
- Test CSRF protection: Ensure forms reject requests without valid tokens
- Test XSS prevention: Verify user input is sanitized (e.g.,
check_markup()applied) - Test access bypass carefully: Don't use
bypass node accessas default test user -- masks bugs - Test input validation: Verify form validation rejects malicious input
Reliability
- Don't depend on test execution order: Each test should be independent
- Avoid sleeps: Use
waitForElement()in JS tests, notsleep() - Mock external services: Don't call real APIs in tests -- slow, unreliable, requires network
- Use assertSame over assertEquals: Catches type mismatches (
'1'vs1) - Expect exceptions explicitly: Use
$this->expectException()to test error handling
Maintainability
- Test behavior, not implementation: Don't assert on internal service calls, assert on outcomes
- Avoid brittle selectors: Use semantic CSS classes (
.success-message) not layout classes (.col-md-6) - Don't test core: Test YOUR code's integration with core, not core functionality
- Keep tests readable: If test setup is >20 lines, extract to helper method
- Document non-obvious test logic: Why you're testing this edge case
Patterns
Arrange-Act-Assert
public function testFeature(): void {
// Arrange: Set up test state
$user = $this->createUser(['access content']);
$node = $this->createNode(['status' => 1]);
// Act: Perform action under test
$access = $node->access('view', $user);
// Assert: Verify outcome
$this->assertTrue($access);
}
Data Providers for Multiple Cases
/**
* @dataProvider providerStatusCodes
*/
public function testRouteAccess($path, $permissions, $expected_code): void {
$user = $this->createUser($permissions);
$this->drupalLogin($user);
$this->drupalGet($path);
$this->assertSession()->statusCodeEquals($expected_code);
}
public function providerStatusCodes(): array {
return [
'admin page denied' => ['/admin', [], 403],
'admin page allowed' => ['/admin', ['access administration pages'], 200],
'content page allowed' => ['/node/1', ['access content'], 200],
];
}
Testing Exception Handling
public function testInvalidInputThrowsException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid data format');
$service = $this->container->get('my_module.service');
$service->process(['invalid' => 'data']);
}
Common Mistakes
- Testing framework code (PHPUnit, Drupal core) -- wastes time
- 100% code coverage goal -- tests getters/setters, no value
- Tests coupled to implementation -- refactoring breaks tests unnecessarily
- Not running tests before committing -- broken code reaches repo
- Ignoring test failures ("flaky test, will fix later") -- technical debt accumulates