I used to think comprehensive testing meant writing tests for everything. After maintaining Laravel applications for several years and working with teams of different sizes, I've learned that effective testing is more about strategy than coverage percentages. Here are some practical approaches I've found that actually improve code confidence without slowing down development.
Good tests aren't about reaching 100% coverage - they're about building confidence that your application works as intended and will continue working as you make changes.
The Testing Pyramid for Laravel
Different types of tests serve different purposes. I've found it helpful to think about Laravel testing in three main layers, each with different goals and trade-offs.
Unit Tests
What: Test individual methods and classes
Speed: Very fast
Confidence: Low-medium
Best for: Complex business logic, calculations, transformations
Feature Tests
What: Test complete user workflows
Speed: Medium
Confidence: High
Best for: API endpoints, web flows, critical user paths
Browser Tests
What: Test in actual browser
Speed: Slow
Confidence: Very high
Best for: Critical business flows, JavaScript interactions
Feature Tests: The Sweet Spot
I spend most of my testing effort on feature tests because they provide the best balance of confidence and maintainability. They test your application the way users actually interact with it.
Testing API Endpoints
Here's how I approach testing a typical Laravel API endpoint:
class UserRegistrationTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function user_can_register_with_valid_data()
{
$userData = [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123'
];
$response = $this->postJson('/api/register', $userData);
$response->assertStatus(201)
->assertJsonStructure([
'user' => ['id', 'name', 'email', 'created_at'],
'token'
]);
$this->assertDatabaseHas('users', [
'email' => 'john@example.com',
'name' => 'John Doe'
]);
// Verify password is hashed
$user = User::where('email', 'john@example.com')->first();
$this->assertTrue(Hash::check('password123', $user->password));
}
/** @test */
public function registration_fails_with_duplicate_email()
{
User::factory()->create(['email' => 'john@example.com']);
$userData = [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123'
];
$response = $this->postJson('/api/register', $userData);
$response->assertStatus(422)
->assertJsonValidationErrors(['email']);
}
/** @test */
public function registration_sends_verification_email()
{
Mail::fake();
$userData = [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123'
];
$this->postJson('/api/register', $userData);
Mail::assertSent(VerificationEmail::class, function ($mail) {
return $mail->hasTo('john@example.com');
});
}
}
What This Test Covers
- • Happy path: Successful registration returns correct response
- • Data persistence: User is actually saved to database
- • Security: Password is properly hashed
- • Validation: Duplicate emails are rejected
- • Side effects: Verification email is sent
Testing Web Controllers
For web controllers, I focus on testing the user journey and authentication flows:
class PostControllerTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function authenticated_user_can_create_post()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/posts', [
'title' => 'My First Post',
'content' => 'This is the content of my post.',
'status' => 'published'
]);
$response->assertRedirect('/posts')
->assertSessionHas('success');
$this->assertDatabaseHas('posts', [
'title' => 'My First Post',
'user_id' => $user->id,
'status' => 'published'
]);
}
/** @test */
public function guests_cannot_create_posts()
{
$response = $this->post('/posts', [
'title' => 'My First Post',
'content' => 'This is the content.'
]);
$response->assertRedirect('/login');
$this->assertDatabaseMissing('posts', ['title' => 'My First Post']);
}
/** @test */
public function users_can_only_edit_their_own_posts()
{
$author = User::factory()->create();
$otherUser = User::factory()->create();
$post = Post::factory()->create(['user_id' => $author->id]);
$response = $this->actingAs($otherUser)
->put("/posts/{$post->id}", [
'title' => 'Updated Title'
]);
$response->assertStatus(403);
$this->assertDatabaseMissing('posts', [
'id' => $post->id,
'title' => 'Updated Title'
]);
}
}
Unit Tests for Business Logic
I write unit tests when I have complex business logic that needs to be tested in isolation. Here's an example of testing a service class:
class OrderCalculatorTest extends TestCase
{
private OrderCalculator $calculator;
protected function setUp(): void
{
parent::setUp();
$this->calculator = new OrderCalculator();
}
/** @test */
public function calculates_total_without_discount()
{
$items = [
['price' => 10.00, 'quantity' => 2],
['price' => 5.00, 'quantity' => 1]
];
$result = $this->calculator->calculateTotal($items, 0.08);
$this->assertEquals(25.00, $result['subtotal']);
$this->assertEquals(0.00, $result['discount']);
$this->assertEquals(2.00, $result['tax']);
$this->assertEquals(27.00, $result['total']);
}
/** @test */
public function applies_percentage_discount_correctly()
{
$items = [
['price' => 100.00, 'quantity' => 1]
];
$result = $this->calculator->calculateTotal($items, 0.10, 'SAVE20');
$this->assertEquals(100.00, $result['subtotal']);
$this->assertEquals(20.00, $result['discount']);
$this->assertEquals(8.00, $result['tax']); // Tax on discounted amount
$this->assertEquals(88.00, $result['total']);
}
/** @test */
public function handles_invalid_discount_codes()
{
$items = [['price' => 50.00, 'quantity' => 1]];
$result = $this->calculator->calculateTotal($items, 0.05, 'INVALID');
$this->assertEquals(0.00, $result['discount']);
$this->assertEquals(52.50, $result['total']);
}
}
Database Testing Strategies
Database testing in Laravel can be tricky. Here are patterns I've found that work well without being too slow or flaky.
Using Factories Effectively
// Good: Specific factories for different scenarios
class UserFactory extends Factory
{
public function definition()
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => Hash::make('password'),
];
}
public function unverified()
{
return $this->state([
'email_verified_at' => null,
]);
}
public function admin()
{
return $this->state([
'role' => 'admin',
]);
}
public function withPosts($count = 3)
{
return $this->has(Post::factory()->count($count));
}
}
// Usage in tests
public function test_admin_can_view_all_users()
{
$admin = User::factory()->admin()->create();
$users = User::factory()->count(5)->create();
$response = $this->actingAs($admin)->get('/admin/users');
$response->assertStatus(200);
$response->assertSee($users->first()->name);
}
Testing Relationships
/** @test */
public function user_posts_are_returned_in_chronological_order()
{
$user = User::factory()->create();
$oldPost = Post::factory()->create([
'user_id' => $user->id,
'created_at' => now()->subDays(2)
]);
$newPost = Post::factory()->create([
'user_id' => $user->id,
'created_at' => now()->subDays(1)
]);
$posts = $user->posts()->latest()->get();
$this->assertEquals($newPost->id, $posts->first()->id);
$this->assertEquals($oldPost->id, $posts->last()->id);
}
/** @test */
public function deleting_user_soft_deletes_their_posts()
{
$user = User::factory()->withPosts(3)->create();
$postIds = $user->posts()->pluck('id');
$user->delete();
$this->assertSoftDeleted('users', ['id' => $user->id]);
foreach ($postIds as $postId) {
$this->assertSoftDeleted('posts', ['id' => $postId]);
}
}
Testing External Dependencies
Most Laravel applications integrate with external services. Here's how I handle testing these interactions:
HTTP Client Testing
class PaymentServiceTest extends TestCase
{
/** @test */
public function processes_successful_payment()
{
Http::fake([
'payments.example.com/*' => Http::response([
'transaction_id' => 'tx_123',
'status' => 'completed',
'amount' => 2500
], 200)
]);
$service = new PaymentService();
$result = $service->charge(2500, 'tok_visa');
$this->assertTrue($result->isSuccessful());
$this->assertEquals('tx_123', $result->getTransactionId());
Http::assertSent(function (Request $request) {
return $request->url() === 'https://payments.example.com/charges' &&
$request['amount'] === 2500 &&
$request['token'] === 'tok_visa';
});
}
/** @test */
public function handles_payment_failure_gracefully()
{
Http::fake([
'payments.example.com/*' => Http::response([
'error' => 'card_declined',
'message' => 'Your card was declined'
], 402)
]);
$service = new PaymentService();
$result = $service->charge(2500, 'tok_declined');
$this->assertFalse($result->isSuccessful());
$this->assertEquals('card_declined', $result->getErrorCode());
}
}
Queue and Job Testing
class ProcessOrderTest extends TestCase
{
/** @test */
public function order_processing_dispatches_notification_job()
{
Queue::fake();
$order = Order::factory()->create();
$this->post("/orders/{$order->id}/process");
Queue::assertPushed(SendOrderConfirmation::class, function ($job) use ($order) {
return $job->order->id === $order->id;
});
}
/** @test */
public function job_sends_email_to_customer()
{
Mail::fake();
$order = Order::factory()->create();
$job = new SendOrderConfirmation($order);
$job->handle();
Mail::assertSent(OrderConfirmationMail::class, function ($mail) use ($order) {
return $mail->hasTo($order->customer_email) &&
$mail->order->id === $order->id;
});
}
}
What Not to Test
Knowing what not to test is just as important as knowing what to test. Here are things I usually skip:
Things I Don't Usually Test
- • Laravel Framework Code: Eloquent relationships, validation rules that are just Laravel features
- • Simple Getters/Setters: Accessors and mutators without complex logic
- • Configuration: Routes, service providers (unless they have custom logic)
- • Third-party Packages: Trust that popular packages work as documented
- • Trivial Methods: Simple data transformations without business logic
Test Organization and Maintenance
Test Structure That Scales
tests/
├── Feature/
│ ├── Auth/
│ │ ├── LoginTest.php
│ │ ├── RegistrationTest.php
│ │ └── PasswordResetTest.php
│ ├── Posts/
│ │ ├── CreatePostTest.php
│ │ ├── UpdatePostTest.php
│ │ └── DeletePostTest.php
│ └── Api/
│ ├── V1/
│ │ ├── PostsApiTest.php
│ │ └── UsersApiTest.php
│ └── V2/
├── Unit/
│ ├── Services/
│ │ ├── PaymentServiceTest.php
│ │ └── OrderCalculatorTest.php
│ ├── Models/
│ │ ├── UserTest.php
│ │ └── PostTest.php
│ └── Helpers/
│ └── DateHelperTest.php
└── Browser/
├── CheckoutFlowTest.php
└── AdminPanelTest.php
Shared Test Utilities
// tests/Concerns/InteractsWithAuth.php
trait InteractsWithAuth
{
protected function signIn($user = null)
{
$user = $user ?: User::factory()->create();
$this->actingAs($user);
return $user;
}
protected function signInAdmin()
{
return $this->signIn(User::factory()->admin()->create());
}
}
// tests/Concerns/CreatesTestData.php
trait CreatesTestData
{
protected function createPostWithComments($commentCount = 3)
{
return Post::factory()
->has(Comment::factory()->count($commentCount))
->create();
}
protected function createOrderWithItems($itemCount = 2)
{
return Order::factory()
->has(OrderItem::factory()->count($itemCount))
->create();
}
}
Making Tests Part of Your Workflow
Test-Driven Development (When It Makes Sense)
I don't write tests for everything up front, but TDD works well for complex business logic:
Good TDD Candidates
- • Complex calculations or algorithms
- • Business rules with multiple conditions
- • Data transformation logic
- • API integrations with specific requirements
- • Bug fixes (write test that reproduces the bug first)
CI/CD Integration
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: laravel_test
options: --health-cmd="mysqladmin ping" --health-interval=10s
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.3
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: xdebug
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Copy .env
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
- name: Generate key
run: php artisan key:generate
- name: Directory Permissions
run: chmod -R 777 storage bootstrap/cache
- name: Run Feature and Unit tests
run: php artisan test --parallel
- name: Run Browser tests
run: php artisan dusk
Team Testing Standards
When working with a team, consistency in testing approach is crucial. Here's what I've found works:
Testing Agreements
Code Review Checklist
Common Testing Pitfalls
Testing Implementation Details
Testing how something works instead of what it does. Focus on behavior and outcomes, not internal method calls.
Brittle Tests
Tests that break when you make unrelated changes. Often caused by over-mocking or testing too many things at once.
Slow Test Suites
Tests that take too long to run discourage running them frequently. Use database transactions, avoid unnecessary I/O.
Testing for Coverage Numbers
Focusing on coverage percentage instead of meaningful tests. High coverage with poor tests gives false confidence.
Building Testing Habits
The best testing strategy is one that you and your team will actually follow consistently. Start with testing the most critical parts of your application and gradually expand your coverage as you build confidence and momentum.
I've found that focusing on feature tests first gives you the biggest bang for your buck. They catch real bugs, give you confidence to refactor, and help you think about your application from the user's perspective.
🎯 Getting Started
Pick one critical user workflow in your application and write comprehensive feature tests for it. Then gradually expand to cover other important flows.
Remember: tests are code too. They need to be maintained, so write them clearly and keep them focused. Good tests make your codebase more maintainable, not less.
Struggling with testing strategy for your Laravel application or want to share testing patterns that work for your team? I'd love to discuss what approaches have worked best in your experience. Testing is one of those areas where learning from others' successes and failures is incredibly valuable.

Chris Page
Fractional CTO and Software Engineer with 25+ years of experience. I help startups scale from 0 to 7 figures using AI-assisted development and proven frameworks.