Development
Jun 9, 2025
11 min read
10 views

Testing Laravel Applications: Strategies That Actually Work in Practice

Building confidence in your Laravel applications through practical testing approaches. Learn how to structure tests, what to test (and what not to), and how to make testing a sustainable part of your development workflow.

Testing Laravel Applications: Strategies That Actually Work in Practice

Share this post

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

Critical paths must have tests: Authentication, payments, data integrity
New features need feature tests: Test the happy path at minimum
Bug fixes need regression tests: Prevent the same issue recurring
Shared testing utilities: Don't repeat setup code

Code Review Checklist

Tests are readable: Clear test names and structure
Tests are focused: One concept per test method
Tests are independent: No dependency on test order
Edge cases are covered: Not just the happy path

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

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.

Related Posts

Continue reading

Ready to Scale Your Startup?

Let's discuss how I can help you build your MVP or optimize your existing technology stack.