Testing & TDD

Testing Strategy & Architecture Review

15 min Lesson 35 of 35

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:

  1. Identify untested critical paths
  2. Write missing tests for high-risk areas
  3. Refactor one test suite to follow best practices
  4. Set up CI/CD if not already configured
  5. Document your testing strategy for your team
  6. Measure current coverage and set improvement goals
  7. 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! 🧪✅

Tutorial Complete!

Congratulations! You have completed all lessons in this tutorial.