Double — це нова PHP-бібліотека для створення тест-дублів від Джейсона Маккрірі, автора відомого сервісу Laravel Shift. Double пропонує універсальний підхід замість звичного вибору між mock, spy та partial. Ви просто створюєте дубль класу чи інтерфейсу, а його поведінка визначається методами, які ви викликаєте згодом. Бібліотека потребує PHP 8.3; на момент написання статті актуальна версія — v0.4.0.
Основні можливості пакету:
- Єдиний конструктор —
Double::for()приймає назви класів, інтерфейсів (можна декілька одночасно) або готовий екземпляр об'єкта. - Три режими роботи — loose (типовий) повертає безпечні значення для неналаштованих викликів, strict видає помилку, а passthru делегує виклик реальному об'єкту.
- Два основні методи налаштування —
expects()для обов’язкових викликів таallows()для опціональних. - Функції Spy для кожного дубля — метод
received()дозволяє перевірити історію викликів без попереднього оголошення об'єкта як spy. - Argument matchers — набір інструментів для перевірки аргументів:
Argument::any(),type(),same(),matches(),contains(),capture(),not()таremaining(). - Інформативні повідомлення про помилки — якщо очікування не справдилося, Double покаже журнал реальних викликів методу.
- Інтеграція з PHPUnit — невдалі перевірки відображаються як failures, а не errors. Успішні перевірки враховуються як assertions, а спеціальний trait автоматизує верифікацію всіх дублів.
- Мапінг із Mockery — документація містить детальну інструкцію з перенесення тестів із Mockery на Double.
# Порівняння: один і той самий тест у Mockery та Double
Ось як виглядає ідентичний тест у 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()вручну або додати traitVerifiesDoublesдля автоматичної перевірки.
Повідомлення про помилки також стали зрозумілішими. Якщо замість find('baz') код викличе find('Baz'), Mockery видасть стандартне повідомлення про невідповідність кількості викликів. 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 для цільового класу чи інтерфейсу:
use JMac\Testing\Double;
$repository = Double::for(BookRepository::class);
$service = new CatalogService($repository);
Ви можете передати кілька інтерфейсів одночасно — Double створить об'єкт, що реалізує їх усі (аналог intersection types у PHP):
$logger = Double::for(LoggerInterface::class, FlushableInterface::class);
Кожен дубль має один із трьох режимів роботи. **Loose** (типовий) повертає безпечні значення відповідно до типу: false для bool, 0 для int, порожній масив для array тощо. Якщо метод має повертати об'єкт іншого класу, Double автоматично згенерує для нього новий дубль (на один рівень вкладеності).
**Strict** видає помилку при першому ж незапланованому виклику. **Passthru** дозволяє неналаштованим методам виконувати реальну логіку об'єкта, зберігаючи запис про кожен виклик:
$repository = Double::for(BookRepository::class)->strict();
$logger = Double::for(Logger::class)->passthru($realLogger);
Оскільки конфігурація відбувається безпосередньо через об'єкт дубля, сім імен методів є зарезервованими: expects, allows, strict, passthru, received, unused та verify. Спроба створити дубль класу, що вже має такі методи, призведе до виключення. Це важливо для Laravel, оскільки метод allows() є частиною контракту Gate.
# Очікування та перевірка аргументів
Синтаксис Double легко читається зліва направо:
$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() робить його необов'язковим.
Кількість викликів у Double налаштовується значно зручніше, ніж у Mockery. Замість купи різних методів (once, twice, atLeast), Double використовує один метод times() з іменованими аргументами:
$repository->expects('save'); // один раз (типово)
$repository->expects('save')->times(2); // рівно два рази
$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(); // жодного разу
Ці ж самі правила можна застосовувати у методі received() для перевірки постфактум:
$repository->received('save')->with($book)->times(2);
Для гнучкої перевірки аргументів використовується фасад Argument:
use JMac\Testing\Matching\Argument;
$repository->allows('save')->with(Argument::type(Book::class))->returns(true);
$repository->allows('find')->with(Argument::matches('/^\d+$/'))->returns($book);
$repository->allows('saveAll')->with(Argument::contains($book))->returns(true);
Метод Argument::capture($var) дозволяє "захопити" аргумент у змінну для подальших перевірок. Також Double підтримує сувору черговість викликів за допомогою методу ordered().
# Верифікація
Метод verify() перевіряє виконання всіх expects(). Якщо ви хочете переконатися, що дубль взагалі не використовувався, скористайтеся unused():
Double `Logger` expected no calls at all, but received: `info('something happened')`.
# Повідомлення про помилки
Double допомагає виправити помилки ще на етапі налаштування. Якщо ви помилилися в назві методу, бібліотека підкаже правильний варіант:
Can't configure `sav` on a double for `BookRepository`. That method
does not exist. Did you mean `save`?
# Інтеграція з PHPUnit
Бібліотека автоматично інтегрується з PHPUnit:
- Помилки очікувань стають об'єктами
AssertionFailedError. - Перевірки
received()враховуються як assertions (тест не буде позначено як risky). - Trait
VerifiesDoublesавтоматизує викликverify()після кожного тесту.
# Встановлення
Пакет встановлюється через Composer:
composer require --dev jasonmccreary/double
Щоб працювати з final класами, додайте Double::bypassFinals() у файл завантаження (bootstrap) вашого тестового середовища перед підключенням автозавантажувача.
Докладна документація доступна на testdoublephp.com, а вихідний код — на GitHub.