- Published on
Automated Testing in PHP: Getting Started with PHPUnit in Laravel
Testing is often overlooked in the rush to deliver features, yet it represents one of the most critical investments a development team can make. Manual testing cannot scale—as applications grow in complexity, the cost of finding bugs through manual QA becomes prohibitive, and the risk of regressions increases exponentially. Automated testing, particularly through unit testing with PHPUnit in Laravel, transforms development from a chaotic reactive process into a controlled, confident workflow. This comprehensive guide introduces developers to PHPUnit testing within Laravel, providing the foundation needed to build resilient, well-tested applications.
Table of Contents
- Why Automated Testing Matters
- Understanding PHPUnit
- Understanding Testing Types
- Creating Your First Test
- Testing with Databases: RefreshDatabase
- Using Factories for Test Data
- Mocking and Dependency Injection
- HTTP Testing in Laravel
- Test-Driven Development (TDD)
- Best Practices for Effective Testing
- Coverage and Metrics
- Continuous Integration
- Conclusion
- References
Why Automated Testing Matters
Before diving into the mechanics of testing, it's essential to understand why testing deserves the investment. Many developers resist testing because it seems to slow down development initially. This perception misses the larger picture.
The Cost of Bugs
According to extensive research in software engineering, bugs caught during development cost roughly $1-100 to fix. The same bug found during system testing costs $100-1,000. In production, that same bug can cost $1,000-10,000 or more in direct costs plus damage to reputation and user trust. Automated testing shifts bug detection as far left as possible—catching issues when they're cheapest to fix.
Regression Prevention
As codebases grow, the fear of breaking existing functionality becomes paralyzing. Developers become hesitant to refactor, to optimize, or to clean up technical debt because they can't be confident their changes won't inadvertently break something elsewhere. Comprehensive automated tests eliminate this fear. Refactoring becomes safe—if tests pass, the code is correct.
Documentation Through Tests
Well-written tests serve as executable documentation. They demonstrate how code is supposed to be used and what behavior to expect. This proves invaluable when revisiting code months later or when onboarding new team members.
Confidence and Velocity
Paradoxically, teams with strong testing practices ship features faster. Without testing, early velocity is high but quickly declines as the codebase becomes fragile and risky to modify. Teams with testing show lower initial velocity but accelerating velocity over time—the opposite trajectory.
Understanding PHPUnit
PHPUnit is the de facto standard testing framework for PHP. It provides the infrastructure for writing, organizing, and executing unit tests. Laravel comes with PHPUnit pre-installed and pre-configured, making it trivial to get started.
Installation and Setup
In most Laravel projects, PHPUnit is already included as a development dependency. If not, install it via Composer:
composer require --dev phpunit/phpunit
Laravel's phpunit.xml configuration file defines the testing environment. By default, it uses an in-memory SQLite database for testing, which provides lightning-fast test execution without requiring database persistence.
Running Your First Test
Laravel includes example tests in the tests/ directory. Run them with:
php artisan test
This command executes all tests and displays results. The output shows test names, execution time, and pass/fail status. For more verbose output:
php artisan test --testdox
Understanding Testing Types
Different types of tests serve different purposes. The testing pyramid illustrates the ideal distribution: many unit tests, fewer integration/feature tests, and minimal end-to-end tests.
Unit Tests
Unit tests examine individual functions or methods in isolation, verifying that a specific piece of code behaves as expected under various inputs. They're fast, focused, and provide rapid feedback.
Example Unit Test:
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Services\Calculator;
class CalculatorTest extends TestCase
{
public function test_addition_works_correctly()
{
$calculator = new Calculator();
$result = $calculator->add(2, 3);
$this->assertEquals(5, $result);
}
public function test_division_by_zero_throws_exception()
{
$calculator = new Calculator();
$this->expectException(DivisionByZeroException::class);
$calculator->divide(10, 0);
}
}
Feature Tests
Feature tests (also called integration tests) verify that multiple components work together correctly. They typically test full request/response cycles through HTTP endpoints, verifying that controllers, services, models, and the database interact properly.
Example Feature Test:
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\User;
class UserCreationTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_be_created_via_api()
{
$response = $this->postJson('/api/users', [
'name' => 'John Doe',
'email' => '[email protected]',
'password' => 'password123',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('users', [
'email' => '[email protected]',
]);
}
}
End-to-End Tests
Laravel Dusk provides browser automation for end-to-end testing. These tests are slowest but provide the highest confidence that user workflows function correctly. They're typically reserved for critical paths like authentication or checkout flows.
Creating Your First Test
Laravel provides convenient Artisan commands for generating test boilerplate:
php artisan make:test UserTest # Feature test
php artisan make:test UserTest --unit # Unit test
Let's create a practical example. Suppose we have a UserService class that validates and creates users:
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Validation\ValidationException;
class UserService
{
public function createUser(array $data): User
{
$this->validateUserData($data);
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
]);
}
private function validateUserData(array $data): void
{
if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
throw new ValidationException('Invalid email address');
}
if (strlen($data['password'] ?? '') < 8) {
throw new ValidationException('Password must be at least 8 characters');
}
}
}
Now we'll test this service. Create a unit test:
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Services\UserService;
use Illuminate\Validation\ValidationException;
class UserServiceTest extends TestCase
{
public function test_creates_user_with_valid_data()
{
$service = new UserService();
$user = $service->createUser([
'name' => 'John Doe',
'email' => '[email protected]',
'password' => 'password123',
]);
$this->assertEquals('[email protected]', $user->email);
}
public function test_throws_exception_with_invalid_email()
{
$service = new UserService();
$this->expectException(ValidationException::class);
$service->createUser([
'name' => 'John Doe',
'email' => 'invalid-email',
'password' => 'password123',
]);
}
public function test_throws_exception_with_short_password()
{
$service = new UserService();
$this->expectException(ValidationException::class);
$service->createUser([
'name' => 'John Doe',
'email' => '[email protected]',
'password' => 'short',
]);
}
}
Run the tests:
php artisan test tests/Unit/UserServiceTest.php --testdox
Testing with Databases: RefreshDatabase
Feature tests often need database access. The RefreshDatabase trait resets the database before each test, ensuring test isolation—one test's data doesn't affect another.
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\User;
class UserControllerTest extends TestCase
{
use RefreshDatabase;
public function test_users_index_returns_all_users()
{
User::factory()->count(3)->create();
$response = $this->get('/users');
$response->assertStatus(200);
$response->assertViewIs('users.index');
$response->assertViewHas('users', User::all());
}
}
The RefreshDatabase trait automatically migrates and clears the test database between tests, providing a clean slate for each test.
Using Factories for Test Data
Writing valid test data by hand is tedious and error-prone. Factories generate realistic test data automatically. Create a factory:
php artisan make:factory UserFactory --model=User
Define the factory:
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class UserFactory extends Factory
{
public function definition(): array
{
return [
'name' => $this->faker->name(),
'email' => $this->faker->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => bcrypt('password'),
'remember_token' => Str::random(10),
];
}
}
Use the factory in tests:
// Create a single user
$user = User::factory()->create();
// Create 10 users
$users = User::factory()->count(10)->create();
// Create a user with specific attributes
$admin = User::factory()->create(['role' => 'admin']);
// Create but don't save to database
$user = User::factory()->make();
Mocking and Dependency Injection
Real-world applications depend on external services—APIs, email providers, payment gateways. In tests, we replace these with mocks to isolate the code being tested and avoid side effects.
Basic Mocking
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Services\EmailService;
use App\Services\MailProvider;
class EmailServiceTest extends TestCase
{
public function test_sends_welcome_email_to_new_user()
{
// Create a mock of MailProvider
$mockMailProvider = $this->createMock(MailProvider::class);
// Specify what methods should be called and with what arguments
$mockMailProvider->expects($this->once())
->method('send')
->with('[email protected]', 'Welcome!');
// Inject mock into service
$service = new EmailService($mockMailProvider);
$service->sendWelcomeEmail('[email protected]');
}
}
Mocking in Laravel with Mockery
Laravel includes Mockery, a powerful mocking library with more intuitive syntax:
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Services\PaymentService;
use App\Integrations\StripeProvider;
use Mockery;
class PaymentTest extends TestCase
{
public function test_payment_processing_calls_stripe_api()
{
// Create a mock Stripe provider
$stripeProvider = Mockery::mock(StripeProvider::class);
$stripeProvider->shouldReceive('charge')
->once()
->with(5000, 'tok_visa')
->andReturn(['id' => 'charge_123', 'status' => 'succeeded']);
// Bind mock to service container
$this->instance(StripeProvider::class, $stripeProvider);
// Service now uses mocked Stripe provider
$service = new PaymentService(app(StripeProvider::class));
$result = $service->processPayment(5000, 'tok_visa');
$this->assertEquals('charge_123', $result['id']);
}
}
HTTP Testing in Laravel
Laravel provides convenient methods for testing API endpoints without spinning up a browser:
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\Post;
class PostAPITest extends TestCase
{
public function test_get_post_endpoint()
{
$post = Post::factory()->create(['title' => 'Test Post']);
$response = $this->getJson("/api/posts/{$post->id}");
$response->assertStatus(200)
->assertJson([
'id' => $post->id,
'title' => 'Test Post',
]);
}
public function test_create_post_endpoint()
{
$response = $this->postJson('/api/posts', [
'title' => 'New Post',
'content' => 'Post content here',
]);
$response->assertStatus(201)
->assertJsonStructure(['id', 'title', 'content', 'created_at']);
$this->assertDatabaseHas('posts', ['title' => 'New Post']);
}
public function test_delete_post_endpoint()
{
$post = Post::factory()->create();
$response = $this->deleteJson("/api/posts/{$post->id}");
$response->assertStatus(200);
$this->assertDatabaseMissing('posts', ['id' => $post->id]);
}
}
Test-Driven Development (TDD)
Test-driven development inverts the typical workflow. Instead of writing code then tests, TDD writes tests first, watches them fail, then writes minimal code to pass. This cycle—Red-Green-Refactor—produces tightly-designed, well-tested code.
Red-Green-Refactor Cycle
Red: Write a test that fails because the feature doesn't exist yet.
public function test_can_mark_task_as_complete()
{
$task = Task::factory()->create(['completed' => false]);
$task->markComplete();
$this->assertTrue($task->refresh()->completed);
}
Green: Write minimal code to make the test pass.
// In Task model
public function markComplete(): void
{
$this->update(['completed' => true]);
}
Refactor: Improve the code without changing its behavior.
// Refactored version with timestamp
public function markComplete(): void
{
$this->forceFill([
'completed' => true,
'completed_at' => now(),
])->save();
}
Benefits of TDD
TDD produces higher-quality code because it forces you to think about design before implementation. It guarantees test coverage for new features. It reduces debugging time because tests catch issues immediately.
Best Practices for Effective Testing
1. Keep Tests Fast
Slow tests discourage frequent execution. Avoid database calls in unit tests. Use mocking to replace external dependencies. Run the full suite regularly to catch issues early.
2. Use Descriptive Test Names
Test names should clearly describe what behavior is being tested. testValidationWorks is unclear. testThrowsValidationExceptionForInvalidEmail is specific and helpful.
3. Test Behavior, Not Implementation
Tests should describe expected behavior, not implementation details. Avoid testing internal methods—test public interfaces. This makes tests more resilient to refactoring.
// Bad: Tests implementation detail
public function test_validate_method_called()
{
$mock = Mockery::mock(UserService::class);
$mock->shouldReceive('validate')->once();
// ...
}
// Good: Tests behavior
public function test_throws_exception_for_invalid_email()
{
$this->expectException(ValidationException::class);
UserService::create(['email' => 'invalid']);
}
4. Maintain Test Isolation
Each test must be independent. One test's failure shouldn't affect others. Use RefreshDatabase to reset state between tests. Avoid shared test fixtures.
5. Arrange-Act-Assert Pattern
Structure tests with three clear sections:
public function test_user_receives_welcome_email_after_registration()
{
// Arrange
$mailProvider = Mockery::mock(MailProvider::class);
$mailProvider->shouldReceive('send')->once();
// Act
$service = new RegistrationService($mailProvider);
$service->register(['email' => '[email protected]']);
// Assert
// Mockery verifies expectations automatically
}
Coverage and Metrics
Code coverage measures what percentage of code is executed by tests. While 100% coverage is impossible (and undesirable—not everything needs testing), coverage metrics identify untested critical paths.
Generate coverage reports:
php artisan test --coverage
This generates an HTML report showing which lines are covered and which aren't. Focus on coverage in critical business logic rather than pursuing arbitrary percentages.
Continuous Integration
Automated tests are most powerful when integrated into CI/CD pipelines. Every commit triggers tests, catching regressions before they reach production.
GitHub Actions Example:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Install dependencies
run: composer install
- name: Run tests
run: php artisan test
Conclusion
Automated testing with PHPUnit in Laravel transforms development from a chaotic, bug-prone process into a controlled, confident workflow. Starting small—writing tests for critical business logic—builds the habit and confidence to expand testing coverage over time.
The investment in testing pays dividends through reduced bugs, easier refactoring, better design, and ultimately faster delivery of reliable software. Developers who master testing become force multipliers, shipping features with confidence while their peers wrestle with manual debugging.
Begin today: write one test for your most critical feature. Run it. Watch it pass. Experience the confidence it brings. That feeling is the beginning of a transformation toward professional, sustainable development practices.
References
Beck, K. (2002). Test Driven Development: By Example. Addison-Wesley Professional.
Fowler, M. (2018). Refactoring: Improving the Design of Existing Code (2nd ed.). Addison-Wesley Professional.
GeeksforGeeks. (2024). How to Test PHP Code With PHPUnit?. Retrieved from https://www.geeksforgeeks.org/php/how-to-test-php-code-with-phpunit/
InnoRaft. (2025). Automated Testing in Laravel: Tools, Strategies & CI/CD Integration. Retrieved from https://www.innoraft.ai/blog/laravel-automated-testing-tools-strategies
Larkin, J. (2024). Unit Testing in Flight PHP with PHPUnit. Flight PHP Documentation.
Laravel. (2024). Testing: Getting Started. Laravel Documentation. Retrieved from https://laravel.com/docs/12.x/testing
Laravel. (2024). Database Testing. Laravel Documentation. Retrieved from https://laravel.com/docs/12.x/database-testing
LaravelDaily. (2025). Guide to Test-Driven Development (TDD) in Laravel: A Step-by-Step Example. Retrieved from https://laraveldaily.com/lesson/testing-laravel/tdd-approach-simple-example
PHPUnit. (2024). Getting Started with PHPUnit 10. PHPUnit Documentation. Retrieved from https://phpunit.de/getting-started/phpunit-10.html