Юнит-тестирование в PHP: от основ до продвинутых техник

Раздел: PHP -> Тестирование

Основные подходы к юнит-тестированию в PHP

Какое решение является стандартом для юнит-тестов в PHP?

Самым распространённым и поддерживаемым инструментом является PHPUnit. Он предоставляет широкий набор assertions, мок-объектов, data providers и интеграцию с CI. Для начала работы требуется установить его через Composer:

composer require --dev phpunit/phpunit

Unit 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 это штатное решение.

Юнит-тестирование PHP - comments

En
Unit tests php (php)