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());
}
}