MidПрактика6 min

Unit тесты

PHPUnit basics, TestCase, assertions, data providers, mocks

PHPUnit в Symfony

Symfony использует PHPUnit для тестирования. Unit-тесты проверяют отдельные классы в изоляции от остальной системы.

В экзамене Sf8 PHPUnit Bridge больше не входит в список тем. Фокус на стандартном PHPUnit 11+.

Структура тестов

tests/
├── Unit/               # Unit tests (no kernel, no container)
│   ├── Service/
│   ├── Entity/
│   └── Util/
├── Integration/        # Integration tests (with kernel)
└── Functional/         # Functional tests (HTTP client)

Базовый Unit-тест

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Service\PriceCalculator;
use App\Entity\Product;
use PHPUnit\Framework\TestCase;

final class PriceCalculatorTest extends TestCase
{
    private PriceCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new PriceCalculator();
    }

    public function testCalculatePrice(): void
    {
        $product = new Product('Widget', 100.0);

        $result = $this->calculator->calculate($product, quantity: 3);

        $this->assertSame(300.0, $result);
    }

    public function testCalculatePriceWithDiscount(): void
    {
        $product = new Product('Widget', 100.0);

        $result = $this->calculator->calculate(
            $product,
            quantity: 3,
            discountPercent: 10,
        );

        $this->assertSame(270.0, $result);
    }

    public function testCalculateThrowsOnNegativeQuantity(): void
    {
        $product = new Product('Widget', 100.0);

        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Quantity must be positive');

        $this->calculator->calculate($product, quantity: -1);
    }
}

Assertions

Основные assertions

Assertion Описание
assertSame($expected, $actual) Строгое сравнение (===)
assertEquals($expected, $actual) Нестрогое сравнение (==)
assertTrue($value) Значение === true
assertFalse($value) Значение === false
assertNull($value) Значение === null
assertNotNull($value) Значение !== null
assertCount($count, $array) Количество элементов
assertEmpty($value) Пустое значение
assertInstanceOf($class, $object) Проверка типа
assertStringContainsString($needle, $haystack) Подстрока
assertArrayHasKey($key, $array) Ключ массива
assertGreaterThan($expected, $actual) Больше
assertMatchesRegularExpression($pattern, $string) Regex
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Entity;

use App\Entity\User;
use PHPUnit\Framework\TestCase;

final class UserTest extends TestCase
{
    public function testNewUserHasDefaultRole(): void
    {
        $user = new User();

        $this->assertContains('ROLE_USER', $user->getRoles());
        $this->assertCount(1, $user->getRoles());
    }

    public function testSetEmail(): void
    {
        $user = new User();
        $user->setEmail('[email protected]');

        $this->assertSame('[email protected]', $user->getEmail());
        $this->assertSame('[email protected]', $user->getUserIdentifier());
    }

    public function testAddRole(): void
    {
        $user = new User();
        $user->setRoles(['ROLE_ADMIN']);

        $roles = $user->getRoles();

        $this->assertContains('ROLE_ADMIN', $roles);
        $this->assertContains('ROLE_USER', $roles); // Always present
    }
}

Data Providers

Data Providers позволяют запускать один тест с разными наборами данных.

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Util;

use App\Util\SlugGenerator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class SlugGeneratorTest extends TestCase
{
    #[DataProvider('slugDataProvider')]
    public function testGenerateSlug(string $input, string $expected): void
    {
        $generator = new SlugGenerator();

        $this->assertSame($expected, $generator->generate($input));
    }

    /**
     * @return iterable<string, array{string, string}>
     */
    public static function slugDataProvider(): iterable
    {
        yield 'simple' => ['Hello World', 'hello-world'];
        yield 'special chars' => ['Hello, World!', 'hello-world'];
        yield 'accents' => ['cafe resume', 'cafe-resume'];
        yield 'multiple spaces' => ['hello   world', 'hello-world'];
        yield 'leading trailing' => [' hello world ', 'hello-world'];
        yield 'numbers' => ['Product 123', 'product-123'];
        yield 'empty' => ['', ''];
    }
}

Data Provider с PHPUnit 11+ атрибутами

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Service\TaxCalculator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class TaxCalculatorTest extends TestCase
{
    #[Test]
    #[DataProvider('taxRates')]
    public function calculatesTaxCorrectly(
        float $amount,
        string $country,
        float $expectedTax,
    ): void {
        $calculator = new TaxCalculator();

        $tax = $calculator->calculate($amount, $country);

        $this->assertEqualsWithDelta($expectedTax, $tax, 0.01);
    }

    /**
     * @return iterable<string, array{float, string, float}>
     */
    public static function taxRates(): iterable
    {
        yield 'Germany 19%' => [100.0, 'DE', 19.0];
        yield 'France 20%' => [100.0, 'FR', 20.0];
        yield 'UK 20%' => [100.0, 'GB', 20.0];
        yield 'US no federal' => [100.0, 'US', 0.0];
        yield 'zero amount' => [0.0, 'DE', 0.0];
    }
}

Mocks и Stubs

Mocking с PHPUnit

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Entity\Order;
use App\Repository\OrderRepository;
use App\Service\MailerInterface;
use App\Service\OrderNotifier;
use PHPUnit\Framework\TestCase;

final class OrderNotifierTest extends TestCase
{
    public function testNotifiesCustomerOnShipment(): void
    {
        // Create mock for MailerInterface
        $mailer = $this->createMock(MailerInterface::class);

        // Expect send() to be called exactly once
        $mailer
            ->expects($this->once())
            ->method('send')
            ->with(
                $this->equalTo('[email protected]'),
                $this->equalTo('Your order has been shipped'),
                $this->stringContains('Order #12345'),
            );

        $notifier = new OrderNotifier($mailer);

        $order = new Order();
        $order->setNumber('12345');
        $order->setCustomerEmail('[email protected]');

        $notifier->notifyShipped($order);
    }

    public function testSkipsNotificationForTestOrders(): void
    {
        $mailer = $this->createMock(MailerInterface::class);

        // Expect send() to NEVER be called
        $mailer
            ->expects($this->never())
            ->method('send');

        $notifier = new OrderNotifier($mailer);

        $order = new Order();
        $order->setNumber('TEST-001');
        $order->setTest(true);

        $notifier->notifyShipped($order);
    }
}

Stubs (возвращаемые значения)

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Entity\User;
use App\Repository\UserRepository;
use App\Service\UserStats;
use PHPUnit\Framework\TestCase;

final class UserStatsTest extends TestCase
{
    public function testCountActiveUsers(): void
    {
        // Stub: repository returns predetermined data
        $repository = $this->createStub(UserRepository::class);
        $repository
            ->method('findActiveUsers')
            ->willReturn([
                new User('[email protected]'),
                new User('[email protected]'),
            ]);

        $stats = new UserStats($repository);

        $this->assertSame(2, $stats->getActiveCount());
    }

    public function testHandlesEmptyResult(): void
    {
        $repository = $this->createStub(UserRepository::class);
        $repository
            ->method('findActiveUsers')
            ->willReturn([]);

        $stats = new UserStats($repository);

        $this->assertSame(0, $stats->getActiveCount());
    }

    public function testHandlesRepositoryException(): void
    {
        $repository = $this->createStub(UserRepository::class);
        $repository
            ->method('findActiveUsers')
            ->willThrowException(new \RuntimeException('DB connection failed'));

        $stats = new UserStats($repository);

        $this->expectException(\RuntimeException::class);
        $stats->getActiveCount();
    }
}

Разница Mock vs Stub

Аспект Stub (createStub) Mock (createMock)
Назначение Возвращает заданные данные Проверяет вызовы методов
Проверка вызовов Нет expects(), once(), never()
willReturn() Да Да
willThrowException() Да Да
Использование Подмена зависимости Проверка взаимодействия

setUp и tearDown

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use PHPUnit\Framework\TestCase;

final class FileProcessorTest extends TestCase
{
    private string $tempDir;

    /**
     * Called BEFORE each test method
     */
    protected function setUp(): void
    {
        $this->tempDir = sys_get_temp_dir() . '/test_' . uniqid();
        mkdir($this->tempDir, 0777, true);
    }

    /**
     * Called AFTER each test method
     */
    protected function tearDown(): void
    {
        // Cleanup temp files
        if (is_dir($this->tempDir)) {
            array_map('unlink', glob($this->tempDir . '/*'));
            rmdir($this->tempDir);
        }
    }

    /**
     * Called ONCE before ALL tests in this class
     */
    public static function setUpBeforeClass(): void
    {
        // One-time setup
    }

    /**
     * Called ONCE after ALL tests in this class
     */
    public static function tearDownAfterClass(): void
    {
        // One-time cleanup
    }

    public function testProcessFile(): void
    {
        file_put_contents($this->tempDir . '/test.txt', 'content');
        // Test file processing...
        $this->assertFileExists($this->tempDir . '/test.txt');
    }
}

PHPUnit 11+ Атрибуты

<?php

declare(strict_types=1);

namespace App\Tests\Unit;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Depends;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;

#[CoversClass(Calculator::class)]
#[Group('unit')]
final class CalculatorTest extends TestCase
{
    #[Test]
    public function addsNumbers(): void
    {
        $calc = new Calculator();
        $this->assertSame(5, $calc->add(2, 3));
    }

    // Inline data provider
    #[Test]
    #[TestWith([1, 2, 3])]
    #[TestWith([0, 0, 0])]
    #[TestWith([-1, 1, 0])]
    public function addsWithInlineData(int $a, int $b, int $expected): void
    {
        $calc = new Calculator();
        $this->assertSame($expected, $calc->add($a, $b));
    }

    #[Test]
    public function createsCalculator(): Calculator
    {
        $calc = new Calculator();
        $this->assertInstanceOf(Calculator::class, $calc);
        return $calc;
    }

    #[Test]
    #[Depends('createsCalculator')]
    public function usesCreatedCalculator(Calculator $calc): void
    {
        $this->assertSame(10, $calc->add(4, 6));
    }
}

Итоги

  • Unit-тесты наследуют TestCase (не KernelTestCase)
  • assertSame() для строгого сравнения, assertEquals() для нестрогого
  • Data Providers через #[DataProvider] -- один тест с разными данными
  • createMock() -- проверка вызовов, createStub() -- подмена данных
  • setUp() / tearDown() -- перед/после каждого теста
  • PHPUnit 11+: атрибуты #[Test], #[DataProvider], #[TestWith]