Automated testing transforms software development. Instead of hoping your code works, you know it works. Instead of fearing changes, you make them confidently. But testing can also become a burden;slow test suites, flaky tests, and tests that break whenever you refactor. Here's how to build a testing strategy that actually helps.
The Testing Pyramid Revisited
The traditional testing pyramid suggests: many unit tests, fewer integration tests, even fewer end-to-end tests. This ratio exists for good reasons;unit tests are fast, reliable, and pinpoint failures precisely.
But the pyramid isn't dogma. Some applications benefit from more integration tests. Some features only make sense to test end-to-end. The principle behind the pyramid matters more than the exact ratio: faster, more reliable tests should handle most of your coverage.
Modern testing often looks more like a trophy or honeycomb, with integration tests playing a larger role. Choose what works for your application and team.
Unit Tests
Unit tests verify small pieces of code in isolation;usually a single class or function.
What to Test
Focus unit tests on code with logic worth verifying:
- Business rules and calculations
- State machines and workflows
- Validation logic
- Data transformations
- Edge cases and boundary conditions
Don't unit test trivial code. A getter that returns a property doesn't need a test. Laravel's framework code doesn't need your tests.
Writing Effective Unit Tests
class MoneyTest extends TestCase
{
public function test_adds_money_of_same_currency(): void
{
$a = Money::fromCents(100, 'USD');
$b = Money::fromCents(50, 'USD');
$result = $a->add($b);
$this->assertEquals(150, $result->cents());
$this->assertEquals('USD', $result->currency());
}
public function test_prevents_adding_different_currencies(): void
{
$usd = Money::fromCents(100, 'USD');
$eur = Money::fromCents(100, 'EUR');
$this->expectException(CurrencyMismatchException::class);
$usd->add($eur);
}
}
Good unit tests are:
- Fast: Milliseconds, not seconds
- Isolated: Don't depend on databases, APIs, or file systems
- Deterministic: Same result every time
- Self-documenting: Test names explain what they verify
Mocking Strategies
When unit testing code with dependencies, use mocking to isolate the unit:
public function test_sends_welcome_email_on_registration(): void
{
Mail::fake();
$service = new RegistrationService();
$service->register('user@example.com', 'password');
Mail::assertSent(WelcomeEmail::class, function ($mail) {
return $mail->hasTo('user@example.com');
});
}
Don't over-mock. If you're mocking everything, your test verifies nothing. Mock external dependencies; let internal collaborators do their real work when practical.
Integration Tests
Integration tests verify that components work together correctly. They're slower than unit tests but catch issues that unit tests miss.
Database Testing
Laravel makes database testing straightforward:
use RefreshDatabase;
public function test_creates_project_with_tasks(): void
{
$client = Client::factory()->create();
$project = Project::create([
'client_id' => $client->id,
'name' => 'Test Project',
]);
$project->tasks()->create(['title' => 'First task']);
$this->assertDatabaseHas('projects', ['name' => 'Test Project']);
$this->assertEquals(1, $project->tasks()->count());
}
Use RefreshDatabase for test isolation. Each test starts with a clean database.
API Testing
Test your API endpoints to verify the full request/response cycle:
public function test_creates_project_via_api(): void
{
$user = User::factory()->create();
$client = Client::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/projects', [
'name' => 'New Project',
'client_id' => $client->id,
]);
$response->assertStatus(201)
->assertJsonPath('data.name', 'New Project');
$this->assertDatabaseHas('projects', ['name' => 'New Project']);
}
External Service Testing
When testing code that calls external APIs, you have options:
Fake the HTTP layer:
Http::fake([
'api.stripe.com/*' => Http::response(['id' => 'ch_123'], 200),
]);
$service = new PaymentService();
$charge = $service->charge(1000, 'tok_visa');
$this->assertEquals('ch_123', $charge->id);
Use service fakes:
Notification::fake();
// Trigger code that sends notifications
Notification::assertSentTo($user, OrderShipped::class);
End-to-End Tests
E2E tests verify complete user workflows through the actual UI. They're slow and sometimes flaky, but they catch issues nothing else will.
When to Use E2E Tests
Reserve E2E tests for critical paths:
- User registration and login
- Checkout and payment flows
- Core business workflows
Don't E2E test every feature. A button click that just calls an API you've already integration-tested doesn't need E2E coverage.
Keeping E2E Tests Maintainable
E2E tests break easily. Reduce fragility:
Use stable selectors:
// Fragile: breaks if HTML structure changes
$browser->click('.nav > ul > li:nth-child(3) > a');
// Stable: uses test-specific attribute
$browser->click('[data-testid="dashboard-link"]');
Create page objects:
class LoginPage
{
public function login(Browser $browser, string $email, string $password): void
{
$browser->visit('/login')
->type('email', $email)
->type('password', $password)
->press('Login');
}
}
Keep tests independent: Each test should create its own data. Tests that depend on other tests running first become maintenance nightmares.
Test-Driven Development
TDD means writing tests before writing code:
- Red: Write a failing test for new functionality
- Green: Write the minimum code to pass the test
- Refactor: Clean up the code while keeping tests passing
TDD changes how you design code. Because you write tests first, code naturally becomes testable. You think about interfaces before implementations.
TDD isn't always practical. Exploratory coding;where you're not sure what you're building;doesn't fit the TDD flow. UI work can be painful to TDD. Use TDD where it helps; don't dogmatically apply it everywhere.
Code Coverage
Coverage measures what percentage of your code runs during tests. It's useful but limited.
Coverage doesn't measure test quality. You can achieve 100% coverage with tests that assert nothing. Coverage tells you what code was executed, not whether it was verified.
Use coverage to find gaps, not as a target. If critical code shows 0% coverage, that's a problem. Chasing arbitrary coverage percentages (80%, 100%) leads to low-value tests.
Coverage varies by code type. Critical business logic deserves high coverage. Glue code and generated scaffolding might not need tests at all.
CI Integration
Tests provide the most value when they run automatically on every change.
# GitHub Actions example
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install
- run: php artisan test --parallel
Run tests in parallel to speed up CI. Most test runners support this, and it can cut test time dramatically.
Fail the build on test failures. A passing test suite should be a requirement for merging code.
Laravel Testing Tools
Pest
Pest offers expressive, minimal syntax:
it('creates a project', function () {
$user = User::factory()->create();
$response = actingAs($user)
->post('/projects', ['name' => 'Test']);
expect($response->status())->toBe(201);
});
Pest is particularly nice for readable test files. It also supports powerful features like datasets for parameterized tests.
PHPUnit
PHPUnit is the standard PHP testing framework. Laravel includes it by default:
public function test_creates_project(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/projects', ['name' => 'Test']);
$response->assertStatus(201);
}
Laravel Dusk
Dusk provides browser testing using ChromeDriver:
public function test_user_can_login(): void
{
$user = User::factory()->create();
$this->browse(function (Browser $browser) use ($user) {
$browser->visit('/login')
->type('email', $user->email)
->type('password', 'password')
->press('Login')
->assertPathIs('/dashboard');
});
}
Use Dusk for critical E2E tests. Its browser automation catches issues that HTTP testing misses;JavaScript errors, rendering problems, and complex user interactions.
Building Your Strategy
Start where value is highest:
- Test critical business logic with unit tests
- Test API endpoints with integration tests
- Test happy paths of critical user journeys with E2E
- Add tests for bugs before fixing them (prevents regression)
Grow coverage over time. Every new feature should include tests. Every bug fix should add a regression test. Coverage increases naturally without heroic test-writing sprints.
Conclusion
Tests are a tool for confidence. They let you change code knowing you haven't broken existing functionality. They document expected behavior. They catch bugs before users do.
But tests have costs: time to write, time to run, time to maintain. A testing strategy that provides confidence without becoming a burden requires judgment about where to invest testing effort.
Start with the fundamentals: unit tests for logic, integration tests for components working together, E2E tests for critical workflows. Automate test running in CI. Grow coverage over time.
The goal isn't a specific coverage number or a particular test-to-code ratio. The goal is a codebase you can change with confidence. Build the testing strategy that gives you that confidence for your specific application.