Testing Strategy & Architecture Review
In this final lesson, we'll review testing strategies, discuss how to choose the right approach for your project, and explore best practices for building a sustainable testing culture in your team.
Choosing the Right Testing Strategy
Project Type Considerations
STARTUP/MVP:
- Focus: Fast feedback, critical paths only
- Distribution: 60% Unit, 30% Feature, 10% E2E
- Coverage Target: 60-70%
- Priority: Auth, payment, core features
ENTERPRISE APPLICATION:
- Focus: Reliability, maintainability
- Distribution: 70% Unit, 20% Integration, 10% E2E
- Coverage Target: 85%+
- Priority: All business logic, integrations
API-ONLY SERVICE:
- Focus: Contract testing, API reliability
- Distribution: 50% Unit, 40% API/Integration, 10% Contract
- Coverage Target: 80%+
- Priority: Endpoints, data validation, error handling
LEGACY CODEBASE:
- Focus: Incremental improvement
- Strategy: Test new features, add tests when fixing bugs
- Coverage Target: Gradual increase (start at 40%, aim for 70%)
- Priority: High-risk areas, frequently changed code
Test Architecture Patterns
Repository Pattern for Testability
<?php
// Without repository (harder to test)
class OrderController
{
public function store(Request $request)
{
$order = Order::create($request->all()); // Direct Eloquent usage
return response()->json($order);
}
}
// With repository (easier to test)
class OrderController
{
public function __construct(
private OrderRepository $orderRepository
) {}
public function store(StoreOrderRequest $request)
{
$order = $this->orderRepository->create($request->validated());
return new OrderResource($order);
}
}
// Test becomes simple
class OrderControllerTest extends TestCase
{
/** @test */
public function stores_order()
{
$repository = Mockery::mock(OrderRepository::class);
$repository->shouldReceive('create')
->once()
->andReturn(new Order());
$this->app->instance(OrderRepository::class, $repository);
$response = $this->postJson('/orders', ['data' => 'test']);
$response->assertCreated();
}
}
Service Layer Pattern
<?php
// Service encapsulates complex business logic
class OrderProcessingService
{
public function __construct(
private OrderRepository $orders,
private PaymentGateway $payment,
private EmailService $email,
private InventoryService $inventory
) {}
public function processOrder(Order $order, string $paymentToken): bool
{
DB::beginTransaction();
try {
// Charge payment
$charge = $this->payment->charge($order->total, $paymentToken);
// Update order
$order->markAsPaid($charge->id);
// Decrease inventory
foreach ($order->items as $item) {
$this->inventory->decrease($item->product_id, $item->quantity);
}
// Send confirmation
$this->email->sendOrderConfirmation($order);
DB::commit();
return true;
} catch (Exception $e) {
DB::rollBack();
throw new OrderProcessingException('Failed to process order', 0, $e);
}
}
}
// Easy to test with mocks
class OrderProcessingServiceTest extends TestCase
{
/** @test */
public function successfully_processes_paid_order()
{
$order = Order::factory()->make();
$payment = Mockery::mock(PaymentGateway::class);
$payment->shouldReceive('charge')->andReturn((object)['id' => 'ch_123']);
$inventory = Mockery::mock(InventoryService::class);
$inventory->shouldReceive('decrease')->once();
$email = Mockery::mock(EmailService::class);
$email->shouldReceive('sendOrderConfirmation')->once();
$service = new OrderProcessingService(
new OrderRepository(),
$payment,
$email,
$inventory
);
$result = $service->processOrder($order, 'tok_visa');
$this->assertTrue($result);
}
}
Test Data Management Strategies
Factory States for Complex Scenarios
<?php
class OrderFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'status' => 'pending',
'total' => $this->faker->randomFloat(2, 10, 1000),
];
}
public function pending(): self
{
return $this->state(['status' => 'pending']);
}
public function paid(): self
{
return $this->state([
'status' => 'paid',
'paid_at' => now(),
'payment_id' => 'ch_' . Str::random(16)
]);
}
public function completed(): self
{
return $this->paid()->state([
'status' => 'completed',
'completed_at' => now()
]);
}
public function cancelled(): self
{
return $this->state([
'status' => 'cancelled',
'cancelled_at' => now()
]);
}
public function withItems(int $count = 3): self
{
return $this->has(OrderItem::factory()->count($count), 'items');
}
}
// Usage in tests
$pendingOrder = Order::factory()->pending()->create();
$paidOrder = Order::factory()->paid()->withItems(5)->create();
$completedOrder = Order::factory()->completed()->create();
Seeders for Integration Tests
<?php
class TestDatabaseSeeder extends Seeder
{
public function run(): void
{
// Seed data for integration tests
$this->call([
TestUserSeeder::class,
TestProductSeeder::class,
TestCategorySeeder::class,
]);
}
}
class TestProductSeeder extends Seeder
{
public function run(): void
{
$categories = Category::all();
foreach ($categories as $category) {
Product::factory()
->count(10)
->for($category)
->create();
}
}
}
// Use in tests
class ProductSearchTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->seed(TestDatabaseSeeder::class);
}
/** @test */
public function can_search_products_by_category()
{
// Test with seeded data
}
}
Testing Anti-Patterns to Avoid
Common Anti-Patterns:
1. Testing Private Methods:
Private methods are implementation details. Test the public interface instead.
// ❌ Bad
$reflection = new ReflectionClass(Calculator::class);
$method = $reflection->getMethod('calculateTax');
$method->setAccessible(true);
$result = $method->invoke($calculator, 100);
// ✅ Good
$result = $calculator->calculateTotal(100); // Tests public method
2. Test Interdependencies:
Tests should be independent - order shouldn't matter.
// ❌ Bad
/** @test */
public function test1_creates_user()
{
$this->user = User::create([...]);
}
/** @test */
public function test2_updates_user() // Depends on test1
{
$this->user->update([...]);
}
// ✅ Good
/** @test */
public function can_update_user()
{
$user = User::factory()->create(); // Independent setup
$user->update([...]);
}
3. Excessive Mocking:
Don't mock what you don't own. Test real objects when possible.
// ❌ Bad: Over-mocking
$product = Mockery::mock(Product::class);
$product->shouldReceive('getName')->andReturn('Test');
$product->shouldReceive('getPrice')->andReturn(100);
$product->shouldReceive('isInStock')->andReturn(true);
// ✅ Good: Use real objects
$product = Product::factory()->create([
'name' => 'Test',
'price' => 100,
'stock' => 10
]);
4. Sleeping in Tests:
Never use sleep() - use proper waiting mechanisms.
// ❌ Bad
$this->post('/async-action');
sleep(5); // Slow and unreliable
$this->assertDatabaseHas(...);
// ✅ Good
$this->post('/async-action');
$this->waitForDatabaseCount('jobs', 1);
Building a Testing Culture
Team Practices
Fostering Test-Driven Development:
1. Make Testing Easy:
- Provide test helpers and utilities
- Document testing patterns
- Set up fast test environment
- Automate test runs in CI/CD
2. Code Review Guidelines:
- Require tests for new features
- Review test quality, not just coverage
- Check for test anti-patterns
- Verify tests actually test the right thing
3. Test Metrics to Track:
- Code coverage trends
- Test execution time
- Flaky test rate
- Bug escape rate (bugs found in production)
4. Team Training:
- Regular testing workshops
- Pair programming on tests
- Share testing wins and learnings
- Celebrate improved test coverage
Test Maintenance Strategy
When to Update Tests
REFACTOR CODE → Update tests if behavior changed
- If only implementation changed: tests should still pass
- If behavior changed: update test expectations
FIX BUG → Add test first (TDD approach)
1. Write failing test that reproduces bug
2. Fix the bug
3. Test now passes
4. Commit both fix and test
ADD FEATURE → Write tests alongside feature
- Unit tests for new classes/methods
- Integration tests for workflows
- E2E tests for user journeys
DEPRECATE FEATURE → Remove or skip tests
- Delete tests for removed code
- Skip tests for temporarily disabled features
Dealing with Flaky Tests
<?php
// Identify flaky test
/** @test */
public function sometimes_fails() // Red flag: intermittent failures
{
$response = $this->get('/api/random');
$this->assertEquals(200, $response->status()); // Flaky!
}
// Common causes and fixes:
// 1. Race conditions
// ❌ Bad
$this->post('/process');
$this->assertTrue(ProcessingJob::wasDispatched()); // May not be dispatched yet
// ✅ Good
Queue::fake();
$this->post('/process');
Queue::assertPushed(ProcessingJob::class);
// 2. Time-dependent tests
// ❌ Bad
$user->created_at = now();
$this->assertTrue($user->isNew()); // Fails if test runs slowly
// ✅ Good
Carbon::setTestNow(now());
$user->created_at = now();
$this->assertTrue($user->isNew());
// 3. Random data
// ❌ Bad
$product = Product::inRandomOrder()->first();
$this->assertEquals('Expected Name', $product->name); // Random!
// ✅ Good
$product = Product::factory()->create(['name' => 'Expected Name']);
$this->assertEquals('Expected Name', $product->name);
Advanced Testing Techniques
Contract Testing
<?php
// Verify API contracts between services
class UserApiContractTest extends TestCase
{
/** @test */
public function user_api_response_matches_contract()
{
$response = $this->getJson('/api/users/1');
$response->assertOk()
->assertJsonStructure([
'id',
'name',
'email',
'created_at',
'updated_at'
])
->assertJsonCount(5); // Exact field count
// Verify field types
$data = $response->json();
$this->assertIsInt($data['id']);
$this->assertIsString($data['name']);
$this->assertIsString($data['email']);
}
}
Mutation Testing
# Install mutation testing
composer require --dev infection/infection
# Run mutation tests
vendor/bin/infection
# Example: Mutation testing reveals weak test
Original code:
if ($price > 100) {
return $price * 0.9; // 10% discount
}
Mutant (changed > to >=):
if ($price >= 100) {
return $price * 0.9;
}
If test still passes → weak test!
Need to add test case for price === 100
Testing Checklist
Pre-Deployment Checklist:
- ✅ All tests pass locally and in CI
- ✅ Coverage meets project standards (80%+)
- ✅ No skipped or disabled tests
- ✅ No flaky tests in past 30 days
- ✅ Performance tests pass
- ✅ Security tests pass (no SQL injection, XSS, etc.)
- ✅ Accessibility tests pass (WCAG AA)
- ✅ Load tests pass for expected traffic
- ✅ Rollback plan tested
Summary: Testing Philosophy
Core Principles:
1. Tests are documentation - They explain how your code should work
2. Fast feedback is crucial - Slow tests don't get run
3. Test behavior, not implementation - Tests should survive refactoring
4. Coverage is a metric, not a goal - 100% coverage ≠ good tests
5. Write tests for confidence - Deploy without fear
6. Tests are first-class code - Maintain them like production code
7. TDD when it helps - Not dogmatic, but often beneficial
Final Challenge:
Review your current project and:
- Identify untested critical paths
- Write missing tests for high-risk areas
- Refactor one test suite to follow best practices
- Set up CI/CD if not already configured
- Document your testing strategy for your team
- Measure current coverage and set improvement goals
- Schedule regular test maintenance sessions
Course Conclusion
You've now completed a comprehensive journey through testing in PHP and Laravel:
- Fundamentals: PHPUnit, test types, assertions
- Laravel Testing: HTTP tests, database, mocking, queues
- Advanced Topics: API testing, Dusk, WebSockets, accessibility
- Best Practices: Test organization, CI/CD, coverage, documentation
- Real-World: Built complete test suite for e-commerce platform
Next Steps:
- Apply these techniques to your current projects
- Share knowledge with your team
- Contribute to open-source testing tools
- Keep learning - testing evolves with technology
- Most importantly: Write tests that give you confidence to ship
Happy testing! 🧪✅