Что такое PHPUnit
PHPUnit — это фреймворк для модульного тестирования PHP-кода. Он является стандартом де-факто для тестирования в PHP-экосистеме. PHPUnit позволяет писать автоматические тесты, которые проверяют корректность работы отдельных модулей (классов, методов, функций).
Зачем нужно тестирование:
- Уверенность в коде — тесты доказывают, что код работает правильно
- Безопасный рефакторинг — тесты ловят регрессии при изменениях
- Документация — тесты показывают, как использовать код
- Быстрая обратная связь — не нужно вручную проверять каждое изменение
- Дизайн кода — написание тестов заставляет проектировать тестируемый код
Установка и настройка
Установка через Composer
# Install PHPUnit as dev dependency
composer require --dev phpunit/phpunit
# Verify installation
vendor/bin/phpunit --version
# PHPUnit 11.5.x by Sebastian Bergmann and contributors.
Конфигурация phpunit.xml
Создайте файл phpunit.xml (или phpunit.xml.dist) в корне проекта:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
stopOnFailure="false"
cacheDirectory=".phpunit.cache"
executionOrder="random"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
<exclude>
<directory>src/DataFixtures</directory>
</exclude>
</source>
<coverage>
<report>
<html outputDirectory="coverage"/>
<clover outputFile="coverage/clover.xml"/>
</report>
</coverage>
<php>
<env name="APP_ENV" value="test"/>
<env name="DATABASE_URL" value="sqlite:///:memory:"/>
</php>
</phpunit>
Структура директорий
project/
├── src/
│ ├── Entity/
│ │ └── User.php
│ ├── Service/
│ │ └── UserService.php
│ └── Repository/
│ └── UserRepository.php
├── tests/
│ ├── Unit/
│ │ ├── Entity/
│ │ │ └── UserTest.php
│ │ └── Service/
│ │ └── UserServiceTest.php
│ └── Integration/
│ └── Repository/
│ └── UserRepositoryTest.php
├── phpunit.xml
└── composer.json
Первый тест
Класс для тестирования
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Simple calculator for arithmetic operations.
*/
final 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.0) {
throw new \DivisionByZeroError('Division by zero');
}
return $a / $b;
}
public function percentage(float $value, float $percent): float
{
return $value * ($percent / 100);
}
}
Тестовый класс
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\Calculator;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Service\Calculator
*/
final class CalculatorTest extends TestCase
{
private Calculator $calculator;
protected function setUp(): void
{
$this->calculator = new Calculator();
}
public function testAddReturnsSumOfTwoNumbers(): void
{
$result = $this->calculator->add(2, 3);
self::assertSame(5.0, $result);
}
public function testSubtractReturnsDifference(): void
{
$result = $this->calculator->subtract(10, 4);
self::assertSame(6.0, $result);
}
public function testMultiplyReturnsProduct(): void
{
$result = $this->calculator->multiply(3, 7);
self::assertSame(21.0, $result);
}
public function testDivideReturnsDivision(): void
{
$result = $this->calculator->divide(10, 2);
self::assertSame(5.0, $result);
}
public function testDivideByZeroThrowsException(): void
{
$this->expectException(\DivisionByZeroError::class);
$this->expectExceptionMessage('Division by zero');
$this->calculator->divide(10, 0);
}
public function testPercentageCalculation(): void
{
$result = $this->calculator->percentage(200, 15);
self::assertSame(30.0, $result);
}
}
Запуск тестов
# Run all tests
vendor/bin/phpunit
# Run specific test file
vendor/bin/phpunit tests/Unit/Service/CalculatorTest.php
# Run specific test method
vendor/bin/phpunit --filter testAddReturnsSumOfTwoNumbers
# Run specific test suite
vendor/bin/phpunit --testsuite Unit
# Run with verbose output
vendor/bin/phpunit -v
# Run and stop on first failure
vendor/bin/phpunit --stop-on-failure
Именование тестов
Два способа объявить метод как тест:
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class NamingConventionTest extends TestCase
{
// Method 1: prefix with "test"
public function testSomethingWorksCorrectly(): void
{
self::assertTrue(true);
}
// Method 2: #[Test] attribute (PHP 8.1+)
#[Test]
public function somethingWorksCorrectly(): void
{
self::assertTrue(true);
}
}
Рекомендация: используйте префикс test — это явнее и читаемее. Названия должны описывать поведение: testUserCannotLoginWithInvalidPassword.
Assertions — утверждения
Assertions — это проверки, которые определяют успешность теста. PHPUnit предоставляет десятки встроенных assertions.
Базовые assertions
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use PHPUnit\Framework\TestCase;
final class AssertionExamplesTest extends TestCase
{
// Equality
public function testEquality(): void
{
// assertEquals: loose comparison (==)
self::assertEquals('123', 123); // passes (type juggling)
self::assertEquals(0, false); // passes
// assertSame: strict comparison (===)
self::assertSame(123, 123); // passes
// self::assertSame('123', 123); // FAILS (different types)
// assertNotEquals / assertNotSame
self::assertNotEquals('abc', 'xyz');
self::assertNotSame(1, '1');
}
// Boolean
public function testBoolean(): void
{
self::assertTrue(1 === 1);
self::assertFalse(1 === 2);
}
// Null
public function testNull(): void
{
self::assertNull(null);
self::assertNotNull('value');
}
// Type checking
public function testTypes(): void
{
self::assertIsString('hello');
self::assertIsInt(42);
self::assertIsFloat(3.14);
self::assertIsBool(true);
self::assertIsArray([1, 2, 3]);
self::assertIsObject(new \stdClass());
}
// Instance checking
public function testInstanceOf(): void
{
$exception = new \InvalidArgumentException('test');
self::assertInstanceOf(\InvalidArgumentException::class, $exception);
self::assertInstanceOf(\Exception::class, $exception); // parent class
self::assertInstanceOf(\Throwable::class, $exception); // interface
}
}
Assertions для строк
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use PHPUnit\Framework\TestCase;
final class StringAssertionsTest extends TestCase
{
public function testStringContains(): void
{
$message = 'Hello, World!';
self::assertStringContainsString('World', $message);
self::assertStringNotContainsString('Goodbye', $message);
// Case-insensitive
self::assertStringContainsStringIgnoringCase('world', $message);
}
public function testStringStartsAndEnds(): void
{
$url = 'https://example.com/api/users';
self::assertStringStartsWith('https://', $url);
self::assertStringEndsWith('/users', $url);
}
public function testRegularExpression(): void
{
$email = '[email protected]';
self::assertMatchesRegularExpression('/^[a-z]+@[a-z]+\.[a-z]+$/i', $email);
}
public function testJson(): void
{
$expected = '{"name":"John","age":30}';
$actual = '{"age":30,"name":"John"}';
// Compares JSON structures (ignores key order)
self::assertJsonStringEqualsJsonString($expected, $actual);
}
public function testEmpty(): void
{
self::assertEmpty('');
self::assertEmpty([]);
self::assertNotEmpty('text');
}
}
Assertions для массивов
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use PHPUnit\Framework\TestCase;
final class ArrayAssertionsTest extends TestCase
{
public function testArrayContains(): void
{
$fruits = ['apple', 'banana', 'cherry'];
self::assertContains('banana', $fruits);
self::assertNotContains('grape', $fruits);
self::assertCount(3, $fruits);
}
public function testArrayHasKey(): void
{
$user = [
'name' => 'John',
'email' => '[email protected]',
'age' => 30,
];
self::assertArrayHasKey('name', $user);
self::assertArrayHasKey('email', $user);
self::assertArrayNotHasKey('password', $user);
}
public function testArrayEquality(): void
{
$expected = [1, 2, 3];
$actual = [1, 2, 3];
self::assertSame($expected, $actual); // Same order and types
self::assertEquals($expected, $actual); // Same values (loose)
}
public function testContainsOnly(): void
{
$numbers = [1, 2, 3, 4, 5];
self::assertContainsOnly('int', $numbers);
}
}
Assertions для сравнений
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use PHPUnit\Framework\TestCase;
final class ComparisonAssertionsTest extends TestCase
{
public function testGreaterAndLess(): void
{
self::assertGreaterThan(5, 10);
self::assertGreaterThanOrEqual(5, 5);
self::assertLessThan(10, 5);
self::assertLessThanOrEqual(5, 5);
}
public function testEqualsWithDelta(): void
{
// Useful for floating-point comparison
self::assertEqualsWithDelta(3.14, 3.141592, 0.01);
}
}
Lifecycle methods — методы жизненного цикла
PHPUnit предоставляет хуки, которые выполняются в определённые моменты жизненного цикла теста.
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use PHPUnit\Framework\TestCase;
final class LifecycleTest extends TestCase
{
private static int $connectionCount = 0;
/**
* Runs ONCE before ALL tests in this class.
* Use for expensive setup (database connection, fixtures).
*/
public static function setUpBeforeClass(): void
{
self::$connectionCount++;
// e.g., create database schema, load fixtures
}
/**
* Runs BEFORE EACH test method.
* Use for per-test setup (fresh objects, reset state).
*/
protected function setUp(): void
{
// Create fresh Calculator for each test
// $this->calculator = new Calculator();
}
/**
* Runs AFTER EACH test method.
* Use for cleanup (close connections, delete temp files).
*/
protected function tearDown(): void
{
// Cleanup after each test
}
/**
* Runs ONCE after ALL tests in this class.
* Use for global cleanup.
*/
public static function tearDownAfterClass(): void
{
// e.g., drop database, close connection pool
}
public function testFirst(): void
{
// setUp() ran before this
self::assertTrue(true);
// tearDown() runs after this
}
public function testSecond(): void
{
// setUp() ran again before this
self::assertTrue(true);
// tearDown() runs after this
}
}
Порядок выполнения
setUpBeforeClass() ← один раз перед всеми тестами
setUp() ← перед testFirst
testFirst()
tearDown() ← после testFirst
setUp() ← перед testSecond
testSecond()
tearDown() ← после testSecond
tearDownAfterClass() ← один раз после всех тестов
Практический пример
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Entity\User;
use App\Service\UserValidator;
use PHPUnit\Framework\TestCase;
final class UserValidatorTest extends TestCase
{
private UserValidator $validator;
protected function setUp(): void
{
// Fresh validator for each test — no shared state
$this->validator = new UserValidator();
}
public function testValidEmailPasses(): void
{
$user = new User(
name: 'John Doe',
email: '[email protected]',
);
$result = $this->validator->validate($user);
self::assertTrue($result->isValid());
self::assertEmpty($result->getErrors());
}
public function testInvalidEmailFails(): void
{
$user = new User(
name: 'John Doe',
email: 'not-an-email',
);
$result = $this->validator->validate($user);
self::assertFalse($result->isValid());
self::assertArrayHasKey('email', $result->getErrors());
}
}
Data Providers
Data providers позволяют запускать один тестовый метод с разными наборами данных. Это устраняет дублирование тестов.
Базовый Data Provider
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\Calculator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class CalculatorDataProviderTest extends TestCase
{
private Calculator $calculator;
protected function setUp(): void
{
$this->calculator = new Calculator();
}
/**
* @return iterable<string, array{float, float, float}>
*/
public static function additionProvider(): iterable
{
yield 'positive numbers' => [2.0, 3.0, 5.0];
yield 'negative numbers' => [-1.0, -2.0, -3.0];
yield 'mixed numbers' => [-1.0, 3.0, 2.0];
yield 'zeros' => [0.0, 0.0, 0.0];
yield 'decimals' => [0.1, 0.2, 0.3];
yield 'large numbers' => [1_000_000.0, 2_000_000.0, 3_000_000.0];
}
#[DataProvider('additionProvider')]
public function testAdd(float $a, float $b, float $expected): void
{
$result = $this->calculator->add($a, $b);
self::assertEqualsWithDelta($expected, $result, 0.0001);
}
/**
* @return iterable<string, array{float, float, float}>
*/
public static function divisionProvider(): iterable
{
yield 'simple division' => [10.0, 2.0, 5.0];
yield 'decimal result' => [7.0, 2.0, 3.5];
yield 'divide by one' => [42.0, 1.0, 42.0];
yield 'negative dividend' => [-10.0, 2.0, -5.0];
yield 'both negative' => [-10.0, -2.0, 5.0];
}
#[DataProvider('divisionProvider')]
public function testDivide(float $a, float $b, float $expected): void
{
$result = $this->calculator->divide($a, $b);
self::assertEqualsWithDelta($expected, $result, 0.0001);
}
}
Data Provider с объектами
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Entity;
use App\Entity\User;
use App\Enum\UserRole;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class UserTest extends TestCase
{
/**
* @return iterable<string, array{string, string, UserRole, bool}>
*/
public static function validUserProvider(): iterable
{
yield 'regular user' => [
'John Doe',
'[email protected]',
UserRole::User,
false,
];
yield 'admin user' => [
'Admin',
'[email protected]',
UserRole::Admin,
true,
];
yield 'moderator' => [
'Mod User',
'[email protected]',
UserRole::Moderator,
false,
];
}
#[DataProvider('validUserProvider')]
public function testUserCreation(
string $name,
string $email,
UserRole $role,
bool $expectedAdmin,
): void {
$user = new User(
name: $name,
email: $email,
role: $role,
);
self::assertSame($name, $user->getName());
self::assertSame($email, $user->getEmail());
self::assertSame($role, $user->getRole());
self::assertSame($expectedAdmin, $user->isAdmin());
}
}
Generator-based Data Provider
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class EmailValidationTest extends TestCase
{
/**
* @return \Generator<string, array{string, bool}>
*/
public static function emailProvider(): \Generator
{
// Valid emails
yield 'simple email' => ['[email protected]', true];
yield 'with dots' => ['[email protected]', true];
yield 'with plus' => ['[email protected]', true];
yield 'subdomain' => ['[email protected]', true];
// Invalid emails
yield 'no at sign' => ['user-example.com', false];
yield 'no domain' => ['user@', false];
yield 'no local part' => ['@example.com', false];
yield 'spaces' => ['user @example.com', false];
yield 'empty string' => ['', false];
}
#[DataProvider('emailProvider')]
public function testEmailValidation(string $email, bool $expectedValid): void
{
$isValid = filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
self::assertSame($expectedValid, $isValid);
}
}
Тестирование исключений
Базовое тестирование исключений
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\UserService;
use App\Exception\UserNotFoundException;
use App\Exception\InvalidEmailException;
use PHPUnit\Framework\TestCase;
final class UserServiceExceptionTest extends TestCase
{
private UserService $service;
protected function setUp(): void
{
$this->service = new UserService();
}
public function testThrowsExceptionForInvalidEmail(): void
{
$this->expectException(InvalidEmailException::class);
$this->expectExceptionMessage('Email "not-valid" is not a valid email address');
$this->expectExceptionCode(422);
$this->service->createUser('John', 'not-valid');
}
public function testThrowsNotFoundForUnknownUser(): void
{
$this->expectException(UserNotFoundException::class);
$this->expectExceptionMessageMatches('/User .+ not found/');
$this->service->findUserOrFail('nonexistent-id');
}
}
Тестирование с try-catch (для дополнительных проверок)
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Exception\ValidationException;
use App\Service\OrderService;
use PHPUnit\Framework\TestCase;
final class OrderServiceTest extends TestCase
{
public function testInvalidOrderThrowsValidationException(): void
{
$service = new OrderService();
try {
$service->createOrder(items: [], customerId: '');
self::fail('Expected ValidationException was not thrown');
} catch (ValidationException $e) {
self::assertSame(422, $e->getCode());
self::assertCount(2, $e->getErrors());
self::assertArrayHasKey('items', $e->getErrors());
self::assertArrayHasKey('customerId', $e->getErrors());
}
}
}
Группировка и пропуск тестов
Группировка
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
#[Group('user')]
final class UserFeatureTest extends TestCase
{
#[Group('auth')]
public function testLogin(): void
{
self::assertTrue(true);
}
#[Group('profile')]
public function testUpdateProfile(): void
{
self::assertTrue(true);
}
}
# Run tests in specific group
vendor/bin/phpunit --group auth
# Exclude a group
vendor/bin/phpunit --exclude-group slow
Пропуск тестов
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
use PHPUnit\Framework\TestCase;
final class ConditionalTest extends TestCase
{
public function testSkippedInCi(): void
{
if (getenv('CI') !== false) {
self::markTestSkipped('This test cannot run in CI');
}
// Test logic here
self::assertTrue(true);
}
public function testIncomplete(): void
{
self::markTestIncomplete('Not yet implemented — see TASK-123');
}
#[RequiresPhpExtension('redis')]
public function testRedisConnection(): void
{
// This test only runs when ext-redis is available
self::assertTrue(true);
}
}
Тестирование вывода
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Command\GreetCommand;
use PHPUnit\Framework\TestCase;
final class OutputTest extends TestCase
{
public function testGreetOutputsCorrectMessage(): void
{
$this->expectOutputString("Hello, World!\n");
$command = new GreetCommand();
$command->execute('World');
}
public function testOutputContainsName(): void
{
$this->expectOutputRegex('/Hello, [A-Z][a-z]+!/');
$command = new GreetCommand();
$command->execute('John');
}
}
Практический пример: тестирование UserService
Сервис
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\User;
use App\Repository\UserRepositoryInterface;
use App\Exception\UserAlreadyExistsException;
use App\Exception\InvalidEmailException;
final readonly class UserService
{
public function __construct(
private UserRepositoryInterface $repository,
) {}
/**
* @throws InvalidEmailException
* @throws UserAlreadyExistsException
*/
public function register(string $name, string $email): User
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException(
sprintf('Email "%s" is not valid', $email),
);
}
if ($this->repository->findByEmail($email) !== null) {
throw new UserAlreadyExistsException(
sprintf('User with email "%s" already exists', $email),
);
}
$user = new User(
name: $name,
email: $email,
);
$this->repository->save($user);
return $user;
}
/**
* @return list<User>
*/
public function findActiveUsers(): array
{
return $this->repository->findByActive(true);
}
}
Полный тестовый класс
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Entity\User;
use App\Exception\InvalidEmailException;
use App\Exception\UserAlreadyExistsException;
use App\Repository\UserRepositoryInterface;
use App\Service\UserService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Service\UserService
*/
final class UserServiceTest extends TestCase
{
private UserRepositoryInterface&MockObject $repository;
private UserService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(UserRepositoryInterface::class);
$this->service = new UserService($this->repository);
}
public function testRegisterCreatesNewUser(): void
{
$this->repository
->method('findByEmail')
->with('[email protected]')
->willReturn(null);
$this->repository
->expects(self::once())
->method('save')
->with(self::isInstanceOf(User::class));
$user = $this->service->register('John', '[email protected]');
self::assertSame('John', $user->getName());
self::assertSame('[email protected]', $user->getEmail());
}
public function testRegisterThrowsForInvalidEmail(): void
{
$this->expectException(InvalidEmailException::class);
$this->expectExceptionMessage('"not-an-email" is not valid');
$this->service->register('John', 'not-an-email');
}
public function testRegisterThrowsForDuplicateEmail(): void
{
$existingUser = new User(name: 'Existing', email: '[email protected]');
$this->repository
->method('findByEmail')
->with('[email protected]')
->willReturn($existingUser);
$this->expectException(UserAlreadyExistsException::class);
$this->service->register('John', '[email protected]');
}
/**
* @return iterable<string, array{string}>
*/
public static function invalidEmailProvider(): iterable
{
yield 'no at sign' => ['invalid'];
yield 'no domain' => ['user@'];
yield 'no local' => ['@domain.com'];
yield 'spaces' => ['user @domain.com'];
yield 'empty' => [''];
}
#[DataProvider('invalidEmailProvider')]
public function testRegisterRejectsInvalidEmails(string $email): void
{
$this->expectException(InvalidEmailException::class);
$this->service->register('Test', $email);
}
public function testFindActiveUsersReturnsUsers(): void
{
$users = [
new User(name: 'Alice', email: '[email protected]'),
new User(name: 'Bob', email: '[email protected]'),
];
$this->repository
->method('findByActive')
->with(true)
->willReturn($users);
$result = $this->service->findActiveUsers();
self::assertCount(2, $result);
self::assertSame('Alice', $result[0]->getName());
self::assertSame('Bob', $result[1]->getName());
}
public function testFindActiveUsersReturnsEmptyWhenNoUsers(): void
{
$this->repository
->method('findByActive')
->willReturn([]);
$result = $this->service->findActiveUsers();
self::assertEmpty($result);
}
}