Double es una nueva biblioteca de test doubles para PHP creada por Jason McCreary, el creador de Laravel Shift. Requiere PHP 8.3 y está en la versión v0.4.0. La idea central: en lugar de elegir de antemano entre un mock, un spy y un partial, se crea un único tipo de objeto con Double::for() y son los verbos que se llaman después los que definen su comportamiento. El artículo recorre la API, la compara con Mockery y explica cada decisión de diseño.

Double::for() acepta clases, interfaces, varias interfaces a la vez (todo lo posterior a la primera debe ser una interface, igual que la regla de PHP para los tipos de intersección) o una instancia real. El objeto devuelto satisface instanceof respecto a su objetivo y, por tanto, cualquier type hint. Cada double funciona en exactamente uno de tres modos: loose (el predeterminado) devuelve valores seguros por tipo para llamadas no configuradas — false para bool, 0 para int, [] para array, el primer caso de un enum, el propio double para self, y un double recién generado para tipos de retorno de clase o interface no anulables (la generación solo llega un nivel de profundidad, después se exige configuración explícita). Strict lanza una excepción en la primera llamada no configurada. Passthru delega las llamadas no configuradas a una instancia real y sigue registrándolo todo.

La configuración vive en el propio double, por lo que siete nombres de método están reservados: expects, allows, strict, passthru, received, unused y verify. Hacer un double de una clase que declare uno de ellos (relevante en Laravel, donde allows() forma parte del contrato Gate) lanza un error inmediato que nombra la colisión.

Los dos verbos de configuración son expects() — la llamada debe ocurrir exactamente una vez por defecto — y allows(), que permite cualquier número de llamadas, incluido cero. Los modificadores se leen de izquierda a derecha: with() para los argumentos, returns() (varios valores forman una cola que conserva el último), throws() y resolves() para closures. Todos los recuentos de llamadas pasan por times(): times(2), times(5), times(1, 3) para rangos, y times(minimum: 2) / times(maximum: 5) mediante argumentos nombrados; never() sigue siendo un método propio. Los mismos recuentos funcionan a posteriori sobre received(). Cuando dos expectativas pueden coincidir con la misma llamada, gana la registrada más recientemente, así que los valores amplios por defecto se escriben primero.

En comparación con un test de Mockery, cambian cuatro cosas: no hay decisión mock()/spy(), ya que received() funciona en cualquier double; once() desaparece porque expects() ya significa exactamente una llamada; shouldReceive()/andReturn() se convierten en expects()/returns() sin alias; y Mockery::close() se sustituye por $double->verify() o el trait automático VerifiesDoubles. La salida de fallos también mejora: Double nombra la clase duplicada en lugar de un identificador Mockery_0_ y muestra el registro de llamadas reales junto a una expectativa incumplida. El shouldNotHaveBeenCalled() de Mockery solo comprueba si el mock fue invocado como callable, mientras que unused() de Double afirma cero llamadas a cualquier método y enumera las que sí vio.

La coincidencia de argumentos en with() compara escalares y arrays con === y objetos con ==. Todo lo más flexible pasa por la fachada Argument: Argument::any(), type(), same() (identidad), matches() (regex), contains(), remaining() (cola variádica), capture($var) para capturar el argumento real en una variable, y not(), que devuelve un objeto con sus propios verbos para que las negaciones se lean con fluidez. Las expectativas pueden marcarse con ordered() para comprobaciones de orden de llamada, aplicadas por double; una llamada anticipada lanza un error inmediato que nombra ambos métodos. Los métodos estáticos no pueden configurarse: la biblioteca los rechaza de entrada con una explicación.

Para la verificación, verify() comprueba cada expects() registrado. Las cadenas received() se evalúan al terminar la sentencia, ya que la biblioteca no puede saber si vas a encadenar ->with() o ->never(). Los mensajes de fallo nombran el double, la llamada y un siguiente paso: un error tipográfico en el nombre del método se detecta al configurarlo, con sugerencia de «quizá quiso decir»; los fallos en modo strict muestran la línea allows() que lo resolvería (o el registro de llamadas si el método fue invocado en otra parte); y los doubles generados automáticamente en modo loose se identifican en sus propios mensajes.

Con PHPUnit instalado (versiones 11 y 12, detectado en tiempo de ejecución), los fallos extienden AssertionFailedError, de modo que una expectativa incumplida se reporta como failure en lugar de error, las verificaciones superadas cuentan como aserciones reales, y el trait VerifiesDoubles verifica automáticamente cada double creado durante un test. PHPUnit, sin embargo, no es obligatorio.

Para la migración, la documentación incluye una correspondencia método a método con Mockery: mock() se convierte en Double::for(), los spies en received(), shouldIgnoreMissing() es el comportamiento loose por defecto, shouldDeferMissing() se convierte en passthru($realInstance), shouldReceive('foo')->once()->andReturn($x) en expects('foo')->returns($x), y andThrow/andReturnUsing en throws()/resolves(). Los matchers se traducen uno a uno: Mockery::on() a Argument::satisfies(), isSame() a same(), andAnyOtherArgs() a remaining(). Algunas funciones de Mockery están ausentes deliberadamente — alias, ducktype(), mocking de métodos estáticos, orden globally() y byDefault() — cada una con una alternativa documentada. Una herramienta gratuita, Double Converter, en laravelshift.com automatiza la conversión.

La instalación se hace con composer require --dev jasonmccreary/double, sin service provider ni archivo de configuración: funciona en suites PHPUnit o Pest, dentro o fuera de Laravel. Para hacer double de clases finales, se llama a Double::bypassFinals() como primera línea del bootstrap de PHPUnit, antes del autoloader: la palabra clave final se reescribe antes de la compilación, pero una clase ya cargada en otro lugar sigue siendo rechazada. La documentación completa está en testdoublephp.com; el código está en GitHub bajo jasonmccreary/double.