| name | testing-anti-patterns |
| description | Use to avoid critical testing mistakes. Five Iron Laws: Never test mock behavior, Never add test-only methods, Never mock without understanding, Always integration test, Always test error paths. |
Testing Anti-Patterns
Core Principle
Avoid the five critical testing mistakes that undermine test value and create false confidence.
When to Use This Skill
- Writing new tests
- Reviewing test code
- Debugging failing tests
- Test suite feels fragile
- Tests pass but bugs still occur
- Refactoring breaks many tests
- Unsure how to test something
The Five Iron Laws
1. NEVER TEST MOCK BEHAVIOR
If your test only verifies mock interactions, it tests nothing.
// ❌ BAD: Testing the mock, not the system
public function test_user_service_calls_repository()
{
$mockRepo = $this->createMock(UserRepository::class);
$mockRepo->expects($this->once())
->method('save')
->with($this->isInstanceOf(User::class));
$service = new UserService($mockRepo);
$service->createUser(['name' => 'John']);
// This test passes if mock is called
// But does the system actually work? Unknown!
}
// ✅ GOOD: Test actual behavior
public function test_user_service_creates_user()
{
$service = new UserService(new UserRepository());
$user = $service->createUser(['name' => 'John']);
$this->assertDatabaseHas('users', ['name' => 'John']);
$this->assertEquals('John', $user->name);
// Tests actual system behavior, not mocks
}
2. NEVER ADD TEST-ONLY METHODS
If code only exists for testing, your test is wrong.
// ❌ BAD: Test-only method
class UserService
{
private $repository;
public function __construct(UserRepository $repository)
{
$this->repository = $repository;
}
// This method ONLY exists for testing!
public function getRepositoryForTesting()
{
return $this->repository;
}
}
public function test_user_service_uses_repository()
{
$service = new UserService($repo);
$this->assertSame($repo, $service->getRepositoryForTesting());
// Testing implementation, not behavior
}
// ✅ GOOD: Test observable behavior
public function test_user_service_saves_user()
{
$service = new UserService(new UserRepository());
$user = $service->createUser(['name' => 'John']);
// Test through public API, not internals
$this->assertTrue($user->exists);
}
3. NEVER MOCK WITHOUT UNDERSTANDING
If you don't understand what you're mocking, your test is worthless.
// ❌ BAD: Mocking without understanding
public function test_payment_processing()
{
// What does StripeClient actually do? Unknown!
$mockStripe = $this->createMock(StripeClient::class);
$mockStripe->method('charge')->willReturn(true);
$service = new PaymentService($mockStripe);
$result = $service->processPayment($order);
$this->assertTrue($result);
// But does this match real Stripe behavior? Unknown!
}
// ✅ GOOD: Understand what you're mocking
public function test_payment_processing()
{
// I understand Stripe returns PaymentIntent object
// with specific structure and states
$mockStripe = $this->createMock(StripeClient::class);
$mockStripe->method('charge')
->willReturn(new PaymentIntent([
'id' => 'pi_123',
'status' => 'succeeded',
'amount' => 1000,
]));
$service = new PaymentService($mockStripe);
$result = $service->processPayment($order);
// Test that we handle PaymentIntent correctly
$this->assertEquals('pi_123', $result->transaction_id);
$this->assertEquals('succeeded', $result->status);
}
4. INTEGRATION TESTS ARE NOT AN AFTERTHOUGHT
Integration tests should be written ALONGSIDE unit tests.
// ❌ BAD: Only unit tests
// unit/UserServiceTest.php
public function test_create_user()
{
$mockRepo = $this->createMock(UserRepository::class);
$service = new UserService($mockRepo);
// ... test with mocks only
}
// Seems fine, but does the REAL system work?
// Does UserService work with REAL UserRepository?
// Does UserRepository work with REAL database?
// Unknown until production!
// ✅ GOOD: Unit AND integration tests
// unit/UserServiceTest.php
public function test_user_creation_logic()
{
$mockRepo = $this->createMock(UserRepository::class);
$mockRepo->method('save')->willReturn(true);
$service = new UserService($mockRepo);
$user = $service->createUser(['name' => 'John']);
$this->assertEquals('John', $user->name);
}
// integration/UserServiceIntegrationTest.php
public function test_user_creation_with_real_database()
{
$repo = new UserRepository();
$service = new UserService($repo);
$user = $service->createUser(['name' => 'John']);
// Tests entire stack
$this->assertDatabaseHas('users', ['name' => 'John']);
$found = User::where('name', 'John')->first();
$this->assertNotNull($found);
}
5. ALWAYS TEST ERROR PATHS
The happy path is 10% of your code. The other 90% is error handling.
// ❌ BAD: Only testing success
public function test_user_registration()
{
$response = $this->postJson('/api/register', [
'email' => 'john@example.com',
'password' => 'secret123',
]);
$response->assertStatus(200);
// What about validation errors?
// What about duplicate emails?
// What about database failures?
}
// ✅ GOOD: Test happy path AND error paths
public function test_user_registration_success()
{
$response = $this->postJson('/api/register', [
'email' => 'john@example.com',
'password' => 'secret123',
]);
$response->assertStatus(200);
}
public function test_registration_requires_email()
{
$response = $this->postJson('/api/register', [
'password' => 'secret123',
]);
$response->assertStatus(422)
->assertJsonValidationErrors('email');
}
public function test_registration_rejects_duplicate_email()
{
User::factory()->create(['email' => 'john@example.com']);
$response = $this->postJson('/api/register', [
'email' => 'john@example.com',
'password' => 'secret123',
]);
$response->assertStatus(422)
->assertJsonValidationErrors('email');
}
public function test_registration_handles_database_failure()
{
// Simulate database down
DB::shouldReceive('transaction')
->andThrow(new QueryException());
$response = $this->postJson('/api/register', [
'email' => 'john@example.com',
'password' => 'secret123',
]);
$response->assertStatus(500);
}
The Four Major Anti-Patterns
Anti-Pattern 1: Testing Mocks Instead of Behavior
What it looks like:
- Tests verify mock method calls
- Tests check mock expectations
- No assertions on actual behavior
- Tests pass but code doesn't work
Why it happens:
- Misunderstanding of mocking purpose
- Following bad examples
- Cargo cult testing
- Not understanding what to test
The fix:
1. Mock only external dependencies
2. Assert on observable behavior
3. Verify actual outcomes
4. Test through public API
Example:
```php
// ❌ TESTS MOCK
public function test_email_is_sent()
{
$mockMailer = $this->createMock(Mailer::class);
$mockMailer->expects($this->once())
->method('send');
$service = new NotificationService($mockMailer);
$service->notifyUser($user);
// Test passes if mock.send() was called
// But was email actually sent? Unknown!
}
// ✅ TESTS BEHAVIOR
public function test_email_is_sent()
{
Mail::fake();
$service = new NotificationService();
$service->notifyUser($user);
Mail::assertSent(NotificationEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
// Tests that email was actually sent
}
### Anti-Pattern 2: Test-Only Methods and Properties
What it looks like:
- Methods named "...ForTesting()"
- Public methods only used by tests
- Properties made public for testing
- Protected changed to public for tests
Why it happens:
- Can't figure out how to test properly
- Testing implementation instead of behavior
- Not using dependency injection
- Over-mocking
The fix:
- Test through public API only
- If you can't test it, redesign it
- Use proper dependency injection
- Test behavior, not implementation
Example:
// ❌ TEST-ONLY METHOD
class OrderProcessor
{
private $validator;
// This method ONLY for testing!
public function getValidatorForTesting()
{
return $this->validator;
}
}
public function test_processor_has_validator()
{
$processor = new OrderProcessor();
$this->assertInstanceOf(
Validator::class,
$processor->getValidatorForTesting()
);
}
// ✅ TEST BEHAVIOR
class OrderProcessor
{
private $validator;
public function process(Order $order): bool
{
if (!$this->validator->isValid($order)) {
return false;
}
// ... process order
return true;
}
}
public function test_processor_rejects_invalid_order()
{
$processor = new OrderProcessor();
$invalidOrder = new Order(['total' => -100]);
$result = $processor->process($invalidOrder);
$this->assertFalse($result);
// Tests validation through behavior, not internals
}
### Anti-Pattern 3: Incomplete or Incorrect Mocks
What it looks like:
- Mock returns wrong data types
- Mock behavior doesn't match real object
- Mock is missing key behaviors
- Tests pass but production fails
Why it happens:
- Don't understand the mocked dependency
- Copy/paste mock setup
- Mock created before understanding real behavior
- No integration tests to catch mismatches
The fix:
- Understand what you're mocking FIRST
- Make mock behavior match reality
- Write integration tests alongside unit tests
- Consider using real object instead
Example:
// ❌ INCORRECT MOCK
public function test_api_client_handles_response()
{
$mockClient = $this->createMock(HttpClient::class);
$mockClient->method('get')->willReturn([
'data' => 'something'
]);
// Real API returns Response object, not array!
$service = new ApiService($mockClient);
$result = $service->fetchData();
// Test passes but production will fail!
}
// ✅ CORRECT MOCK
public function test_api_client_handles_response()
{
// I understand HttpClient returns Response object
$mockResponse = new Response(200, [], json_encode([
'data' => 'something'
]));
$mockClient = $this->createMock(HttpClient::class);
$mockClient->method('get')->willReturn($mockResponse);
// Mock matches real behavior
$service = new ApiService($mockClient);
$result = $service->fetchData();
$this->assertEquals('something', $result);
}
// ✅ EVEN BETTER: Integration test too
public function test_api_service_with_real_client()
{
// Use real HTTP client with test endpoint
$client = new HttpClient();
$service = new ApiService($client);
$result = $service->fetchData();
// Tests with real HTTP client
$this->assertNotNull($result);
}
### Anti-Pattern 4: Integration Testing as Afterthought
What it looks like:
- Only unit tests with mocks
- Integration tests added "later" (never)
- No end-to-end tests
- Bugs found in production
Why it happens:
- "Unit tests are enough" mentality
- Integration tests seen as slower/harder
- Prioritizing coverage over confidence
- Not understanding test pyramid
The fix:
- Write integration tests alongside unit tests
- Test full stack for critical paths
- Use test-driven-development for both
- Balance unit and integration tests
Example:
// ❌ ONLY UNIT TESTS
class OrderServiceTest extends TestCase
{
public function test_creates_order()
{
$mockRepo = $this->createMock(OrderRepository::class);
$mockPayment = $this->createMock(PaymentService::class);
$mockInventory = $this->createMock(InventoryService::class);
$service = new OrderService($mockRepo, $mockPayment, $mockInventory);
$order = $service->createOrder($items);
// Everything mocked, does real system work? Unknown!
}
}
// ✅ UNIT AND INTEGRATION TESTS
// unit/OrderServiceTest.php
class OrderServiceTest extends TestCase
{
public function test_order_creation_logic()
{
// Mock external dependencies only
$mockPayment = $this->createMock(PaymentService::class);
$mockPayment->method('charge')->willReturn(true);
$service = new OrderService(
new OrderRepository(),
$mockPayment,
new InventoryService()
);
$order = $service->createOrder($items);
$this->assertNotNull($order->id);
$this->assertEquals('pending', $order->status);
}
}
// integration/OrderServiceIntegrationTest.php
class OrderServiceIntegrationTest extends TestCase
{
public function test_full_order_flow()
{
// No mocks, test entire stack
$service = new OrderService(
new OrderRepository(),
new PaymentService(),
new InventoryService()
);
$order = $service->createOrder($items);
// Verify database
$this->assertDatabaseHas('orders', ['id' => $order->id]);
// Verify inventory reduced
$this->assertDatabaseHas('inventory', [
'product_id' => $items[0]->id,
'quantity' => $originalQuantity - $items[0]->quantity
]);
// Verify payment recorded
$this->assertDatabaseHas('payments', [
'order_id' => $order->id,
'status' => 'completed'
]);
// Tests REAL system integration
}
}
## Recognizing Anti-Patterns in the Wild
### Red Flag 1: High Mock-to-Assertion Ratio
```php
// 🚩 RED FLAG
public function test_something()
{
// 20 lines of mock setup
$mock1->expects($this->once())->method('foo');
$mock2->expects($this->once())->method('bar');
$mock3->expects($this->once())->method('baz');
// ... 17 more lines ...
$service->doSomething();
// 1 assertion
$this->assertTrue(true);
// Ratio: 20:1 mock:assertion
// Probably testing mocks, not behavior!
}
Red Flag 2: Tests Break on Refactoring
// 🚩 RED FLAG
public function test_user_service()
{
$mockRepo = $this->createMock(UserRepository::class);
$mockRepo->expects($this->once())->method('save');
$service = new UserService($mockRepo);
$service->createUser(['name' => 'John']);
}
// Refactor: Rename save() to persist()
class UserRepository
{
public function persist(User $user) { } // Renamed!
}
// Test breaks! Even though behavior unchanged
// This indicates testing implementation, not behavior
Red Flag 3: Test Names Don't Describe Behavior
// 🚩 RED FLAGS
public function test_calls_repository() { }
public function test_uses_correct_method() { }
public function test_mocks_are_called() { }
// These describe implementation, not behavior!
// ✅ GOOD
public function test_creates_user_in_database() { }
public function test_rejects_invalid_email() { }
public function test_sends_welcome_email() { }
// These describe observable behavior
Red Flag 4: Can't Run Tests Independently
// 🚩 RED FLAG
public function test_step_1_creates_user()
{
$this->user = User::create(['name' => 'John']);
$this->assertNotNull($this->user->id);
}
public function test_step_2_updates_user()
{
// Depends on test_step_1 running first!
$this->user->update(['name' => 'Jane']);
$this->assertEquals('Jane', $this->user->name);
}
// Tests must be independent!
// This is test pollution
How to Fix Anti-Patterns
Fix 1: Convert Mock Tests to Behavior Tests
// BEFORE: Testing mocks
public function test_notification_sent()
{
$mockMailer->expects($this->once())->method('send');
$service->notifyUser($user);
}
// AFTER: Testing behavior
public function test_notification_sent()
{
Mail::fake();
$service->notifyUser($user);
Mail::assertSent(NotificationEmail::class);
}
Fix 2: Remove Test-Only Methods
// BEFORE: Test-only method
class Service
{
private $dep;
public function getDepForTesting() { return $this->dep; }
}
// AFTER: Test through behavior
class Service
{
private $dep;
// No test-only methods
public function process() {
return $this->dep->doSomething();
}
}
// Test the process() result, not internal dep
Fix 3: Add Integration Tests
// BEFORE: Only unit tests with mocks
public function test_with_mocks()
{
$mockDb = $this->createMock(Database::class);
// ... test with mocks only
}
// AFTER: Add integration test
public function test_with_real_database()
{
// Use real database
$service = new Service(new Database());
$result = $service->process();
$this->assertDatabaseHas('results', ['data' => $result]);
}
Fix 4: Test Error Paths
// BEFORE: Only happy path
public function test_success_case() { }
// AFTER: Happy path AND error paths
public function test_success_case() { }
public function test_validation_errors() { }
public function test_database_failure() { }
public function test_network_timeout() { }
public function test_invalid_input() { }
Integration with Skills
Use with:
test-driven-development- Avoid anti-patterns from startcode-review- Catch anti-patterns in reviewsystematic-debugging- Debug test issues
Prevents:
- False confidence from bad tests
- Tests that pass but code fails
- Brittle tests that break on refactoring
Checklist
When writing tests:
- Not testing mock behavior
- No test-only methods
- Understand what I'm mocking
- Have integration tests
- Test error paths
- Test describes behavior
- Test is independent
- Refactoring won't break test
When reviewing tests:
- Check mock-to-assertion ratio
- Look for test-only methods
- Verify integration tests exist
- Check error path coverage
- Ensure tests describe behavior
Authority
This skill is based on:
- "Growing Object-Oriented Software, Guided by Tests" (Freeman & Pryce)
- Martin Fowler's testing patterns
- Research on test effectiveness
- Professional testing practices
Research: Studies show proper testing reduces bugs by 40-80%, but anti-patterns reduce effectiveness to near zero.
Social Proof: All mature engineering teams avoid these anti-patterns.
Your Commitment
When writing tests:
- I will not test mock behavior
- I will not add test-only methods
- I will understand what I mock
- I will write integration tests
- I will test error paths
- I will test behavior, not implementation
- I will keep tests independent
Bottom Line: Five Iron Laws prevent bad tests. Don't test mocks. Don't add test-only methods. Understand mocks. Write integration tests. Test error paths. Follow these, and your tests will actually protect you.