Introduction: Why testing in PHP is not an option, but a standard
In modern PHP development, testing has long ceased to be a "nice to have" and has become a mandatory stage of production projects. Without code coverage by tests, you risk releasing an error into production that could cost the business money and reputation. In this article, we will break down three levels of testing: unit, integration, and e2e (end-to-end). You will learn how they differ, which tools to use (PHPUnit, Pest, Laravel Dusk, Codeception), and how to build a proper testing strategy for your PHP project.
1. Unit tests: testing isolated units of code
Unit tests check the operation of individual classes, functions, or methods in isolation from external dependencies (DB, API, file system). This is the fastest and cheapest type of test. The main tool in PHP is PHPUnit (the de facto standard) and its "trendy" alternative Pest.
1.1. Installation and first test with PHPUnit
Install PHPUnit via Composer:
composer require --dev phpunit/phpunitCreate a test for a simple calculator class:
<?php// src/Calculator.phpclass Calculator { public function add(int $a, int $b): int { return $a + $b; }}
// tests/CalculatorTest.phpuse PHPUnit\\Framework\\TestCase;
class CalculatorTest extends TestCase { public function testAddition(): void { $calc = new Calculator(); $result = $calc->add(2, 3); $this->assertEquals(5, $result); }}Run the test:
./vendor/bin/phpunit tests1.2. Using Mock objects
To isolate the class under test, we "replace" its dependencies using mocks. Example with a user repository:
<?phpclass UserService { private UserRepository $repo; public function __construct(UserRepository $repo) { $this->repo = $repo; } public function getUserName(int $id): string { $user = $this->repo->findById($id); return $user ? $user->getName() : 'Guest'; }}
// In the test$mockRepo = $this->createMock(UserRepository::class);$mockRepo->method('findById') ->with(1) ->willReturn(new User('Alice'));
$service = new UserService($mockRepo);$this->assertEquals('Alice', $service->getUserName(1));Best practice: Write unit tests for all business logic. They should execute in seconds and not depend on the environment.
2. Integration tests: checking interaction with DB, API, and external services
Integration tests check how your classes work together with real components: database, cache, external HTTP services. They are slower than unit tests but provide more confidence that the system works as a whole.
2.1. Testing database interaction (Laravel + RefreshDatabase)
If you use Laravel, integration tests become very simple thanks to the RefreshDatabase trait:
<?phpnamespace Tests\\Feature;
use Tests\\TestCase;use Illuminate\\Foundation\\Testing\\RefreshDatabase;
class UserRegistrationTest extends TestCase { use RefreshDatabase;
public function test_user_can_register(): void { $response = $this->post('/register', [ 'name' => 'John', 'email' => 'john@example.com', 'password' => 'secret123', ]);
$response->assertStatus(302); // redirect after success $this->assertDatabaseHas('users', [ 'email' => 'john@example.com' ]); }}The RefreshDatabase trait automatically migrates the database before tests and