Юнит-тестирование в PHP: от основ до продвинутых техник
Основные подходы к юнит-тестированию в PHP
Какое решение является стандартом для юнит-тестов в PHP?
Самым распространённым и поддерживаемым инструментом является PHPUnit. Он предоставляет широкий набор assertions, мок-объектов, data providers и интеграцию с CI. Для начала работы требуется установить его через Composer:
composer require --dev phpunit/phpunitUnit tests php (юнит-тестирование php)
После установки создаётся класс теста, наследующий от TestCase:
<?php
use PHPUnit\Framework\TestCase;
class CalculatorTest extends TestCase
{
public function testAddition()
{
$calc = new Calculator();
$result = $calc->add(2, 3);
$this->assertEquals(5, $result);
}
}
система тестирования php (система тестирования на php)
Запуск теста выполняется командой vendor/bin/phpunit. Если тестов несколько, PHPUnit автоматически находит их в каталоге tests.
Типичные проблемы:
- Ошибка Class 'PHPUnit\Framework\TestCase' not found - не установлен PHPUnit или не выполнен autoload.
- Тест проходит, но реальная логика не проверена - использование assertTrue без контекста вместо конкретных assertions.
Как написать юнит-тест без фреймворка?
В минимальном варианте можно использовать собственный класс с assert-методами. Это полезно, когда требуется лёгкое решение без внешних зависимостей.
<?php
class MyAssert
{
public static function assertEquals($expected, $actual)
{
if ($expected !== $actual) {
throw new Exception("Expected $expected, got $actual");
}
}
}
class CalculatorTest
{
public function testAdd()
{
$calc = new Calculator();
MyAssert::assertEquals(5, $calc->add(2, 3));
echo "Test passed\n";
}
}
Api tests php (тестирование api на php)
Запуск - через php test.php. Такой подход подходит для очень простых проектов, но не даёт отчётов, покрытия и удобных моков.
Проблема: отсутствие нормального вывода ошибок и пропуск тестов при фатальных ошибках. Решение - всё же перейти на PHPUnit.
Какие альтернативные фреймворки существуют?
Кроме PHPUnit популярны Pest (более лаконичный синтаксис, обёртка над PHPUnit) и PHPSpec (ориентирован на BDD). Пест-тесты выглядят так:
<?php
it('adds two numbers', function () {
$calc = new Calculator();
expect($calc->add(2, 3))->toBe(5);
});
Http localhost test php (тестирование php на localhost)
PHPSpec использует спецификации вместо тестов:
class CalculatorSpec extends ObjectBehavior
{
function it_can_add()
{
$this->add(2, 3)->shouldBe(5);
}
}
Php testing (тестирование php)
Цель: Pest - быстрый старт и читаемый код; PHPSpec - поведенческий дизайн.
Проблема: PHPSpec не поддерживает традиционное мокирование в том же объёме, что PHPUnit. Для сложных зависимостей лучше оставаться на PHPUnit.
Как мокировать зависимости в PHPUnit?
PHPUnit предоставляет встроенные методы createMock() и getMockBuilder(). Например, класс, который зависит от внешнего сервиса:
class OrderProcessorTest extends TestCase
{
public function testProcess()
{
$mailer = $this->createMock(Mailer::class);
$mailer->expects($this->once())
->method('send')
->with($this->stringContains('Order'))
->willReturn(true);
$processor = new OrderProcessor($mailer);
$this->assertTrue($processor->process(['id' => 1]));
}
}
Также можно использовать Mockery (отдельная библиотека) для более выразительных моков:
$mailer = \Mockery::mock(Mailer::class);
$mailer->shouldReceive('send')
->once()
->with('subject', 'body')
->andReturn(true);
Мокирование необходимо, когда тестируется класс, зависящий от базы данных, API или файловой системы.
Ошибка: попытка мокировать final класс или методы. В таком случае потребуется интерфейс или использование partial mock.
Как тестировать исключения?
PHPUnit предлагает метод expectException():
public function testInvalidInput()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Value must be positive');
$calc = new Calculator();
$calc->sqrt(-1);
}
Также можно использовать аннотацию @expectedException (устаревшая в PHPUnit 9+).
Как использовать Data Providers для множества тестовых данных?
Data Provider - метод, возвращающий массив с наборами аргументов. Например:
/** @dataProvider additionProvider */
public function testAdd($a, $b, $expected)
{
$calc = new Calculator();
$this->assertEquals($expected, $calc->add($a, $b));
}
public function additionProvider(): array
{
return [
[1, 2, 3],
[0, 0, 0],
[-1, 1, 0],
];
}
Провайдеры позволяют избежать дублирования кода и легко добавлять новые кейсы.
Расширенные примеры юнит-тестирования PHP
Рассмотрим более сложные сценарии, которые часто встречаются в реальных проектах.
Пример 1. Тестирование класса с зависимостью от базы данных (репозиторий)
Предположим, имеется класс UserService, который использует UserRepository для сохранения пользователя. Изолируем тест с помощью мока:
<?php
use PHPUnit\Framework\TestCase;
class UserServiceTest extends TestCase
{
public function testCreateUser()
{
$repository = $this->createMock(UserRepository::class);
$repository->expects($this->once())
->method('save')
->willReturn(1); // возвращаем ID
$service = new UserService($repository);
$user = $service->createUser('john@example.com', 'John');
$this->assertInstanceOf(User::class, $user);
$this->assertEquals(1, $user->getId());
}
}
Результат: тест выполняется без реальной базы данных, проверяется, что метод save вызван ровно один раз.
Пример 2. Использование Prophecy для мокирования
Prophecy - альтернативная библиотека, встроенная в PHPUnit (через трейт MockObject). Пример:
use Prophecy\PhpUnit\ProphecyTrait;
class ShippingServiceTest extends TestCase
{
use ProphecyTrait;
public function testCalculateCost()
{
$rateProvider = $this->prophesize(RateProvider::class);
$rateProvider->getRate('US')->willReturn(5.0);
$service = new ShippingService($rateProvider->reveal());
$this->assertEquals(15.0, $service->calculateCost(3, 'US'));
}
}
Prophecy отличается лаконичным синтаксисом promises и prophecies.
Пример 3. Тестирование кода, работающего с файловой системой (vfsStream)
Для эмуляции файловой системы используется пакет mikey179/vfsstream. Настройка:
composer require --dev mikey179/vfsstream
Тест:
use org\bovigo\vfs\vfsStream;
class FileUploaderTest extends TestCase
{
public function testUpload()
{
$root = vfsStream::setup('uploads');
$uploader = new FileUploader(vfsStream::url('uploads'));
$result = $uploader->store(new UploadedFile('test.txt', 'hello'));
$this->assertTrue($result);
$this->assertTrue($root->hasChild('test.txt'));
}
}
Проблема: vfsStream не поддерживает все функции потоковой работы, но для большинства сценариев достаточно.
Пример 4. Тестирование HTTP-клиента с Guzzle Mock
Используем guzzlehttp/guzzle и встроенный MockHandler:
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
class ApiClientTest extends TestCase
{
public function testFetchData()
{
$mock = new MockHandler([
new Response(200, [], '{"status":"ok"}'),
]);
$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);
$api = new ApiClient($client);
$this->assertEquals(['status' => 'ok'], $api->fetch());
}
}
Результат: реальный HTTP-запрос не выполняется, тест быстр и детерминирован.
Пример 5. Тестирование команд Symfony Console
Команды можно тестировать через ApplicationTester:
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
class GreetCommandTest extends TestCase
{
public function testExecute()
{
$application = new Application();
$application->add(new GreetCommand());
$command = $application->find('app:greet');
$commandTester = new CommandTester($command);
$commandTester->execute(['name' => 'Alice']);
$output = $commandTester->getDisplay();
$this->assertStringContainsString('Hello, Alice', $output);
}
}
Это позволяет тестировать консольные приложения без запуска процесса.
Пример 6. Тестирование с использованием контейнера внедрения зависимостей (PHP-DI)
В сложных проектах можно создать контейнер для тестовой среды:
use DI\ContainerBuilder;
$containerBuilder = new ContainerBuilder();
$containerBuilder->addDefinitions([
// заменяем реальный репозиторий на мок
UserRepository::class => function () {
$mock = $this->createMock(UserRepository::class);
$mock->method('find')->willReturn(new User('test'));
return $mock;
},
]);
$container = $containerBuilder->build();
$service = $container->get(UserService::class);
Такой подход удобен для интеграционного тестирования с заменой только части зависимостей.
Пример 7. Data Provider с повторным использованием фикстур
Создаём провайдер, который возвращает экземпляры объектов, чтобы не создавать их в каждом тесте:
public function discountDataProvider(): array
{
$user = new User('VIP');
$user->setDiscount(10);
return [
[$user, 100, 90],
[new User('Regular'), 100, 100],
];
}
/** @dataProvider discountDataProvider */
public function testDiscount(User $user, $price, $expected)
{
$calc = new PriceCalculator();
$this->assertEquals($expected, $calc->calculate($user, $price));
}
Результат: код становится чище, а тесты - более модульными.
Пример 8. Тестирование асинхронных вызовов (Guzzle Promises)
Если используется GuzzleHttp\Promise, можно мокировать промис:
$promise = new FulfilledPromise('response');
$client = $this->createMock(Client::class);
$client->method('sendAsync')->willReturn($promise);
$service = new AsyncService($client);
$result = $service->process();
$this->assertEquals('response', $result->wait());
Проблема: не все библиотеки поддерживают промисы, но для Guzzle это штатное решение.