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('@');
});Related Documentation
- Testing Overview - General testing concepts
- Feature Testing - Integration testing
- API Testing - API endpoint testing
- Best Practices - Coding standards
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"