Why Test Your Code? An Introduction to Laravel Testing

Published on December 02, 2025
Laravel Testing PHPUnit TDD Quality BestPractices

Introduction: Why Testing Matters

Testing is not just a "nice-to-have" in modern software development—it's a critical practice that separates professional developers from hobbyists. In Laravel, testing is a first-class citizen with powerful tools built right into the framework.

The Cost of NOT Testing:

  • Bugs reach production, affecting users
  • Fear of making changes to existing code
  • Longer debugging sessions
  • Poor code quality and maintainability
  • Decreased team velocity over time
  • Higher technical debt

Real-World Example:

Imagine an e-commerce application where:

  • A bug in the checkout process loses sales
  • A calculation error in pricing costs money
  • A security vulnerability exposes customer data
  • Broken features after updates frustrate users

Testing helps prevent these scenarios before they happen.

The Testing Pyramid

Understanding the different types of tests and when to use them:

// Testing Pyramid (from base to top)
1. Unit Tests (70% of tests)
   - Test individual components in isolation
   - Fast to run, numerous
   - Example: Testing a single method in a service class

2. Feature Tests (20% of tests)
   - Test complete features/use cases
   - Include interactions between components
   - Example: Testing user registration flow

3. Integration Tests (5-10% of tests)
   - Test interactions with external services
   - Example: Testing payment gateway integration

4. End-to-End Tests (0-5% of tests)
   - Test complete user journeys
   - Slow, but closest to real user experience
   - Example: Testing checkout process from cart to confirmation

Setting Up Testing in Laravel

Default Testing Structure

# Laravel's default test directory structure
tests/
├── Unit/                    # Unit tests
│   ├── ExampleTest.php
│   └── ...
├── Feature/                # Feature tests
│   ├── ExampleTest.php
│   └── ...
├── CreatesApplication.php  # Application setup trait
├── TestCase.php           # Base test case
└── Pest.php               # Pest testing framework (if using)

Environment Configuration

<?php
// phpunit.xml (Laravel's testing configuration)

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
    <testsuites>
        <testsuite name="Unit">
            <directory suffix="Test.php">./tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory suffix=".php">./app</directory>
        </include>
    </source>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
    </php>
</phpunit>

Database Testing Setup

<?php
// tests/TestCase.php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;

    protected function setUp(): void
    {
        parent::setUp();
        
        // Run migrations for testing database
        $this->artisan('migrate');
        
        // Optional: Seed test data
        // $this->seed();
    }
    
    protected function tearDown(): void
    {
        // Clean up after tests
        parent::tearDown();
    }
}

Unit Testing Fundamentals

Testing Simple Functions

<?php
// app/Services/Calculator.php

namespace App\Services;

class Calculator
{
    public function add(float $a, float $b): float
    {
        return $a + $b;
    }
    
    public function subtract(float $a, float $b): float
    {
        return $a - $b;
    }
    
    public function multiply(float $a, float $b): float
    {
        return $a * $b;
    }
    
    public function divide(float $a, float $b): float
    {
        if ($b == 0) {
            throw new \InvalidArgumentException('Division by zero');
        }
        
        return $a / $b;
    }
    
    public function calculateDiscount(float $price, float $percentage): float
    {
        if ($percentage < 0 || $percentage > 100) {
            throw new \InvalidArgumentException('Percentage must be between 0 and 100');
        }
        
        return $price * (1 - $percentage / 100);
    }
}
<?php
// tests/Unit/CalculatorTest.php

namespace Tests\Unit;

use Tests\TestCase;
use App\Services\Calculator;

class CalculatorTest extends TestCase
{
    private Calculator $calculator;
    
    protected function setUp(): void
    {
        parent::setUp();
        $this->calculator = new Calculator();
    }
    
    public function test_addition()
    {
        // Arrange
        $a = 5.5;
        $b = 2.3;
        
        // Act
        $result = $this->calculator->add($a, $b);
        
        // Assert
        $this->assertEquals(7.8, $result);
        $this->assertSame(7.8, $result); // Strict comparison
    }
    
    public function test_subtraction()
    {
        $result = $this->calculator->subtract(10, 3);
        $this->assertEquals(7, $result);
    }
    
    public function test_multiplication()
    {
        $result = $this->calculator->multiply(4, 2.5);
        $this->assertEquals(10, $result);
    }
    
    public function test_division()
    {
        $result = $this->calculator->divide(10, 2);
        $this->assertEquals(5, $result);
    }
    
    public function test_division_by_zero_throws_exception()
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Division by zero');
        
        $this->calculator->divide(10, 0);
    }
    
    public function test_discount_calculation()
    {
        $result = $this->calculator->calculateDiscount(100, 20);
        $this->assertEquals(80, $result);
    }
    
    public function test_invalid_discount_percentage_throws_exception()
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Percentage must be between 0 and 100');
        
        $this->calculator->calculateDiscount(100, 120);
    }
    
    /**
     * @dataProvider additionProvider
     */
    public function test_addition_with_data_provider($a, $b, $expected)
    {
        $result = $this->calculator->add($a, $b);
        $this->assertEquals($expected, $result);
    }
    
    public function additionProvider(): array
    {
        return [
            'positive numbers' => [2, 3, 5],
            'negative numbers' => [-2, -3, -5],
            'mixed numbers' => [2, -3, -1],
            'zero' => [0, 5, 5],
            'decimals' => [2.5, 3.5, 6.0],
        ];
    }
}

Feature Testing

Testing HTTP Endpoints

<?php
// app/Http/Controllers/Auth/RegisterController.php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;

class RegisterController extends Controller
{
    public function store(Request $request)
    {
        $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'confirmed', Rules\Password::defaults()],
            'terms' => ['required', 'accepted'],
        ]);

        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);

        event(new Registered($user));

        Auth::login($user);

        return response()->json([
            'message' => 'Registration successful',
            'user' => $user->only(['id', 'name', 'email']),
        ], 201);
    }
}
<?php
// tests/Feature/Auth/RegistrationTest.php

namespace Tests\Feature\Auth;

use Tests\TestCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;

class RegistrationTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_can_register()
    {
        // Arrange
        $userData = [
            'name' => 'John Doe',
            'email' => 'john@example.com',
            'password' => 'Password123!',
            'password_confirmation' => 'Password123!',
            'terms' => true,
        ];
        
        // Act
        $response = $this->postJson('/api/register', $userData);
        
        // Assert
        $response->assertStatus(201);
        $response->assertJson([
            'message' => 'Registration successful',
        ]);
        $response->assertJsonStructure([
            'message',
            'user' => ['id', 'name', 'email'],
        ]);
        
        // Check database
        $this->assertDatabaseHas('users', [
            'name' => 'John Doe',
            'email' => 'john@example.com',
        ]);
        
        // Check password is hashed
        $user = User::where('email', 'john@example.com')->first();
        $this->assertTrue(Hash::check('Password123!', $user->password));
        
        // Check user is logged in
        $this->assertTrue(Auth::check());
        $this->assertEquals($user->id, Auth::id());
    }
    
    public function test_registration_requires_valid_data()
    {
        // Test empty request
        $response = $this->postJson('/api/register', []);
        
        $response->assertStatus(422);
        $response->assertJsonValidationErrors([
            'name', 'email', 'password', 'terms'
        ]);
    }
    
    public function test_email_must_be_unique()
    {
        User::factory()->create(['email' => 'existing@example.com']);
        
        $response = $this->postJson('/api/register', [
            'name' => 'John Doe',
            'email' => 'existing@example.com', // Already exists
            'password' => 'Password123!',
            'password_confirmation' => 'Password123!',
            'terms' => true,
        ]);
        
        $response->assertStatus(422);
        $response->assertJsonValidationErrors(['email']);
    }
    
    public function test_password_must_be_confirmed()
    {
        $response = $this->postJson('/api/register', [
            'name' => 'John Doe',
            'email' => 'john@example.com',
            'password' => 'Password123!',
            'password_confirmation' => 'DifferentPassword!', // Mismatch
            'terms' => true,
        ]);
        
        $response->assertStatus(422);
        $response->assertJsonValidationErrors(['password']);
    }
    
    public function test_terms_must_be_accepted()
    {
        $response = $this->postJson('/api/register', [
            'name' => 'John Doe',
            'email' => 'john@example.com',
            'password' => 'Password123!',
            'password_confirmation' => 'Password123!',
            'terms' => false, // Not accepted
        ]);
        
        $response->assertStatus(422);
        $response->assertJsonValidationErrors(['terms']);
    }
}

Testing Best Practices

  1. Write Tests First (TDD)
    // Red: Write a failing test
    // Green: Make the test pass
    // Refactor: Improve the code
  2. Follow AAA Pattern
    public function test_something()
    {
        // Arrange: Set up test data
        $data = [...];
        
        // Act: Perform the action
        $result = $service->process($data);
        
        // Assert: Verify the outcome
        $this->assertEquals('expected', $result);
    }
  3. Keep Tests Independent
    • Each test should run independently
    • Don't rely on state from previous tests
    • Use setup/teardown properly
  4. Test Edge Cases
    • Empty inputs
    • Invalid inputs
    • Boundary values
    • Error conditions
    • Null values
  5. Use Descriptive Test Names
    // Good
    public function test_user_cannot_login_with_wrong_password()
    
    // Bad
    public function test_login_2()
  6. Keep Tests Fast
    • Use in-memory databases for testing
    • Mock external services
    • Avoid unnecessary setup
  7. Test Behavior, Not Implementation
    // Test what the code does, not how it does it
    // This makes tests more resilient to refactoring
  8. Maintain Test Coverage
    • Aim for 70-80% coverage
    • Focus on critical paths
    • Don't chase 100% coverage blindly

Conclusion

Testing is an essential skill for any professional Laravel developer. It's not about writing perfect tests from day one, but about building the habit and improving over time. Remember:

  • Start small - Even a few tests are better than none
  • Focus on value - Test what matters most
  • Make it a habit - Write tests as you develop
  • Learn and improve - Refactor tests as you learn better patterns

The investment in testing pays off in reduced bugs, faster development, and more maintainable code. Your future self (and your team) will thank you!