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]