Laravel Feature and Unit Tests: A Practical Guide with PHPUnit
Write comprehensive tests for your Laravel applications.
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.
Imagine an e-commerce application where:
Testing helps prevent these scenarios before they happen.
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
# 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)
<?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>
<?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();
}
}
<?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],
];
}
}
<?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']);
}
}
// Red: Write a failing test
// Green: Make the test pass
// Refactor: Improve the code
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);
}
// Good
public function test_user_cannot_login_with_wrong_password()
// Bad
public function test_login_2()
// Test what the code does, not how it does it
// This makes tests more resilient to refactoring
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:
The investment in testing pays off in reduced bugs, faster development, and more maintainable code. Your future self (and your team) will thank you!