| name | acc-detect-test-smells |
| description | Detects test antipatterns and code smells in PHP test suites. Identifies 15 smells (Logic in Test, Mock Overuse, Fragile Tests, Mystery Guest, etc.) with fix recommendations and refactoring patterns for testability. |
Test Smell Detection
Identifies antipatterns and code smells in PHP test suites.
15 Test Smells
1. Logic in Test
Problem: Test contains conditional logic (if/for/while).
Detection:
Grep: "if \(" --glob "tests/**/*Test.php"
Grep: "for \(" --glob "tests/**/*Test.php"
Grep: "while \(" --glob "tests/**/*Test.php"
Grep: "foreach \(" --glob "tests/**/*Test.php"
Example (Bad):
public function test_calculates_total(): void
{
$items = [10, 20, 30];
$total = 0;
foreach ($items as $item) {
$total += $item;
}
self::assertEquals($total, $this->calculator->sum($items));
}
Fix: Use data providers or inline expected values.
public function test_calculates_total(): void
{
$items = [10, 20, 30];
self::assertEquals(60, $this->calculator->sum($items));
}
2. Mock Overuse
Problem: More than 3 mocks in a single test.
Detection:
Grep: "createMock\|createStub" --glob "tests/**/*Test.php" -C 20
Example (Bad):
public function test_process_order(): void
{
$repository = $this->createMock(OrderRepository::class);
$mailer = $this->createMock(MailerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$eventDispatcher = $this->createMock(EventDispatcher::class);
$validator = $this->createMock(ValidatorInterface::class);
}
Fix: Use Fakes, refactor design, or split test.
public function test_process_order(): void
{
$repository = new InMemoryOrderRepository();
$mailer = new CollectingMailer();
$eventDispatcher = new CollectingEventDispatcher();
}
3. Test Interdependence
Problem: Tests depend on execution order or shared state.
Detection:
Grep: "static \$" --glob "tests/**/*Test.php"
Grep: "self::\$[a-z]" --glob "tests/**/*Test.php"
Grep: "@depends" --glob "tests/**/*Test.php"
Example (Bad):
private static array $createdUsers = [];
public function test_creates_user(): void
{
$user = $this->service->create('john@example.com');
self::$createdUsers[] = $user;
}
public function test_finds_created_user(): void
{
$user = $this->service->find(self::$createdUsers[0]->id);
}
Fix: Each test creates its own data.
public function test_finds_user(): void
{
$user = UserMother::default();
$this->repository->save($user);
$found = $this->service->find($user->id);
self::assertEquals($user->id, $found->id);
}
4. Fragile Test
Problem: Test breaks when implementation changes (not behavior).
Detection:
Grep: "expects\(.*exactly\|expects\(.*at\(" --glob "tests/**/*Test.php"
Grep: "->method\('_" --glob "tests/**/*Test.php"
Example (Bad):
$mock->expects($this->exactly(3))->method('process');
$mock->expects($this->at(0))->method('first');
$mock->expects($this->at(1))->method('second');
Fix: Test outcomes, not call sequences.
$this->service->processAll($items);
self::assertCount(3, $this->repository->findProcessed());
5. Mystery Guest
Problem: Test uses external files or hidden data sources.
Detection:
Grep: "file_get_contents\|fopen\|include\|require" --glob "tests/**/*Test.php"
Grep: "getenv\|_ENV\|_SERVER" --glob "tests/**/*Test.php"
Example (Bad):
public function test_imports_products(): void
{
$data = json_decode(file_get_contents('fixtures/products.json'));
}
Fix: Inline test data or use explicit builders.
public function test_imports_products(): void
{
$data = [
['name' => 'Book', 'price' => 1000],
['name' => 'Pen', 'price' => 100],
];
$this->importer->import($data);
self::assertCount(2, $this->repository->findAll());
}
6. Eager Test
Problem: Single test verifies multiple unrelated behaviors.
Detection:
Grep: "self::assert" --glob "tests/**/*Test.php" -C 5
Example (Bad):
public function test_user_operations(): void
{
$user = $this->service->create('john@example.com');
self::assertNotNull($user);
$user->setName('John');
$this->service->update($user);
self::assertEquals('John', $user->getName());
$this->service->delete($user);
self::assertNull($this->repository->find($user->id));
}
Fix: One test per behavior.
public function test_creates_user(): void { ... }
public function test_updates_user_name(): void { ... }
public function test_deletes_user(): void { ... }
7. Assertion Roulette
Problem: Multiple assertions without messages, unclear which failed.
Detection:
Grep: "self::assert" --glob "tests/**/*Test.php"
Example (Bad):
public function test_order_properties(): void
{
self::assertEquals('pending', $order->status);
self::assertEquals(100, $order->total);
self::assertEquals(3, count($order->items));
self::assertEquals('john@example.com', $order->customer->email);
self::assertEquals('2024-01-01', $order->createdAt->format('Y-m-d'));
}
Fix: Group related assertions or add messages.
public function test_order_has_correct_status(): void
{
self::assertEquals('pending', $order->status);
}
public function test_order_calculates_total(): void
{
self::assertEquals(100, $order->total);
}
8. Obscure Test
Problem: Test purpose unclear from name or structure.
Detection:
Grep: "test_it_works\|test_test\|test_foo" --glob "tests/**/*Test.php"
Grep: "function test_" --glob "tests/**/*Test.php" -A 10
Example (Bad):
public function test_it_works(): void // ❌ What works?
{
$x = $this->service->doSomething($this->data);
self::assertTrue($x);
}
Fix: Descriptive name following convention.
public function test_calculate_total_with_discount_returns_reduced_price(): void
{
}
9. Test Code Duplication
Problem: Same setup/assertion code repeated across tests.
Detection:
Example (Bad):
public function test_confirm_order(): void
{
$order = new Order(OrderId::generate(), CustomerId::generate());
$order->addItem(new Product('Book', Money::EUR(100)));
}
Fix: Extract to setUp, Builder, or Mother.
protected function setUp(): void
{
$this->order = OrderBuilder::anOrder()->withItem($this->book)->build();
}
10. Conditional Test Logic
Problem: Different assertions based on conditions.
Detection:
Grep: "if.*assert\|assert.*if" --glob "tests/**/*Test.php"
Example (Bad):
public function test_process(): void
{
$result = $this->service->process($input);
if ($result !== null) {
self::assertInstanceOf(Order::class, $result);
} else {
self::fail('Should not be null');
}
}
Fix: Explicit assertions.
public function test_process_returns_order(): void
{
$result = $this->service->process($input);
self::assertNotNull($result);
self::assertInstanceOf(Order::class, $result);
}
11. Hard-Coded Test Data
Problem: Magic values without meaning.
Detection:
Grep: "'[a-z0-9]{8,}'" --glob "tests/**/*Test.php"
Grep: "12345\|999\|100" --glob "tests/**/*Test.php"
Example (Bad):
$order = new Order('550e8400-e29b-41d4-a716-446655440000');
$money = Money::EUR(12345);
Fix: Named constants or builders.
private const KNOWN_ORDER_ID = 'order-123';
$order = OrderBuilder::anOrder()
->withId(OrderId::fromString(self::KNOWN_ORDER_ID))
->withTotal(Money::EUR(100))
->build();
12. Testing Private Methods
Problem: Tests access private/protected methods directly.
Detection:
Grep: "setAccessible\(true\)" --glob "tests/**/*Test.php"
Grep: "ReflectionMethod\|ReflectionProperty" --glob "tests/**/*Test.php"
Example (Bad):
$method = new ReflectionMethod(Order::class, 'calculateDiscount');
$method->setAccessible(true);
$result = $method->invoke($order, $amount);
Fix: Test through public API.
$order->applyDiscount($coupon);
self::assertEquals($expectedTotal, $order->total());
13. Slow Test
Problem: Unit test takes >100ms.
Detection:
phpunit --log-junit timing.xml
Grep: "sleep\|usleep\|file_\|curl_" --glob "tests/Unit/**/*Test.php"
Fix: Mock external dependencies, use in-memory implementations.
14. Mocking Final Classes
Problem: Attempting to mock final classes.
Detection:
Grep: "final class" --glob "src/**/*.php"
Grep: "createMock\(.*::" --glob "tests/**/*Test.php"
Fix: Mock interfaces, not implementations.
$mock = $this->createMock(ServiceInterface::class);
15. Mocking Value Objects
Problem: Mocking immutable value objects.
Detection:
Grep: "readonly class" --glob "src/**/*.php"
Fix: Use real value objects — they're simple and deterministic.
$email = new Email('test@example.com');
Output Format
# Test Smell Report
## Summary
| Smell | Count | Severity |
|-------|-------|----------|
| Logic in Test | 5 | High |
| Mock Overuse | 3 | High |
| Mystery Guest | 2 | Medium |
| Eager Test | 8 | Medium |
## Findings
### Logic in Test (5 occurrences)
| File | Line | Code |
|------|------|------|
| OrderTest.php | 45 | `foreach ($items as $item)` |
| UserTest.php | 23 | `if ($result !== null)` |
**Recommendation:** Extract to data providers or inline values.
### Mock Overuse (3 occurrences)
| File | Test Method | Mock Count |
|------|-------------|------------|
| PaymentTest.php | test_process | 6 |
| OrderServiceTest.php | test_place | 5 |
**Recommendation:** Use Fakes (InMemory implementations) or split tests.
## Action Items
1. **High Priority**
- Refactor `PaymentTest::test_process` — 6 mocks indicates design smell
- Remove loops from `OrderTest` — use DataProvider
2. **Medium Priority**
- Inline fixture data in `ImporterTest`
- Split `UserTest::test_user_operations` into 3 tests
## Recommended Skills
| Smell | Fix With |
|-------|----------|
| Mock Overuse | `acc-create-mock-repository` |
| Mystery Guest | `acc-create-test-builder` |
| Test Duplication | `acc-create-test-builder` |
Severity Matrix
| Smell | Severity | Impact |
|---|
| Logic in Test | High | Unreliable results |
| Mock Overuse | High | Design problem indicator |
| Test Interdependence | High | Flaky tests |
| Fragile Test | High | Maintenance burden |
| Mocking Final/VO | High | Runtime errors |
| Mystery Guest | Medium | Hard to understand |
| Eager Test | Medium | Hard to diagnose failures |
| Slow Test | Medium | Development slowdown |
| Obscure Test | Low | Documentation issue |
| Hard-Coded Data | Low | Readability |
Refactoring for Testability
See references/refactoring-patterns.md for detailed refactoring patterns:
- Extract Interface (Seam)
- Constructor Injection
- Replace Singleton with DI
- Break Temporal Coupling
- Extract Pure Function
- Replace new with Factory
- Testability Score Checklist
- Smell → Refactoring Matrix