Skip to content

Unit Testing

Overview

Unit tests verify that individual units of code (functions, methods, classes) work correctly in isolation. They are fast, focused, and don't interact with external dependencies like databases or APIs.


Principles of Unit Testing

1. Test One Thing

Each test should verify a single behavior or outcome.

php
<?php

use App\Services\PriceCalculator;

// Good - Tests one calculation
test('calculates price with tax', function () {
    $calculator = new PriceCalculator();

    $result = $calculator->calculateWithTax(100, 0.10);

    expect($result)->toBe(110.0);
});

// Good - Tests another calculation separately
test('calculates price with discount', function () {
    $calculator = new PriceCalculator();

    $result = $calculator->calculateWithDiscount(100, 0.20);

    expect($result)->toBe(80.0);
});

2. Test in Isolation

Mock external dependencies to test the unit alone.

php
<?php

use App\Services\OrderService;
use App\Services\PaymentService;
use Mockery\MockInterface;

test('processes order successfully', function () {
    // Mock the payment service dependency
    $this->mock(PaymentService::class, function (MockInterface $mock) {
        $mock->shouldReceive('charge')
            ->once()
            ->with(100.00)
            ->andReturn(true);
    });

    $orderService = app(OrderService::class);
    $result = $orderService->processOrder(100.00);

    expect($result)->toBeTrue();
});

3. Fast Execution

Unit tests should run in milliseconds, not seconds.

php
<?php

// Good - No database, no external calls
test('validates email format', function () {
    $validator = new EmailValidator();

    $result = $validator->isValid('john@example.com');

    expect($result)->toBeTrue();
});

// Avoid - Database queries slow down unit tests
test('validates email uniqueness', function () {
    // This is a feature test, not a unit test
    User::create(['email' => 'john@example.com']);
    // ...
});

Testing Services

Services contain business logic and are prime candidates for unit testing.

Basic Service Test

php
<?php

namespace Tests\Unit\Services;

use App\Services\User\UserService;
use App\Repositories\UserRepository;
use Mockery\MockInterface;

test('creates user with hashed password', function () {
    // Mock the repository
    $this->mock(UserRepository::class, function (MockInterface $mock) {
        $mock->shouldReceive('create')
            ->once()
            ->withArgs(function ($data) {
                return $data['name'] === 'John'
                    && $data['email'] === 'john@example.com'
                    && str_starts_with($data['password'], '$2y$');
            })
            ->andReturn((object) [
                'id' => 1,
                'name' => 'John',
                'email' => 'john@example.com'
            ]);
    });

    $service = app(UserService::class);

    $user = $service->createUser([
        'name' => 'John',
        'email' => 'john@example.com',
        'password' => 'password123'
    ]);

    expect($user->name)->toBe('John');
    expect($user->email)->toBe('john@example.com');
});

Testing Service with Multiple Methods

php
<?php

namespace Tests\Unit\Services;

use App\Services\DiscountService;

describe('DiscountService', function () {
    beforeEach(function () {
        $this->service = new DiscountService();
    });

    test('calculates percentage discount', function () {
        $result = $this->service->calculatePercentage(100, 10);

        expect($result)->toBe(10.0);
    });

    test('calculates fixed discount', function () {
        $result = $this->service->calculateFixed(100, 15);

        expect($result)->toBe(15.0);
    });

    test('applies discount to price', function () {
        $result = $this->service->applyDiscount(100, 20);

        expect($result)->toBe(80.0);
    });

    test('returns zero for invalid discount', function () {
        $result = $this->service->calculatePercentage(100, -10);

        expect($result)->toBe(0.0);
    });
});

Testing Models

Test model methods, scopes, and relationships logic.

Testing Model Methods

php
<?php

namespace Tests\Unit\Models;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('full name accessor returns correct format', function () {
    $user = User::factory()->make([
        'first_name' => 'John',
        'last_name' => 'Doe',
    ]);

    expect($user->full_name)->toBe('John Doe');
});

test('is admin returns true for admin role', function () {
    $user = User::factory()->make(['role' => 'admin']);

    expect($user->isAdmin())->toBeTrue();
});

test('is admin returns false for user role', function () {
    $user = User::factory()->make(['role' => 'user']);

    expect($user->isAdmin())->toBeFalse();
});

test('can activate method sets is_active to true', function () {
    $user = User::factory()->create(['is_active' => false]);

    $user->activate();

    expect($user->is_active)->toBeTrue();
    expect($user->activated_at)->not->toBeNull();
});

Testing Scopes

php
<?php

namespace Tests\Unit\Models;

use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('active scope returns only active posts', function () {
    Post::factory()->create(['is_active' => true]);
    Post::factory()->create(['is_active' => false]);

    $activePosts = Post::active()->get();

    expect($activePosts)->toHaveCount(1);
    expect($activePosts->first()->is_active)->toBeTrue();
});

test('published scope returns only published posts', function () {
    Post::factory()->create(['status' => 'published']);
    Post::factory()->create(['status' => 'draft']);

    $publishedPosts = Post::published()->get();

    expect($publishedPosts)->toHaveCount(1);
    expect($publishedPosts->first()->status)->toBe('published');
});

test('by author scope filters by author id', function () {
    $author = User::factory()->create();
    Post::factory()->count(3)->create(['author_id' => $author->id]);
    Post::factory()->count(2)->create(); // Different authors

    $authorPosts = Post::byAuthor($author)->get();

    expect($authorPosts)->toHaveCount(3);
    expect($authorPosts->every(fn ($post) => $post->author_id === $author->id))->toBeTrue();
});

Testing Helpers and Utilities

Test standalone functions and helper classes.

Testing Helper Functions

php
<?php

namespace Tests\Unit\Helpers;

test('format currency adds currency symbol', function () {
    $result = format_currency(100);

    expect($result)->toBe('$100.00');
});

test('format currency handles decimals', function () {
    $result = format_currency(99.99);

    expect($result)->toBe('$99.99');
});

test('slug helper creates valid slug', function () {
    $result = str_slug('Hello World Test');

    expect($result)->toBe('hello-world-test');
});

test('truncate text adds ellipsis', function () {
    $text = 'This is a very long text that needs to be truncated';
    $result = truncate_text($text, 20);

    expect($result)->toBe('This is a very long...');
    expect(strlen($result))->toBeLessThanOrEqual(23); // 20 + '...'
});

Testing Utility Classes

php
<?php

namespace Tests\Unit\Utils;

use App\Utils\StringHelper;

describe('StringHelper', function () {
    test('converts snake case to camel case', function () {
        $result = StringHelper::snakeToCamel('hello_world_test');

        expect($result)->toBe('helloWorldTest');
    });

    test('converts camel case to snake case', function () {
        $result = StringHelper::camelToSnake('helloWorldTest');

        expect($result)->toBe('hello_world_test');
    });

    test('generates random string of specified length', function () {
        $result = StringHelper::random(10);

        expect($result)->toBeString();
        expect(strlen($result))->toBe(10);
    });

    test('sanitizes html tags', function () {
        $dirty = '<script>alert("xss")</script>Hello';
        $result = StringHelper::sanitize($dirty);

        expect($result)->toBe('Hello');
        expect($result)->not->toContain('<script>');
    });
});

Testing with Datasets

Test the same logic with multiple inputs efficiently.

Basic Datasets

php
<?php

use App\Services\TaxCalculator;

test('calculates tax correctly', function (float $amount, float $rate, float $expected) {
    $calculator = new TaxCalculator();

    $result = $calculator->calculate($amount, $rate);

    expect($result)->toBe($expected);
})->with([
    [100, 0.10, 10.0],
    [200, 0.15, 30.0],
    [50, 0.20, 10.0],
    [1000, 0.05, 50.0],
    [0, 0.10, 0.0],
]);

Named Datasets

php
<?php

use App\Services\Validator;

test('validates email format', function (string $email, bool $isValid) {
    $validator = new Validator();

    $result = $validator->isValidEmail($email);

    expect($result)->toBe($isValid);
})->with([
    'valid email' => ['john@example.com', true],
    'invalid - no @' => ['johnexample.com', false],
    'invalid - no domain' => ['john@', false],
    'valid - subdomain' => ['john@mail.example.com', true],
    'invalid - spaces' => ['john @example.com', false],
    'valid - plus sign' => ['john+test@example.com', true],
]);

Shared Datasets

php
<?php

// tests/Datasets/Users.php
dataset('user_roles', [
    'admin' => ['admin'],
    'user' => ['user'],
    'moderator' => ['moderator'],
    'guest' => ['guest'],
]);

// tests/Unit/Services/PermissionTest.php
use App\Services\PermissionService;

test('checks admin permissions', function (string $role) {
    $service = new PermissionService();

    $result = $service->isAdmin($role);

    expect($result)->toBe($role === 'admin');
})->with('user_roles');

Mocking and Spying

Mocking Dependencies

php
<?php

use App\Services\EmailService;
use App\Services\NotificationService;
use Mockery\MockInterface;

test('sends notification via email', function () {
    // Mock the email service
    $this->mock(EmailService::class, function (MockInterface $mock) {
        $mock->shouldReceive('send')
            ->once()
            ->with('john@example.com', 'Welcome', 'Welcome to our app!')
            ->andReturn(true);
    });

    $notificationService = app(NotificationService::class);
    $result = $notificationService->sendWelcome('john@example.com');

    expect($result)->toBeTrue();
});

Partial Mocks

php
<?php

use App\Services\ReportService;

test('generates report with mocked data fetch', function () {
    $mock = $this->partialMock(ReportService::class, function (MockInterface $mock) {
        $mock->shouldReceive('fetchData')
            ->once()
            ->andReturn(['data' => 'mocked']);
    });

    $result = $mock->generateReport();

    expect($result)->toContain('mocked');
});

Spies

php
<?php

use App\Services\LoggerService;

test('calls logger when processing', function () {
    $spy = $this->spy(LoggerService::class);

    $service = new SomeService($spy);
    $service->process();

    $spy->shouldHaveReceived('log')->once();
});

Testing Exceptions

Testing Exception Throwing

php
<?php

use App\Services\UserService;
use App\Exceptions\UserNotFoundException;

test('throws exception when user not found', function () {
    $service = new UserService();

    $service->getUser(999);
})->throws(UserNotFoundException::class);

test('throws exception with specific message', function () {
    $service = new UserService();

    $service->getUser(999);
})->throws(UserNotFoundException::class, 'User with ID 999 not found');

Testing Exception Handling

php
<?php

use App\Services\PaymentService;
use App\Exceptions\PaymentFailedException;

test('handles payment failure gracefully', function () {
    $this->mock(PaymentGateway::class, function ($mock) {
        $mock->shouldReceive('charge')
            ->andThrow(new PaymentFailedException('Card declined'));
    });

    $service = app(PaymentService::class);

    expect(fn () => $service->processPayment(100))
        ->toThrow(PaymentFailedException::class);
});

Testing Private/Protected Methods

Using Reflection

php
<?php

use App\Services\EncryptionService;
use ReflectionClass;

test('private method generates correct hash', function () {
    $service = new EncryptionService();
    $reflection = new ReflectionClass($service);
    $method = $reflection->getMethod('generateHash');
    $method->setAccessible(true);

    $result = $method->invoke($service, 'test');

    expect($result)->toBeString();
    expect(strlen($result))->toBe(64); // SHA256 hash length
});

Better Approach: Test Public Interface

php
<?php

// Instead of testing private methods directly,
// test them through the public interface

test('encryption includes hash validation', function () {
    $service = new EncryptionService();

    $encrypted = $service->encrypt('secret');
    $decrypted = $service->decrypt($encrypted);

    expect($decrypted)->toBe('secret');
    // This indirectly tests the private hash generation
});

Testing Value Objects

php
<?php

namespace Tests\Unit\ValueObjects;

use App\ValueObjects\Money;

describe('Money Value Object', function () {
    test('creates money with amount and currency', function () {
        $money = new Money(100, 'USD');

        expect($money->amount())->toBe(100.0);
        expect($money->currency())->toBe('USD');
    });

    test('adds two money objects', function () {
        $money1 = new Money(100, 'USD');
        $money2 = new Money(50, 'USD');

        $result = $money1->add($money2);

        expect($result->amount())->toBe(150.0);
    });

    test('throws exception when adding different currencies', function () {
        $money1 = new Money(100, 'USD');
        $money2 = new Money(50, 'EUR');

        $money1->add($money2);
    })->throws(\InvalidArgumentException::class);

    test('formats money correctly', function () {
        $money = new Money(1234.56, 'USD');

        expect($money->format())->toBe('$1,234.56');
    });

    test('equals returns true for same amount and currency', function () {
        $money1 = new Money(100, 'USD');
        $money2 = new Money(100, 'USD');

        expect($money1->equals($money2))->toBeTrue();
    });
});

Testing Enums (PHP 8.1+)

php
<?php

namespace Tests\Unit\Enums;

use App\Enums\OrderStatus;

describe('OrderStatus Enum', function () {
    test('has correct values', function () {
        expect(OrderStatus::PENDING->value)->toBe('pending');
        expect(OrderStatus::PROCESSING->value)->toBe('processing');
        expect(OrderStatus::COMPLETED->value)->toBe('completed');
    });

    test('label method returns correct labels', function () {
        expect(OrderStatus::PENDING->label())->toBe('Pending');
        expect(OrderStatus::COMPLETED->label())->toBe('Completed');
    });

    test('is final returns true for completed and cancelled', function () {
        expect(OrderStatus::COMPLETED->isFinal())->toBeTrue();
        expect(OrderStatus::CANCELLED->isFinal())->toBeTrue();
        expect(OrderStatus::PENDING->isFinal())->toBeFalse();
    });

    test('can transition checks valid state changes', function () {
        expect(OrderStatus::PENDING->canTransitionTo(OrderStatus::PROCESSING))->toBeTrue();
        expect(OrderStatus::COMPLETED->canTransitionTo(OrderStatus::PENDING))->toBeFalse();
    });
});

Testing Collections

php
<?php

use Illuminate\Support\Collection;

test('custom collection method filters correctly', function () {
    $collection = collect([
        ['name' => 'John', 'active' => true],
        ['name' => 'Jane', 'active' => false],
        ['name' => 'Bob', 'active' => true],
    ]);

    $active = $collection->where('active', true);

    expect($active)->toHaveCount(2);
    expect($active->pluck('name')->toArray())->toBe(['John', 'Bob']);
});

test('collection transformation works', function () {
    $collection = collect([1, 2, 3, 4, 5]);

    $result = $collection->map(fn ($item) => $item * 2);

    expect($result->toArray())->toBe([2, 4, 6, 8, 10]);
});

Performance Testing

Test Execution Time

php
<?php

test('algorithm completes within time limit', function () {
    $start = microtime(true);

    $service = new SortingService();
    $service->sort(range(1, 10000));

    $duration = microtime(true) - $start;

    expect($duration)->toBeLessThan(1.0); // Should complete within 1 second
});

Memory Usage

php
<?php

test('does not exceed memory limit', function () {
    $memoryBefore = memory_get_usage();

    $service = new DataProcessor();
    $service->processLargeDataset();

    $memoryUsed = memory_get_usage() - $memoryBefore;

    expect($memoryUsed)->toBeLessThan(10 * 1024 * 1024); // Max 10MB
});

Best Practices

1. Arrange-Act-Assert Pattern

php
<?php

test('calculates total with shipping', function () {
    // Arrange
    $calculator = new PriceCalculator();
    $items = [100, 200, 300];
    $shipping = 25;

    // Act
    $total = $calculator->calculateTotal($items, $shipping);

    // Assert
    expect($total)->toBe(625.0);
});

2. Descriptive Test Names

php
<?php

// Good
test('returns empty array when no active users exist')
test('throws exception when discount exceeds 100 percent')
test('formats phone number with country code')

// Bad
test('test users')
test('discount test')
test('format')

3. Test Edge Cases

php
<?php

describe('Division Calculator', function () {
    test('divides two positive numbers', function () {
        $result = divide(10, 2);
        expect($result)->toBe(5.0);
    });

    test('divides negative by positive', function () {
        $result = divide(-10, 2);
        expect($result)->toBe(-5.0);
    });

    test('divides by decimal', function () {
        $result = divide(10, 0.5);
        expect($result)->toBe(20.0);
    });

    test('throws exception when dividing by zero', function () {
        divide(10, 0);
    })->throws(\DivisionByZeroError::class);

    test('handles very small numbers', function () {
        $result = divide(0.000001, 0.000001);
        expect($result)->toBe(1.0);
    });
});

4. Keep Tests Independent

php
<?php

// Good - Each test is independent
test('creates user', function () {
    $service = new UserService();
    $user = $service->create(['name' => 'John']);
    expect($user)->not->toBeNull();
});

test('updates user', function () {
    $service = new UserService();
    $user = $service->create(['name' => 'John']);
    $updated = $service->update($user, ['name' => 'Jane']);
    expect($updated->name)->toBe('Jane');
});

// Bad - Tests depend on each other
// Don't do this!

5. Use Factories for Test Data

php
<?php

// Good - Use factories
test('processes user data', function () {
    $user = User::factory()->make([
        'role' => 'admin'
    ]);

    $result = processUser($user);

    expect($result)->toBeTrue();
});

// Avoid - Manual creation
test('processes user data', function () {
    $user = (object) [
        'id' => 1,
        'name' => 'John',
        'email' => 'john@example.com',
        // ... many fields
    ];
});

Common Mistakes to Avoid

1. Testing Implementation Instead of Behavior

php
<?php

// Bad - Testing implementation
test('calls repository find method', function () {
    $repo = $this->mock(UserRepository::class);
    $repo->shouldReceive('find')->once();
    // ...
});

// Good - Testing behavior
test('returns user when exists', function () {
    $user = User::factory()->make();
    // ... test the actual outcome
    expect($result)->toBeInstanceOf(User::class);
});

2. Testing Framework Code

php
<?php

// Bad - Testing Laravel's functionality
test('eloquent saves to database', function () {
    $user = new User(['name' => 'John']);
    $user->save();
    expect(User::count())->toBe(1);
});

// Good - Testing your logic
test('creates user with default role', function () {
    $service = new UserService();
    $user = $service->create(['name' => 'John']);
    expect($user->role)->toBe('user');
});

3. Multiple Assertions Without Context

php
<?php

// Avoid - Unclear what failed if test breaks
test('user validation', function () {
    expect($user->name)->toBe('John');
    expect($user->email)->toContain('@');
    expect($user->role)->toBe('admin');
});

// Better - Separate tests
test('sets user name correctly', function () {
    expect($user->name)->toBe('John');
});

test('validates email format', function () {
    expect($user->email)->toContain('@');
});


Quick Reference

Common Expectations

php
expect($value)->toBe($expected)
expect($value)->toEqual($expected)  // Loose comparison
expect($value)->toBeTrue()
expect($value)->toBeFalse()
expect($value)->toBeNull()
expect($value)->toBeEmpty()
expect($array)->toHaveCount(3)
expect($string)->toContain('substring')
expect($number)->toBeGreaterThan(5)
expect($number)->toBeLessThan(10)
expect($object)->toBeInstanceOf(User::class)
expect($value)->not->toBe($other)

Running Unit Tests Only

bash
# Run unit tests only
php artisan test --testsuite=Unit

# Run specific unit test file
php artisan test tests/Unit/Services/UserServiceTest.php

# Run with filter
php artisan test --filter="UserService"

CPR - Clinical Patient Records