Best Practices & Anti-Patterns
When to Use
Consult this section when writing tests, reviewing code, or establishing testing standards for your team.
Decision: Security Best Practices
| Practice | Implementation | Why |
|---|---|---|
| Test access control | Create users with different permissions, verify access denied | Prevents unauthorized access bugs |
| Test CSRF protection | Verify form tokens required | Prevents cross-site request forgery |
| Test input validation | Submit malicious input, verify sanitization | Prevents XSS and injection attacks |
| Test permission boundaries | Test with minimal permissions, verify failures | Prevents privilege escalation |
Pattern: Security Testing
public function testAccessControl(): void {
// Test admin access
$admin = $this->drupalCreateUser(['administer my module']);
$this->drupalLogin($admin);
$this->drupalGet('admin/config/my-module');
$this->assertSession()->statusCodeEquals(200);
// Test unauthorized access
$user = $this->drupalCreateUser(['access content']);
$this->drupalLogin($user);
$this->drupalGet('admin/config/my-module');
$this->assertSession()->statusCodeEquals(403);
// Test anonymous access
$this->drupalLogout();
$this->drupalGet('admin/config/my-module');
$this->assertSession()->statusCodeEquals(403);
}
public function testXssProtection(): void {
$malicious = '<script>alert("XSS")</script>';
$this->submitForm(['title' => $malicious], 'Save');
// Verify sanitization
$this->assertSession()->responseNotContains('<script>');
$this->assertSession()->responseContains(htmlspecialchars($malicious));
}
Anti-Pattern: Not testing permission boundaries → Security vulnerabilities in production
WHY: Access control bugs are among the most serious security issues. Always test with users who should NOT have access.
Decision: Performance Best Practices
| Practice | Implementation | Why |
|---|---|---|
| Test query counts | Assert database query limits | Detects N+1 query problems |
| Test cache efficiency | Compare cold vs hot cache metrics | Verifies caching works |
| Test cache invalidation | Verify caches cleared on updates | Prevents stale data |
| Test bulk operations | Process large datasets, measure performance | Catches scalability issues |
Pattern: Performance Testing
public function testQueryEfficiency(): void {
// Track queries
\Drupal::service('database')->enableQueryLog();
// Perform operation
$service = \Drupal::service('my_module.processor');
$service->processList($items);
$queries = \Drupal::service('database')->getLog();
// Assert reasonable query count (not N+1)
$this->assertLessThanOrEqual(10, count($queries));
}
public function testCacheWarmth(): void {
// Cold cache - expect queries
$this->drupalGet('my-module/cached-page');
$cold_performance = $this->getPerformanceData();
$this->assertGreaterThan(10, $cold_performance['queries']);
// Hot cache - expect no queries
$this->drupalGet('my-module/cached-page');
$hot_performance = $this->getPerformanceData();
$this->assertLessThan(3, $hot_performance['queries']);
}
Anti-Pattern: Not measuring performance until production → Scalability issues found too late
WHY: Performance problems multiply with scale. A query that runs twice on test data might run 10,000 times on production data. Test early.
Decision: Development Standards
| Practice | Implementation | Why |
|---|---|---|
| Use data providers | #[DataProvider] for multiple test cases |
Reduces code duplication |
| Test one behavior per method | Each test verifies one thing | Easier debugging |
| Use descriptive test names | Name describes what's tested | Self-documenting tests |
| Document complex tests | PHPDoc explains test scenario | Maintainability |
| Mock external dependencies | Never call real APIs in tests | Fast, reliable tests |
Pattern: Development Standards
use PHPUnit\Framework\Attributes\DataProvider;
/**
* Tests email validation with various input formats.
*/
#[DataProvider('emailProvider')]
public function testEmailValidation($email, $expected_valid, $description): void {
$result = $this->validator->validateEmail($email);
$this->assertEquals($expected_valid, $result, $description);
}
// A data provider must be static, or PHPUnit drops the tests it feeds.
public static function emailProvider(): array {
return [
'valid email' => ['test@example.com', TRUE, 'Standard email format'],
'invalid format' => ['notanemail', FALSE, 'Missing @ symbol'],
'empty string' => ['', FALSE, 'Empty input'],
'no domain' => ['test@', FALSE, 'Missing domain'],
'international domain' => ['test@münchen.de', TRUE, 'IDN domain'],
];
}
Anti-Pattern: Testing multiple behaviors in one method → When it fails, unclear what broke
WHY: Tests are documentation. Each test should verify one specific behavior. When a test fails, you should know exactly what broke from the test name alone.
Common Anti-Patterns
Anti-Pattern 1: Using database queries in Unit tests
// WRONG - Unit tests should not access database
public function testCalculation(): void {
$node = Node::load(1); // Database access!
$result = $this->calculator->process($node->get('value'));
}
// RIGHT - Mock the data
public function testCalculation(): void {
$mock_data = ['value' => 10];
$result = $this->calculator->process($mock_data['value']);
}
WHY: Unit tests must be fast and isolated. Database access makes them slow and creates dependencies on database state.
Anti-Pattern 2: Not waiting for AJAX in JavaScript tests
// WRONG - Race condition
$page->clickLink('Load More');
$this->assertSession()->pageTextContains('New Content'); // Might not be loaded yet!
// RIGHT - Wait for AJAX
$page->clickLink('Load More');
$this->assertSession()->assertWaitOnAjaxRequest();
$this->assertSession()->pageTextContains('New Content');
WHY: JavaScript execution is asynchronous. Tests must wait for operations to complete or they become flaky.
Anti-Pattern 3: Using sleep() instead of proper waits
// WRONG - Fixed timing is unreliable
$page->clickLink('Submit');
sleep(2); // Hope 2 seconds is enough
$this->assertSession()->pageTextContains('Success');
// RIGHT - Wait for specific condition
$page->clickLink('Submit');
$this->assertSession()->waitForText('Success', 10000); // Wait up to 10 seconds
WHY: System load varies. Fixed sleep times cause flaky tests that sometimes pass, sometimes fail. Wait for specific conditions instead.
Anti-Pattern 4: Not cleaning up test data
// WRONG - Leaves test pollution
public function testFeature(): void {
$node = Node::create(['type' => 'article'])->save();
// Test runs but node remains in database
}
// RIGHT - Tests automatically clean up
// Drupal test base classes handle cleanup automatically
// Just rely on setUp/tearDown
WHY: Drupal's test base classes automatically clean up. Don't try to manage cleanup manually unless you're using DTT (Drupal Test Traits) with existing sites.