Jason McCreary, творець Laravel Shift, представив Double - бібліотеку для створення тестових дублів у PHP. Вона замінює традиційний вибір між моком, шпигуном та частковим моком єдиним типом об'єкта: ви створюєте дубль для класу чи інтерфейсу, а дієслова, які використовуєте далі, визначають його поведінку. Double вимагає PHP 8.3 і на момент написання має версію v0.4.0.
Ключові можливості
Бібліотека пропонує наступний функціонал:
- Один конструктор -
Double::for() приймає класи, інтерфейси, кілька інтерфейсів одночасно або реальний екземпляр
- Три режими - loose (за замовчуванням) повертає типобезпечні значення для неналаштованих викликів, strict викидає виняток, а passthru делегує виклики реальному об'єкту
- Два дієслова налаштування -
expects() для виклику, який обов'язково має статися, allows() для необов'язкового
- Перевірки у стилі шпигуна на кожному дублі -
received() дозволяє перевірити, що сталося, без попереднього оголошення шпигуна
- Матчери аргументів -
Argument::any(), type(), same(), matches(), contains(), capture(), not() та remaining()
- Повідомлення про помилки з логом викликів - невиконане очікування показує, з якими аргументами метод насправді викликався
- Інтеграція з PHPUnit - помилки звітуються як failures замість errors, успішні перевірки враховуються як assertions, а трейт автоматично верифікує кожен дубль
- Мапінг з Mockery - документація включає довідник методів для конвертації існуючого набору тестів
Порівняння з Mockery
Якщо ви переходите з Mockery, ось той самий тест, написаний обома способами. Спочатку в Mockery:
use Mockery;
$repository = Mockery::spy(BookRepository::class);
$repository->shouldReceive('find')->once()->with(123)->andReturn($book);
$service = new CatalogService($repository);
$service->lookup(123);
$repository->shouldHaveReceived('recordView')->with($book);
Mockery::close();
Той самий тест у Double:
use JMac\Testing\Double;
$repository = Double::for(BookRepository::class);
$repository->expects('find')->with(123)->returns($book);
$service = new CatalogService($repository);
$service->lookup(123);
$repository->received('recordView')->with($book);
Чотири зміни:
- Немає рішення
mock() чи spy() на початку, оскільки received() працює на будь-якому дублі
once() прибрано, бо це вже означає expects()
shouldReceive() та andReturn() стають expects() та returns(). Double зберігає одне дієслово на концепцію без аліасів
Mockery::close() не має аналога. Викличте $repository->verify() або додайте трейт VerifiesDoubles і дозвольте йому спрацювати автоматично
Вивід помилок також відрізняється. Якщо код викликає find('Baz') замість find('baz'), Mockery звітує:
Method find('baz') from Mockery_0_BookRepository should be called
exactly 1 times but called 0 times.
Double звітує:
Double `foo` expected `find('baz')` to be called exactly 1 time, but it was never called.
The following calls to `find` were made during this test: `find('Baz')`
Double називає клас, який ви дублювали, замість згенерованого Mockery_0_ ідентифікатора, а другий рядок перелічує, що метод насправді отримав.
Створення дубля та вибір режиму
Double::for() повертає реальний об'єкт, який задовольняє instanceof та будь-яку type hint, що очікує ціль:
use JMac\Testing\Double;
$repository = Double::for(BookRepository::class);
$service = new CatalogService($repository);
Передайте більше однієї цілі, і ви отримаєте єдиний дубль, що реалізує всі з них. Все після першого має бути інтерфейсом - те саме правило, що PHP застосовує до intersection types:
$logger = Double::for(LoggerInterface::class, FlushableInterface::class);
Кожен дубль має рівно один режим, який контролює, що станеться, коли виклик не відповідає нічому з налаштованого. Loose mode (за замовчуванням) повертає типобезпечне значення на основі оголошеного типу повернення методу: false для bool, 0 для int, [] для array, перший case enum'у, сам дубль для self. Для non-nullable типу класу чи інтерфейсу генерується свіжий дубль цього типу замість повернення null. Генерація працює лише на один рівень глибини.
Strict mode падає на першому неналаштованому виклику. Passthru делегує неналаштовані виклики реальному екземпляру і все ще записує кожен виклик:
$repository = Double::for(BookRepository::class)->strict();
$logger = Double::for(Logger::class)->passthru($realLogger);
Конфігурація живе на самому дублі, тому сім назв методів зарезервовано: expects, allows, strict, passthru, received, unused та verify. Дублювання класу, що оголошує один з них, негайно викидає виняток.
Очікування та зіставлення аргументів
Налаштування дубля читається зліва направо, і кожен модифікатор має значення за замовчуванням:
$repository->expects('find')->with(123)->returns($book);
$repository->allows('find')->with(999)->throws(new NotFoundException());
$repository->allows('calculateTax')->resolves(fn (...$args) => $gateway->calculateTax(...$args));
expects() означає, що виклик має статися рівно один раз, якщо не вказано інше. allows() означає, що він може статися будь-яку кількість разів, включаючи нуль.
Підрахунок викликів працює інакше, ніж у Mockery. Double направляє все через одне дієслово, використовуючи іменовані аргументи:
$repository->expects('save'); // once, за замовчуванням
$repository->expects('save')->times(2); // двічі
$repository->expects('save')->times(5); // рівно 5
$repository->expects('save')->times(1, 3); // між 1 та 3
$repository->expects('save')->times(minimum: 2); // щонайменше 2
$repository->allows('save')->times(maximum: 5); // щонайбільше 5
$repository->allows('save')->never(); // скорочення для times(0)
Ті самі підрахунки працюють на received() після факту:
$repository->received('save')->with($book)->times(2);
$repository->received('delete')->never();
Передача кількох значень у returns() будує чергу. Коли два очікування можуть відповідати одному виклику, перемагає останнє зареєстроване.
Просте значення в with() порівнює скаляри та масиви через ===, а об'єкти через ==. Все менш строге йде через фасад Argument:
use JMac\Testing\Matching\Argument;
$repository->allows('save')->with(Argument::type(Book::class))->returns(true);
$repository->allows('find')->with(Argument::any(1, 2, 3))->returns($book);
$repository->allows('find')->with(Argument::matches('/^\d+$/'))->returns($book);
$repository->allows('saveAll')->with(Argument::contains($book))->returns(true);
$repository->allows('combine')->with('-', Argument::remaining())->returns('a-b-c');
Argument::same() перевіряє ідентичність, де простий об'єкт лише перевіряє еквівалентність. Argument::capture($var) підходить до чогось завгодно і записує реальний аргумент у змінну для подальших assertions.
Коли порядок викликів є частиною контракту, позначте відповідні очікування ordered():
$connection->expects('open')->ordered();
$connection->expects('write')->ordered();
$connection->expects('close')->ordered();
Верифікація
verify() перевіряє кожне зареєстроване expects() і є еквівалентом Mockery::close(). received() йде в іншому напрямку, перевіряючи після факту, і доступний на кожному дублі незалежно від способу створення.
Також є unused(), який стверджує, що дубль не отримав жодних викликів до будь-якого методу:
Double `Logger` expected no calls at all, but received: `info('something happened')`.
Повідомлення про помилки
Кожне повідомлення про помилку називає дубль, називає виклик і вказує на наступний крок. Друкарська помилка в назві методу виявляється під час налаштування:
Can't configure `sav` on a double for `BookRepository`. That method
does not exist. Did you mean `save`?
У strict mode неналаштований виклик пропонує рядок allows(), який це виправить:
Double `foo` received an unexpected call to `bar(1, 2)`. Strict mode
requires every call to be configured. For example:
`$foo->allows('bar')->returns(...)`.
Інтеграція з PHPUnit
Нічого в бібліотеці не вимагає PHPUnit, але три речі змінюються, коли він встановлений. Помилки розширюють AssertionFailedError PHPUnit, тому невиконане очікування звітується як failure, а не error. Успішні верифікації реєструють реальний assertion. А трейт усуває ручний виклик verify():
use JMac\Testing\Integrations\PHPUnit\VerifiesDoubles;
class TestCase extends \PHPUnit\Framework\TestCase
{
use VerifiesDoubles;
}
Кожен дубль, створений під час тесту, і кожен assertion received(), зроблений на ньому, перевіряється, коли тест завершується. Це працює з PHPUnit 11 та 12.
Конвертація існуючого набору тестів
Документація включає мапінг метод-за-методом. Найпоширеніші рядки:
Mockery::mock(Foo::class) → Double::for(Foo::class)
Mockery::spy(Foo::class) → Double::for(Foo::class), потім received()
shouldIgnoreMissing() → за замовчуванням
shouldDeferMissing() → passthru($realInstance)
shouldReceive('foo')->once()->andReturn($x) → expects('foo')->returns($x)
shouldReceive('foo')->andReturn($x) → allows('foo')->returns($x)
andThrow($e) / andReturnUsing($fn) → throws($e) / resolves($fn)
shouldHaveReceived('foo') → received('foo')
Mockery::close() → verify() або трейт VerifiesDoubles
Таблиця матчерів має таку саму форму. Кілька функцій Mockery навмисно відсутні, включаючи аліаси, ducktype(), мокування статичних методів та byDefault(). Також доступний безкоштовний Double Converter, який автоматизує конвертацію з Mockery на Double.
Встановлення
Встановіть пакет як dev-залежність:
composer require --dev jasonmccreary/double
Немає service provider і конфігураційного файлу, тому бібліотека працює в будь-якому наборі тестів на PHPUnit або Pest, всередині Laravel чи ні. Щоб дублювати final клас, викличте Double::bypassFinals() як перший рядок вашого bootstrap-файлу PHPUnit.
Повна документація доступна на testdoublephp.com, а сам Double можна знайти на GitHub.