Testing & TDD
Testing Best Practices
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_casefor 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