Testing & TDD

Testing Best Practices

15 min Lesson 29 of 35

Testing Best Practices

Writing tests is one thing, but writing good, maintainable tests is an art. In this lesson, we'll explore industry best practices for organizing, naming, maintaining, and improving your test suites while avoiding common pitfalls.

Test Organization

A well-organized test suite makes it easy to find, understand, and maintain tests. Here are key organizational principles:

Directory Structure

tests/ ├── Unit/ │ ├── Models/ │ │ ├── UserTest.php │ │ └── OrderTest.php │ ├── Services/ │ │ ├── PaymentServiceTest.php │ │ └── EmailServiceTest.php │ └── Helpers/ │ └── StringHelperTest.php ├── Feature/ │ ├── Auth/ │ │ ├── LoginTest.php │ │ └── RegistrationTest.php │ ├── Api/ │ │ ├── ProductApiTest.php │ │ └── OrderApiTest.php │ └── Admin/ │ └── DashboardTest.php ├── Integration/ │ ├── PaymentGatewayTest.php │ └── EmailProviderTest.php └── E2E/ ├── CheckoutFlowTest.php └── UserJourneyTest.php
Organization Principles:
  • Mirror application structure: Test directory structure should mirror your application structure
  • Separate by test type: Unit, Feature, Integration, and E2E tests in separate directories
  • Group by feature: Related tests should be grouped together (e.g., all auth tests)
  • One class per test file: Each test file should test one class or feature

Test Class Organization

<?php namespace Tests\Feature\Auth; use Tests\TestCase; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; class LoginTest extends TestCase { use RefreshDatabase; // 1. Setup methods at the top protected function setUp(): void { parent::setUp(); $this->user = User::factory()->create(); } // 2. Happy path tests first /** @test */ public function user_can_login_with_valid_credentials() { // Arrange $user = User::factory()->create([ 'email' => 'test@example.com', 'password' => bcrypt('password123') ]); // Act $response = $this->post('/login', [ 'email' => 'test@example.com', 'password' => 'password123' ]); // Assert $response->assertRedirect('/dashboard'); $this->assertAuthenticatedAs($user); } // 3. Edge cases and error scenarios /** @test */ public function user_cannot_login_with_invalid_password() { $response = $this->post('/login', [ 'email' => $this->user->email, 'password' => 'wrong-password' ]); $response->assertSessionHasErrors('email'); $this->assertGuest(); } /** @test */ public function login_validation_requires_email() { $response = $this->post('/login', [ 'password' => 'password123' ]); $response->assertSessionHasErrors('email'); } // 4. Helper methods at the bottom protected function attemptLogin(array $credentials): TestResponse { return $this->post('/login', $credentials); } }

Naming Conventions

Clear, descriptive test names make it easy to understand what's being tested and why a test failed.

Test Method Naming Patterns

<?php // ❌ Bad: Unclear what's being tested /** @test */ public function test1() { } /** @test */ public function it_works() { } // ✅ Good: Descriptive pattern - [action]_[scenario]_[expected_result] /** @test */ public function user_can_update_profile_with_valid_data() { } /** @test */ public function order_creation_fails_when_product_is_out_of_stock() { } /** @test */ public function api_returns_401_when_token_is_expired() { } // ✅ Alternative pattern - [it/should]_[expected_behavior]_[when_condition] /** @test */ public function it_sends_welcome_email_when_user_registers() { } /** @test */ public function should_calculate_discount_when_coupon_is_valid() { } /** @test */ public function it_throws_exception_when_payment_fails() { }
Naming Tips:
  • Use snake_case for test methods to improve readability
  • Start with the subject being tested (user, order, api)
  • Include the condition or context (when, with, without)
  • End with the expected outcome (succeeds, fails, returns, throws)
  • Don't worry about method name length - clarity is more important

Test Description with PHPUnit Annotations

<?php /** * @test * @group authentication * @group critical */ public function user_session_expires_after_maximum_inactivity_period() { // Test implementation } /** * @test * @group api * @group slow * @dataProvider invalidEmailProvider */ public function registration_fails_with_invalid_email_format($email) { // Test implementation } // Data provider for the test above public function invalidEmailProvider(): array { return [ ['not-an-email'], ['missing@domain'], ['@nodomain.com'], ['spaces in@email.com'], ]; }

Arrange-Act-Assert (AAA) Pattern

Structure your tests using the AAA pattern for maximum clarity:

<?php /** @test */ public function cart_total_calculates_correctly_with_tax() { // Arrange - Set up test data and conditions $cart = new Cart(); $product = Product::factory()->create(['price' => 100]); $taxRate = 0.15; // 15% tax // Act - Perform the action being tested $cart->addProduct($product); $total = $cart->calculateTotal($taxRate); // Assert - Verify the expected outcome $this->assertEquals(115, $total); $this->assertCount(1, $cart->items()); }
AAA Benefits:
  • Readability: Anyone can understand what the test does
  • Maintenance: Easy to locate and modify specific parts
  • Focus: Each test should have one clear "Act" section
  • Debugging: Failed assertions clearly point to what went wrong

Test Maintenance Strategies

Don't Repeat Yourself (DRY)

<?php // ❌ Bad: Repeated setup code class OrderTest extends TestCase { /** @test */ public function can_create_order() { $user = User::factory()->create(); $product = Product::factory()->create(['price' => 100]); $order = Order::create([...]); // assertions } /** @test */ public function can_cancel_order() { $user = User::factory()->create(); $product = Product::factory()->create(['price' => 100]); $order = Order::create([...]); // assertions } } // ✅ Good: Extract common setup class OrderTest extends TestCase { use RefreshDatabase; protected User $user; protected Product $product; protected function setUp(): void { parent::setUp(); $this->user = User::factory()->create(); $this->product = Product::factory()->create(['price' => 100]); } /** @test */ public function can_create_order() { $order = $this->createOrder(); $this->assertDatabaseHas('orders', [ 'user_id' => $this->user->id, 'product_id' => $this->product->id ]); } /** @test */ public function can_cancel_order() { $order = $this->createOrder(); $order->cancel(); $this->assertEquals('cancelled', $order->fresh()->status); } protected function createOrder(array $attributes = []): Order { return Order::factory()->create(array_merge([ 'user_id' => $this->user->id, 'product_id' => $this->product->id ], $attributes)); } }

Test Fixtures and Factories

<?php // Create reusable test fixtures namespace Tests\Fixtures; class OrderFixtures { public static function validOrderData(): array { return [ 'user_id' => 1, 'total' => 150.00, 'status' => 'pending', 'items' => [ ['product_id' => 1, 'quantity' => 2, 'price' => 75.00] ] ]; } public static function expiredOrder(): Order { return Order::factory()->create([ 'created_at' => now()->subDays(31), 'status' => 'pending' ]); } public static function completedOrder(): Order { return Order::factory()->create([ 'status' => 'completed', 'completed_at' => now() ]); } } // Use in tests /** @test */ public function can_process_valid_order() { $orderData = OrderFixtures::validOrderData(); $order = Order::create($orderData); $this->assertTrue($order->process()); }

Common Testing Anti-Patterns

1. Testing Implementation Instead of Behavior

<?php // ❌ Bad: Tests implementation details /** @test */ public function user_service_calls_repository_save_method() { $repository = Mockery::mock(UserRepository::class); $repository->shouldReceive('save') ->once() ->with(Mockery::type(User::class)); $service = new UserService($repository); $service->createUser(['name' => 'John']); } // ✅ Good: Tests behavior and outcome /** @test */ public function user_service_creates_user_successfully() { $userData = ['name' => 'John', 'email' => 'john@example.com']; $user = $this->userService->createUser($userData); $this->assertInstanceOf(User::class, $user); $this->assertEquals('John', $user->name); $this->assertDatabaseHas('users', ['email' => 'john@example.com']); }

2. Testing Multiple Concerns in One Test

<?php // ❌ Bad: Tests too many things /** @test */ public function user_management() { // Creates user $user = User::create(['name' => 'John']); $this->assertDatabaseHas('users', ['name' => 'John']); // Updates user $user->update(['name' => 'Jane']); $this->assertEquals('Jane', $user->name); // Deletes user $user->delete(); $this->assertDatabaseMissing('users', ['id' => $user->id]); } // ✅ Good: Separate tests for each concern /** @test */ public function can_create_user() { $user = User::create(['name' => 'John']); $this->assertDatabaseHas('users', ['name' => 'John']); } /** @test */ public function can_update_user_name() { $user = User::factory()->create(['name' => 'John']); $user->update(['name' => 'Jane']); $this->assertEquals('Jane', $user->fresh()->name); } /** @test */ public function can_delete_user() { $user = User::factory()->create(); $user->delete(); $this->assertDatabaseMissing('users', ['id' => $user->id]); }

3. Fragile Tests (Over-Mocking)

<?php // ❌ Bad: Too much mocking makes tests fragile /** @test */ public function processes_order() { $paymentGateway = Mockery::mock(PaymentGateway::class); $emailService = Mockery::mock(EmailService::class); $inventoryService = Mockery::mock(InventoryService::class); $paymentGateway->shouldReceive('charge') ->once() ->with(100, 'token_123') ->andReturn(true); $inventoryService->shouldReceive('decrementStock') ->once() ->with(1, 2); $emailService->shouldReceive('send') ->once() ->with(Mockery::type(OrderConfirmation::class)); $processor = new OrderProcessor( $paymentGateway, $emailService, $inventoryService ); $processor->process($order); } // ✅ Good: Test behavior with minimal mocking /** @test */ public function successfully_processes_paid_order() { $order = Order::factory()->create([ 'status' => 'pending', 'total' => 100 ]); $result = $this->orderProcessor->process($order); $this->assertTrue($result); $this->assertEquals('completed', $order->fresh()->status); $this->assertDatabaseHas('payments', [ 'order_id' => $order->id, 'status' => 'paid' ]); }

4. Slow Tests

<?php // ❌ Bad: Unnecessary database interactions /** @test */ public function calculates_discount_correctly() { $user = User::factory()->create(); $product = Product::factory()->create(['price' => 100]); $order = Order::factory()->create(); $discount = $this->calculator->calculateDiscount(100, 0.10); $this->assertEquals(10, $discount); } // ✅ Good: Pure unit test without database /** @test */ public function calculates_discount_correctly() { $discount = $this->calculator->calculateDiscount(100, 0.10); $this->assertEquals(10, $discount); }

Test Documentation

<?php /** * @test * @group critical * @group payment * * Test that verifies the payment processing flow handles * declined transactions correctly by: * 1. Attempting to charge the card * 2. Catching the declined status * 3. Updating the order status * 4. Sending notification to the user * 5. Logging the failure for analytics * * This is critical for user experience and fraud prevention. */ public function payment_declined_transactions_are_handled_gracefully() { // Test implementation with clear comments }
Practice Exercise:

Refactor this poorly written test to follow best practices:

<?php public function test1() { $u = new User(); $u->name = 'Test'; $u->email = 'test@test.com'; $u->save(); $this->assertTrue($u->exists); $u->name = 'Updated'; $u->save(); $this->assertEquals('Updated', User::find($u->id)->name); }

Fix: Use proper naming, AAA pattern, separate concerns, use factories, and add meaningful assertions.

Summary

Following testing best practices ensures:

  • Maintainability: Tests are easy to understand and modify
  • Reliability: Tests accurately verify behavior, not implementation
  • Speed: Test suite runs quickly, encouraging frequent execution
  • Clarity: Failed tests clearly indicate what went wrong
  • Value: Tests provide confidence without becoming a burden
Golden Rules:
  • One logical assertion per test (testing one behavior)
  • Tests should be independent and isolated
  • Fast tests are run more often - optimize for speed
  • Test behavior, not implementation
  • Clear naming is more important than brevity
  • Write tests that you'd want to maintain in 6 months