HardТеория12 min

Продвинутое тестирование

Mocking, stubs, spies, Code Coverage, TDD, Mockery, тестирование баз данных

Mocking — имитация зависимостей

Моки (mocks) позволяют заменять реальные зависимости на контролируемые имитации. Это ключевой инструмент модульного тестирования — он изолирует тестируемый код от внешних зависимостей (базы данных, HTTP-клиенты, файловая система).

createMock() — быстрое создание мока

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Repository\UserRepositoryInterface;
use App\Entity\User;
use App\Service\UserService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

final class MockExampleTest extends TestCase
{
    public function testCreateMockBasic(): void
    {
        // Create a mock of the interface
        $repository = $this->createMock(UserRepositoryInterface::class);

        // Configure the mock to return a specific value
        $repository
            ->method('findByEmail')
            ->willReturn(new User(name: 'John', email: '[email protected]'));

        // Use the mock
        $user = $repository->findByEmail('[email protected]');

        self::assertSame('John', $user->getName());
    }
}

Полная конфигурация мока

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Entity\User;
use App\Notification\NotifierInterface;
use App\Repository\UserRepositoryInterface;
use App\Service\RegistrationService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

final class RegistrationServiceTest extends TestCase
{
    private UserRepositoryInterface&MockObject $repository;
    private NotifierInterface&MockObject $notifier;
    private RegistrationService $service;

    protected function setUp(): void
    {
        $this->repository = $this->createMock(UserRepositoryInterface::class);
        $this->notifier = $this->createMock(NotifierInterface::class);

        $this->service = new RegistrationService(
            repository: $this->repository,
            notifier: $this->notifier,
        );
    }

    public function testRegisterSavesUserAndSendsNotification(): void
    {
        // Stub: configure return value
        $this->repository
            ->method('findByEmail')
            ->with('[email protected]')  // Expected argument
            ->willReturn(null);          // Return value

        // Mock: verify method is called with specific arguments
        $this->repository
            ->expects(self::once())          // Exactly one call
            ->method('save')
            ->with(self::callback(function (User $user): bool {
                return $user->getName() === 'Alice'
                    && $user->getEmail() === '[email protected]';
            }));

        // Mock: verify notification is sent
        $this->notifier
            ->expects(self::once())
            ->method('sendWelcomeEmail')
            ->with(self::isInstanceOf(User::class));

        // Act
        $user = $this->service->register('Alice', '[email protected]');

        // Assert
        self::assertSame('Alice', $user->getName());
    }

    public function testRegisterDoesNotNotifyOnDuplicate(): void
    {
        $existingUser = new User(name: 'Alice', email: '[email protected]');

        $this->repository
            ->method('findByEmail')
            ->willReturn($existingUser);

        // Verify notification is NEVER sent
        $this->notifier
            ->expects(self::never())
            ->method('sendWelcomeEmail');

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

        $this->service->register('Alice', '[email protected]');
    }
}

Expectation matchers

<?php

declare(strict_types=1);

namespace App\Tests\Unit;

use App\Logger\LoggerInterface;
use PHPUnit\Framework\TestCase;

final class ExpectationMatchersTest extends TestCase
{
    public function testCallCountMatchers(): void
    {
        $logger = $this->createMock(LoggerInterface::class);

        // Exactly N times
        $logger->expects(self::exactly(3))->method('info');

        // At least once
        $logger->expects(self::atLeastOnce())->method('debug');

        // At most N times
        $logger->expects(self::atMost(5))->method('warning');

        // Never called
        $logger->expects(self::never())->method('error');

        // Any number of times (no expectation on count)
        $logger->expects(self::any())->method('notice');
    }

    public function testArgumentMatchers(): void
    {
        $logger = $this->createMock(LoggerInterface::class);

        // Exact value
        $logger->expects(self::once())
            ->method('info')
            ->with('User logged in');

        // Multiple arguments
        $logger->expects(self::once())
            ->method('info')
            ->with(
                self::stringContains('User'),
                self::arrayHasKey('userId'),
            );

        // Any argument
        $logger->expects(self::once())
            ->method('info')
            ->with(self::anything());

        // Custom callback matcher
        $logger->expects(self::once())
            ->method('info')
            ->with(self::callback(function (string $message): bool {
                return str_starts_with($message, 'User');
            }));
    }
}

Настройка возвращаемых значений

<?php

declare(strict_types=1);

namespace App\Tests\Unit;

use App\Repository\ProductRepositoryInterface;
use App\Entity\Product;
use PHPUnit\Framework\TestCase;

final class ReturnValueTest extends TestCase
{
    public function testReturnConfigurations(): void
    {
        $repo = $this->createMock(ProductRepositoryInterface::class);

        // Simple return value
        $repo->method('find')->willReturn(new Product('Laptop'));

        // Return different values on consecutive calls
        $repo->method('count')
            ->willReturnOnConsecutiveCalls(0, 1, 5);

        self::assertSame(0, $repo->count());
        self::assertSame(1, $repo->count());
        self::assertSame(5, $repo->count());

        // Return value map (different returns based on arguments)
        $repo->method('find')
            ->willReturnMap([
                [1, new Product('Laptop')],
                [2, new Product('Phone')],
                [999, null],
            ]);

        // Return via callback
        $repo->method('findByName')
            ->willReturnCallback(function (string $name): ?Product {
                return match ($name) {
                    'Laptop' => new Product('Laptop'),
                    'Phone' => new Product('Phone'),
                    default => null,
                };
            });

        // Throw exception
        $repo->method('findOrFail')
            ->willThrowException(new \RuntimeException('Not found'));

        // Return the argument itself
        $repo->method('save')
            ->willReturnArgument(0);  // Returns first argument
    }
}

Stubs vs Mocks vs Spies

Это три разных подхода к тестовым двойникам (test doubles):

Stub — заглушка

Предоставляет предопределённые ответы. Не проверяет вызовы.

<?php

declare(strict_types=1);

namespace App\Tests\Unit;

use App\Service\PricingService;
use App\Gateway\CurrencyGatewayInterface;
use PHPUnit\Framework\TestCase;

final class StubExampleTest extends TestCase
{
    public function testCalculatePriceWithStub(): void
    {
        // Stub: just provides a canned response
        $gateway = $this->createStub(CurrencyGatewayInterface::class);
        $gateway->method('getRate')
            ->willReturn(1.15);  // Always return this rate

        $service = new PricingService($gateway);
        $price = $service->convertToEur(100.0);

        // We only care about the result, not how gateway was called
        self::assertEqualsWithDelta(115.0, $price, 0.01);
    }
}

Mock — имитация с проверкой

Предоставляет ответы и проверяет вызовы (количество, аргументы).

<?php

declare(strict_types=1);

namespace App\Tests\Unit;

use App\Service\OrderService;
use App\Gateway\PaymentGatewayInterface;
use App\Entity\Order;
use PHPUnit\Framework\TestCase;

final class MockExampleTest extends TestCase
{
    public function testProcessOrderCallsPaymentGateway(): void
    {
        // Mock: verifies the interaction
        $gateway = $this->createMock(PaymentGatewayInterface::class);

        $gateway->expects(self::once())          // Must be called exactly once
            ->method('charge')
            ->with(
                self::equalTo('cust_123'),        // Customer ID
                self::equalTo(99.99),             // Amount
                self::equalTo('USD'),             // Currency
            )
            ->willReturn(true);

        $service = new OrderService($gateway);
        $order = new Order(customerId: 'cust_123', amount: 99.99);

        $service->process($order);

        // Mock expectations are automatically verified at tearDown
    }
}

Spy — шпион (через callback)

PHPUnit не имеет встроенных spies, но их можно эмулировать:

<?php

declare(strict_types=1);

namespace App\Tests\Unit;

use App\Service\EventDispatcher;
use App\Event\UserRegisteredEvent;
use PHPUnit\Framework\TestCase;

final class SpyExampleTest extends TestCase
{
    public function testEventIsFiredWithCorrectData(): void
    {
        $dispatcher = $this->createMock(EventDispatcher::class);

        // Spy: capture the argument for later inspection
        $capturedEvent = null;

        $dispatcher->expects(self::once())
            ->method('dispatch')
            ->willReturnCallback(function (UserRegisteredEvent $event) use (&$capturedEvent): void {
                $capturedEvent = $event;
            });

        // ... execute code that should dispatch event ...

        // Later: inspect what was captured
        self::assertNotNull($capturedEvent);
        self::assertSame('[email protected]', $capturedEvent->getEmail());
    }
}

Сравнительная таблица

Тип Предоставляет ответы Проверяет вызовы Когда использовать
Stub Да Нет Входные данные для тестируемого кода
Mock Да Да Проверка взаимодействия с зависимостями
Spy Опционально Постфактум Захват аргументов для детальной проверки

Правило: предпочитайте stubs. Используйте mocks только когда взаимодействие критично (отправка email, запись в лог, платёжный шлюз).

Partial Mocks

Частичные моки позволяют замокать только некоторые методы класса, оставив остальные реальными.

<?php

declare(strict_types=1);

namespace App\Tests\Unit;

use App\Service\ReportGenerator;
use PHPUnit\Framework\TestCase;

final class PartialMockTest extends TestCase
{
    public function testPartialMockWithOnlyMethods(): void
    {
        // Mock only the specified methods, keep the rest real
        $generator = $this->getMockBuilder(ReportGenerator::class)
            ->onlyMethods(['fetchData'])  // Only mock fetchData
            ->getMock();

        // Configure mocked method
        $generator->method('fetchData')
            ->willReturn(['revenue' => 1000, 'expenses' => 500]);

        // generateReport() is a REAL method that uses fetchData()
        $report = $generator->generateReport();

        self::assertSame(500, $report->getProfit());
    }
}

Тестирование private-методов (через Reflection)

Прямое тестирование private-методов — антипаттерн. Тесты должны проверять публичный API. Однако иногда для legacy-кода это необходимо.

<?php

declare(strict_types=1);

namespace App\Tests\Unit;

use App\Service\TokenGenerator;
use PHPUnit\Framework\TestCase;

final class PrivateMethodTest extends TestCase
{
    /**
     * WARNING: Testing private methods is generally an anti-pattern.
     * Prefer testing through public API instead.
     */
    public function testPrivateMethodViaReflection(): void
    {
        $generator = new TokenGenerator();

        $method = new \ReflectionMethod(TokenGenerator::class, 'generateRandomBytes');
        // In PHP 8.1+, setAccessible is not needed (ignored)
        // but explicitly calling it is fine for clarity
        $method->setAccessible(true);

        $result = $method->invoke($generator, 32);

        self::assertSame(32, strlen($result));
    }
}

Лучший подход: если private-метод настолько сложен, что его нужно тестировать отдельно, вероятно, его стоит вынести в отдельный класс с публичным интерфейсом.

Code Coverage — покрытие кода

Code Coverage показывает, какие строки кода выполняются во время тестов.

Настройка

<!-- phpunit.xml -->
<phpunit>
    <source>
        <include>
            <directory>src</directory>
        </include>
        <exclude>
            <directory>src/DataFixtures</directory>
            <directory>src/Migrations</directory>
        </exclude>
    </source>

    <coverage>
        <report>
            <html outputDirectory="coverage"/>
            <clover outputFile="coverage/clover.xml"/>
            <text outputFile="php://stdout" showOnlySummary="true"/>
        </report>
    </coverage>
</phpunit>

Запуск с покрытием

# Generate HTML coverage report
vendor/bin/phpunit --coverage-html coverage/

# Text output (summary)
vendor/bin/phpunit --coverage-text

# Clover XML (for CI tools)
vendor/bin/phpunit --coverage-clover coverage/clover.xml

# Minimum coverage threshold (fails if below)
vendor/bin/phpunit --coverage-text --min=80

Атрибуты покрытия

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Service\Calculator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;

// PHPUnit 11+: use attributes instead of annotations
#[CoversClass(Calculator::class)]
final class CalculatorCoverageTest extends TestCase
{
    public function testAdd(): void
    {
        $calc = new Calculator();
        self::assertSame(5.0, $calc->add(2, 3));
    }
}

Типы покрытия

Метрика Описание Целевой %
Line Coverage Строки, выполненные тестами >80%
Branch Coverage Ветви кода (if/else) >70%
Path Coverage Полные пути через код >60%
Method Coverage Методы, вызванные из тестов >90%
Class Coverage Классы, затронутые тестами >95%

Database Testing

Тестирование с SQLite in-memory

<?php

declare(strict_types=1);

namespace App\Tests\Integration\Repository;

use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\DBAL\DriverManager;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\ORMSetup;
use Doctrine\ORM\Tools\SchemaTool;
use PHPUnit\Framework\TestCase;

final class UserRepositoryTest extends TestCase
{
    private EntityManager $em;
    private UserRepository $repository;

    protected function setUp(): void
    {
        // In-memory SQLite for fast tests
        $config = ORMSetup::createAttributeMetadataConfiguration(
            paths: [__DIR__ . '/../../../src/Entity'],
            isDevMode: true,
        );

        $connection = DriverManager::getConnection([
            'driver' => 'pdo_sqlite',
            'memory' => true,
        ], $config);

        $this->em = new EntityManager($connection, $config);

        // Create schema
        $schemaTool = new SchemaTool($this->em);
        $schemaTool->createSchema(
            $this->em->getMetadataFactory()->getAllMetadata(),
        );

        $this->repository = new UserRepository($this->em);
    }

    protected function tearDown(): void
    {
        $this->em->close();
    }

    public function testSaveAndFindUser(): void
    {
        $user = new User(name: 'Alice', email: '[email protected]');

        $this->repository->save($user);
        $this->em->flush();
        $this->em->clear();

        $found = $this->repository->findByEmail('[email protected]');

        self::assertNotNull($found);
        self::assertSame('Alice', $found->getName());
    }
}

Изоляция через транзакции

<?php

declare(strict_types=1);

namespace App\Tests\Integration;

use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;

abstract class DatabaseTestCase extends TestCase
{
    protected EntityManagerInterface $em;

    protected function setUp(): void
    {
        $this->em = self::getEntityManager();
        $this->em->beginTransaction();
    }

    protected function tearDown(): void
    {
        // Rollback all changes — database stays clean
        $this->em->rollback();
        $this->em->close();
    }

    abstract protected static function getEntityManager(): EntityManagerInterface;
}

TDD — Test-Driven Development

TDD — это методология, где тесты пишутся до кода. Цикл: Red -> Green -> Refactor.

Шаг 1: Red — напишите провальный тест

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Service\PasswordStrengthChecker;
use App\Enum\PasswordStrength;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class PasswordStrengthCheckerTest extends TestCase
{
    private PasswordStrengthChecker $checker;

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

    /**
     * @return iterable<string, array{string, PasswordStrength}>
     */
    public static function passwordProvider(): iterable
    {
        yield 'short password is weak' => ['abc', PasswordStrength::Weak];
        yield 'only lowercase is weak' => ['abcdefgh', PasswordStrength::Weak];
        yield 'lowercase + numbers is medium' => ['abcd1234', PasswordStrength::Medium];
        yield 'mixed case + numbers is medium' => ['Abcd1234', PasswordStrength::Medium];
        yield 'mixed + special is strong' => ['Abcd1234!@', PasswordStrength::Strong];
        yield 'long with all types is strong' => ['MyP@ssw0rd!2024', PasswordStrength::Strong];
    }

    #[DataProvider('passwordProvider')]
    public function testCheckStrength(string $password, PasswordStrength $expected): void
    {
        $result = $this->checker->check($password);

        self::assertSame($expected, $result);
    }
}

Шаг 2: Green — напишите минимальный код

<?php

declare(strict_types=1);

namespace App\Service;

use App\Enum\PasswordStrength;

final class PasswordStrengthChecker
{
    private const int MIN_STRONG_LENGTH = 10;
    private const int MIN_MEDIUM_LENGTH = 8;

    public function check(string $password): PasswordStrength
    {
        $length = mb_strlen($password);
        $hasUpper = (bool) preg_match('/[A-Z]/', $password);
        $hasLower = (bool) preg_match('/[a-z]/', $password);
        $hasDigit = (bool) preg_match('/\d/', $password);
        $hasSpecial = (bool) preg_match('/[^A-Za-z0-9]/', $password);

        $score = (int) $hasUpper + (int) $hasLower + (int) $hasDigit + (int) $hasSpecial;

        if ($length >= self::MIN_STRONG_LENGTH && $score >= 4) {
            return PasswordStrength::Strong;
        }

        if ($length >= self::MIN_MEDIUM_LENGTH && $score >= 2) {
            return PasswordStrength::Medium;
        }

        return PasswordStrength::Weak;
    }
}

Шаг 3: Refactor — улучшите код

На этом шаге тесты уже проходят. Можно безопасно рефакторить, зная что тесты поймают регрессии.

PHPUnit 11+ Features

Attributes вместо аннотаций

<?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\RequiresPhp;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;

#[CoversClass(MyClass::class)]
#[Group('unit')]
final class Phpunit11FeaturesTest extends TestCase
{
    #[Test]
    #[TestDox('User can be created with valid email')]
    public function userCreation(): void
    {
        self::assertTrue(true);
    }

    #[Test]
    #[RequiresPhp('>=8.4')]
    #[RequiresPhpExtension('intl')]
    public function featureRequiringPhp84(): void
    {
        self::assertTrue(true);
    }
}

Mockery — альтернативная библиотека мокирования

Mockery предоставляет более выразительный API для создания моков.

Установка

composer require --dev mockery/mockery

Базовое использование

<?php

declare(strict_types=1);

namespace App\Tests\Unit;

use App\Repository\UserRepositoryInterface;
use App\Entity\User;
use App\Service\UserService;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;

final class MockeryExampleTest extends TestCase
{
    // Integrates Mockery verification with PHPUnit
    use MockeryPHPUnitIntegration;

    public function testWithMockery(): void
    {
        // Create mock
        $repository = Mockery::mock(UserRepositoryInterface::class);

        // Configure expectations
        $repository
            ->shouldReceive('findByEmail')
            ->once()
            ->with('[email protected]')
            ->andReturn(new User(name: 'John', email: '[email protected]'));

        $repository
            ->shouldReceive('save')
            ->once()
            ->with(Mockery::type(User::class));

        $service = new UserService($repository);
        // ... test logic
    }

    public function testMockeryMatchers(): void
    {
        $logger = Mockery::mock(LoggerInterface::class);

        // Pattern matching
        $logger->shouldReceive('info')
            ->with(Mockery::pattern('/User .+ logged in/'))
            ->once();

        // Any argument
        $logger->shouldReceive('debug')
            ->with(Mockery::any())
            ->times(3);

        // Argument validation with closure
        $logger->shouldReceive('error')
            ->with(Mockery::on(function (string $message): bool {
                return str_starts_with($message, 'ERROR:');
            }))
            ->never();  // Should never be called
    }

    public function testMockerySpy(): void
    {
        $repository = Mockery::spy(UserRepositoryInterface::class);

        // Use the spy (no expectations set upfront)
        $repository->save(new User(name: 'John', email: '[email protected]'));
        $repository->save(new User(name: 'Jane', email: '[email protected]'));

        // Verify after the fact
        $repository->shouldHaveReceived('save')->twice();
        $repository->shouldNotHaveReceived('delete');
    }

    public function testPartialMockWithMockery(): void
    {
        // Only mock specific methods
        $service = Mockery::mock(ReportService::class)->makePartial();

        $service->shouldReceive('fetchExternalData')
            ->andReturn(['revenue' => 1000]);

        // All other methods are real
        $result = $service->generateReport();
        self::assertNotNull($result);
    }
}

Mockery vs PHPUnit Mocks

Функция PHPUnit Mockery
API стиль Методы-цепочки Fluent/выразительный
Spies Эмуляция через callback Встроенные
Partial mocks getMockBuilder() makePartial()
Argument matchers Ограниченные Богатые
Verification Во время теста После выполнения (spy)
Документация Официальная Обширная

Integration Testing vs Unit Testing

Unit Test — изолированный

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Service\OrderProcessor;
use App\Repository\OrderRepositoryInterface;
use App\Gateway\PaymentGatewayInterface;
use App\Entity\Order;
use PHPUnit\Framework\TestCase;

/**
 * Unit test: all dependencies are mocked.
 * Tests ONLY the OrderProcessor logic.
 */
final class OrderProcessorUnitTest extends TestCase
{
    public function testProcessOrder(): void
    {
        $repository = $this->createMock(OrderRepositoryInterface::class);
        $gateway = $this->createMock(PaymentGatewayInterface::class);

        $gateway->method('charge')->willReturn(true);
        $repository->expects(self::once())->method('save');

        $processor = new OrderProcessor($repository, $gateway);
        $order = new Order(amount: 99.99, currency: 'USD');

        $result = $processor->process($order);

        self::assertTrue($result->isSuccess());
    }
}

Integration Test — с реальными зависимостями

<?php

declare(strict_types=1);

namespace App\Tests\Integration\Service;

use App\Entity\Order;
use App\Service\OrderProcessor;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

/**
 * Integration test: uses real services from container.
 * Tests OrderProcessor with real database and (mock) gateway.
 */
final class OrderProcessorIntegrationTest extends KernelTestCase
{
    public function testProcessOrderSavesToDatabase(): void
    {
        self::bootKernel();
        $container = static::getContainer();

        /** @var OrderProcessor $processor */
        $processor = $container->get(OrderProcessor::class);

        $order = new Order(amount: 99.99, currency: 'USD');
        $result = $processor->process($order);

        self::assertTrue($result->isSuccess());

        // Verify in database
        $em = $container->get('doctrine.orm.entity_manager');
        $saved = $em->getRepository(Order::class)->find($order->getId());
        self::assertNotNull($saved);
        self::assertSame('processed', $saved->getStatus());
    }
}

Когда что использовать

Тип Скорость Изоляция Что тестирует Пример
Unit Быстро Полная Логику одного класса Calculator, Validator
Integration Средне Частичная Взаимодействие компонентов Repository + DB
Functional Медленно Нет Всю систему HTTP request → response

Тестирование событий

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Event\UserRegisteredEvent;
use App\Service\RegistrationService;
use App\Repository\UserRepositoryInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use PHPUnit\Framework\TestCase;

final class EventDispatchingTest extends TestCase
{
    public function testRegistrationDispatchesEvent(): void
    {
        $repository = $this->createStub(UserRepositoryInterface::class);
        $repository->method('findByEmail')->willReturn(null);

        $dispatcher = $this->createMock(EventDispatcherInterface::class);

        // Capture the dispatched event
        $capturedEvent = null;
        $dispatcher->expects(self::once())
            ->method('dispatch')
            ->willReturnCallback(function (object $event) use (&$capturedEvent): object {
                $capturedEvent = $event;
                return $event;
            });

        $service = new RegistrationService($repository, $dispatcher);
        $service->register('Alice', '[email protected]');

        self::assertInstanceOf(UserRegisteredEvent::class, $capturedEvent);
        self::assertSame('[email protected]', $capturedEvent->getEmail());
    }
}

Проверь себя

5 из 10

Какой порядок шагов в TDD?

Что делает expects(self::once()) на моке?

Какой тип теста самый быстрый?

Почему прямое тестирование private-методов считается антипаттерном?

Чем Stub отличается от Mock?