Pruebas unitarias en tiempo real, o "cómo burlarse ahora"

9

Cuando trabajas en una función que depende del tiempo ... ¿Cómo organizas las pruebas unitarias? Cuando los escenarios de las pruebas unitarias dependen de la forma en que su programa interpreta "ahora", ¿cómo los configura?

Segunda edición: después de un par de días leyendo tu experiencia

Puedo ver que las técnicas para lidiar con esta situación generalmente giran en torno a uno de estos tres principios:

  • Agregue (código duro) una dependencia: agregue una pequeña capa sobre la función / objeto de tiempo, y siempre llame a su función de fecha y hora a través de esta capa. De esta manera, puede obtener el control del tiempo durante los casos de prueba.
  • Usa simulacros: tu código permanece exactamente igual. En sus pruebas, reemplaza el objeto de tiempo por un objeto de tiempo falso. A veces, la solución implica modificar el objeto de tiempo genuino que proporciona su lenguaje de programación.
  • Use la inyección de dependencia: cree su código para que cualquier referencia de tiempo se pase como parámetro. Luego, tiene el control de los parámetros durante las pruebas.

Las técnicas (o bibliotecas) específicas de un idioma son muy bienvenidas, y se mejorarán si aparece un código ilustrativo. Entonces, lo más interesante es cómo se pueden aplicar principios similares en cualquier plataforma. Y sí ... si puedo aplicarlo de inmediato en PHP, mejor que mejor;)

Comencemos con un ejemplo simple: una aplicación de reserva básica.

Digamos que tenemos API JSON y dos mensajes: un mensaje de solicitud y un mensaje de confirmación. Un escenario estándar es el siguiente:

  1. Usted hace una solicitud Obtienes una respuesta con un token. El sistema bloquea el recurso que se necesita para cumplir esa solicitud durante 5 minutos.
  2. Confirma una solicitud, identificada por el token. Si el token se emitió dentro de los 5 minutos, se aceptará (el recurso aún está disponible). Si han pasado más de 5 minutos, debe realizar una nueva solicitud (el recurso fue liberado. Debe verificar su disponibilidad nuevamente).

Aquí viene el escenario de prueba correspondiente:

  1. Hago una solicitud Confirmo (inmediatamente) con el token que recibí. Mi confirmación es aceptada

  2. Hago una solicitud Espero 3 minutos Confirmo con el token que recibí. Mi confirmación es aceptada

  3. Hago una solicitud Espero 6 minutos Confirmo con el token que recibí. Mi confirmación es rechazada.

¿Cómo podemos llegar a programar estas pruebas unitarias? ¿Qué arquitectura debemos usar para que estas funcionalidades sigan siendo comprobables?

Nota de edición : cuando disparamos una solicitud, el tiempo se almacena en una base de datos en un formato que pierde cualquier información sobre milisegundos.

EXTRA - pero quizás un poco detallado: Aquí vienen detalles sobre lo que descubrí haciendo mi "tarea":

  1. Construí mi función con una dependencia de una función de tiempo propia. Mi función VirtualDateTime tiene un método estático get_time () al que llamo donde solía llamar al nuevo DateTime (). Esta función de tiempo me permite simular y controlar qué hora es "ahora", de modo que pueda crear pruebas como: "Configurar ahora para el 21 de enero de 2014 16h15. Hacer una solicitud. Avanzar 3 minutos. Hacer la confirmación". Esto funciona bien, a costa de la dependencia, y "código no tan bonito".

  2. Una solución un poco más integrada sería construir una función myDateTime propia que extienda DateTime con funcionalidades adicionales de "tiempo virtual" (lo más importante, ahora configúrelo como quiero). Esto está haciendo que el código de la primera solución sea un poco más elegante (uso del nuevo myDateTime en lugar del nuevo DateTime), pero termina siendo muy similar: tengo que construir mi característica usando mi propia clase, creando así una dependencia.

  3. Pensé en hackear la función DateTime, para que funcione con mi dependencia cuando la necesito. Sin embargo, ¿hay alguna forma simple y ordenada de reemplazar una clase por otra? (Creo que obtuve una respuesta a eso: ver el espacio de nombres a continuación).

  4. En un entorno PHP, leí que "Runkit" puede permitirme hacer esto (piratear la función DateTime) dinámicamente. Lo que es bueno es que podría verificar que estoy corriendo en un entorno de prueba antes de modificar cualquier cosa sobre DateTime, y dejarlo intacto en producción [1] . Esto suena mucho más seguro y limpio que cualquier pirateo manual de DateTime.

  5. Inyección de dependencia de una función de reloj en cada clase que utiliza el tiempo [2] . ¿No es esto excesivo? Veo que en algunos entornos se vuelve muy útil [5] . Sin embargo, en este caso, no me gusta tanto.

  6. Eliminar la dependencia del tiempo en todas las funciones [2] . ¿Es esto siempre factible? (Ver más ejemplos a continuación)

  7. ¿Usando espacios de nombres [2] [3] ? Esto se ve bastante bien. Podría burlarme de DateTime con mi propia función DateTime que extiende \ DateTime ... Use DateTime :: setNow ("2014-01-21 16:15:00") y DateTime :: wait ("+ 3 minutos"). Esto cubre más o menos lo que necesito. ¿Qué pasa si usamos la función time ()? ¿O otras funciones de tiempo? Todavía tengo que evitar su uso en mi código original. O tendría que asegurarme de que cualquier función de tiempo PHP que utilizo en mi código se anule en mis pruebas ... ¿Hay alguna biblioteca disponible que haga exactamente esto?

  8. He estado buscando una manera de cambiar la hora del sistema solo por un hilo. Parece que no hay ninguno [4] . Es una pena: una función PHP "simple" para "Establecer el tiempo hasta el 21 de enero de 2014 16h15 para este hilo" sería una gran característica para este tipo de pruebas.

  9. Cambie la fecha y hora del sistema para la prueba, usando exec (). Esto puede funcionar si no tiene miedo de meterse con otras cosas en el servidor. Y debe retrasar la hora del sistema después de ejecutar su prueba. Puede hacer el truco en algunas situaciones, pero se siente bastante "hacky".

Esto me parece un problema muy estándar. Sin embargo, todavía extraño una forma simple y genérica de tratarlo. Tal vez me perdí algo? ¡Por favor comparte tu experiencia!

NOTA: Aquí hay otras situaciones en las que podemos tener necesidades de prueba similares.

  • Cualquier característica que funcione con algún tipo de tiempo de espera (por ejemplo, ¿juego de ajedrez?)

  • procesando una cola que desencadena eventos en un momento dado (en el escenario anterior podríamos seguir así: un día antes de que comience la reserva, quiero enviar un correo al usuario con todos los detalles. Escenario de prueba ...)

  • Desea configurar un entorno de prueba con datos recopilados en el pasado, le gustaría verlo como si fuera ahora. [1]

1 /programming/3271735/simulate-different-server-datetimes-in-php

2 /programming/4221480/how-to-change-current-time-for-unit-testing-date-functions-in-php

3 http://www.schmengler-se.de/en/2011/03/php-mocking-built-in-functions-like-time-in-unit-tests/

4 /programming/3923848/change-todays-date-and-time-in-php

5 Unidad que prueba el código con límite de tiempo

mika
fuente
1
En python (e imagino que otros lenguajes dinámicos tienen cosas similares) hay esta biblioteca genial: github.com/spulec/freezegun
Ismail Badawi
2
Una vez que te das cuenta de que ahora es solo un punto en el tiempo, esto se vuelve mucho más fácil.
Wyatt Barnett
En muchos casos, es más sencillo pasar un valor DateTime nowal código en lugar de un reloj.
CodesInChaos
@IsmailBadawi: esto se ve realmente genial. De esta manera, no tiene que alterar la forma en que codifica de ninguna manera: simplemente configura sus pruebas usando la biblioteca, ¿no es así? ¿Cómo se vería con nuestro ejemplo de reserva?
mika
@WyattBarnett Claro. El momento en que pierdo el control de este momento es cuando uso el nuevo DateTime () sin parámetro. ¿Considera que es un mal hábito usar esto en una clase por completo?
mika

Respuestas:

8

Voy a usar un pseudocódigo similar a Python basado en sus casos de prueba para (con suerte) ayudar a explicar un poco esta respuesta, en lugar de solo exponer. En resumen: esta respuesta es básicamente un ejemplo de inyección de dependencia / inversión de dependencia de nivel de función única pequeña , en lugar de aplicar el principio a una clase / objeto completo.

En este momento, parece que sus pruebas están estructuradas de la siguiente manera:

def test_3_minute_confirm():
    req = make_request()
    sleep(3 * 60)  # Baaaad
    assertTrue(confirm_request(req))

Con implementaciones algo similar a:

def make_request():
    return { 'request_time': datetime.now(), }

def confirm_request(req):
    return req['request_time'] >= (datetime.now() - timedelta(minutes=5))

En su lugar, intente olvidarse de "ahora" y diga a ambas funciones qué hora usar. Si la mayoría de las veces va a ser "ahora", puede hacer una de dos cosas principales para mantener el código de llamada simple:

  • Haga que el tiempo sea un argumento opcional que por defecto sea "ahora". En Python (y PHP, ahora que releí la pregunta) esto es simple: "tiempo" predeterminado None( nullen PHP) y establecerlo en "ahora" si no se dio ningún valor.
  • En otros lenguajes sin argumentos predeterminados (o en PHP si se vuelve difícil de manejar), podría ser más fácil extraer las tripas de las funciones en un ayudante que sí se pruebe.

Por ejemplo, las dos funciones anteriores reescritas en la segunda versión podrían verse así:

def make_request():
    return make_request_at(datetime.now())

def make_request_at(when):
    return { 'request_time': when, }

def confirm_request(req):
    return confirm_request_at(req, datetime.now())

def confirm_request_at(req, check_time):
    return req['request_time'] >= (check_time - timedelta(minutes=5))

De esta manera, las partes existentes del código no necesitan modificarse para pasar "ahora", mientras que las partes que realmente desea probar se pueden probar mucho más fácilmente:

def test_3_minute_confirm():
    current_time = datetime.now()
    later_time = current_time + timedelta(minutes=3)

    req = make_request_at(current_time)
    assertTrue(confirm_request_at(req, later_time))

También crea flexibilidad adicional en el futuro, por lo que es necesario cambiar menos si es necesario reutilizar las partes importantes. Para dos ejemplos que aparecen en la mente: una cola en la que las solicitudes deben crearse momentos demasiado tarde pero aún usan la hora correcta, o las confirmaciones tienen que suceder a las solicitudes pasadas como parte de las comprobaciones de integridad de datos.

Técnicamente, pensar de esta manera podría violar a YAGNI , pero en realidad no estamos construyendo para esos casos teóricos: este cambio sería solo para una prueba más fácil, que simplemente respalda esos casos teóricos.


¿Hay alguna razón por la que recomendaría una de estas soluciones sobre otra?

Personalmente, trato de evitar cualquier tipo de burla tanto como sea posible, y prefiero las funciones puras como las anteriores. De esta forma, el código que se está probando es idéntico al código que se ejecuta fuera de las pruebas, en lugar de reemplazar las partes importantes con simulacros.

Hemos tenido una o dos regresiones que nadie notó durante un buen mes porque esa parte particular del código no se probó a menudo en el desarrollo (no estábamos trabajando activamente en él), se lanzó ligeramente roto (por lo que no hubo errores informes), y los simulacros utilizados estaban ocultando un error en la interacción entre las funciones de ayuda. Algunas modificaciones leves, por lo que no fueron necesarias simulacros y se pudieron probar muchas más, incluida la corrección de las pruebas en torno a esa regresión.

Cambie la fecha y hora del sistema para la prueba, usando exec (). Esto puede funcionar si no tiene miedo de meterse con otras cosas en el servidor.

Este es el que debe evitarse a toda costa para las pruebas unitarias. Realmente no hay forma de saber qué tipo de inconsistencias o condiciones de carrera podrían introducirse para causar fallas intermitentes (para aplicaciones no relacionadas o compañeros de trabajo en el mismo cuadro de desarrollo), y una prueba fallida / errónea podría no retrasar el reloj correctamente.

Izkata
fuente
Muy buen caso para la inyección de dependencia. Gracias por los detalles, realmente ayuda a obtener una imagen completa.
mika
8

Para mi dinero, mantener una dependencia en alguna clase de reloj es la forma más clara de manejar las pruebas dependientes del tiempo. En PHP, puede ser tan simple como una clase con una sola función nowque devuelve la hora actual usando time()o new DateTime().

Inyectar esta dependencia le permite manipular el tiempo en sus pruebas y usar el tiempo real en producción, sin tener que escribir demasiado código adicional.

Andy Hunt
fuente
Esto es lo que se está ejecutando actualmente en mi caja :) Y lo ha estado haciendo bastante bien. Por supuesto, tienes que escribir código pensando en tu clase extra, este es el costo.
mika
1
@Mika: Sin embargo, los gastos generales son mínimos, por lo que no es tan malo. Sin embargo, diré que mi preferencia real es pasar las fechas requeridas \ times \ datetimes como parámetros a un método.
Andy Hunt
3

Lo primero que debe hacer es eliminar los números mágicos de su código. En este caso, su número mágico de "5 minutos". No solo inevitablemente tendrá que cambiarlos más tarde cuando algún cliente lo solicite, sino que también mejorará las pruebas unitarias. ¿Quién va a esperar 5 minutos para que se ejecute su prueba?

En segundo lugar, si aún no lo ha hecho, use UTC para las marcas de tiempo reales. Esto evitará problemas con el horario de verano y la mayoría de los otros problemas de reloj.

En la prueba de la unidad, cambie la duración configurada a algo así como 500 ms y haga su prueba normal: ¿funciona de una manera antes del tiempo de espera y de otra manera después?

~ 1s todavía es bastante lento para las pruebas unitarias, y es probable que necesite algún tipo de umbral para tener en cuenta el rendimiento de la máquina y los cambios de reloj debido a NTP (u otra deriva). Pero en mi experiencia, esta es la mejor manera práctica de unir las cosas basadas en el tiempo de prueba en la mayoría de los sistemas. Simple, rápido, confiable. La mayoría de los otros enfoques (como ha encontrado) requieren toneladas de trabajo y / o son terriblemente propensos a errores.

Telastyn
fuente
Este es un enfoque interesante. "5" está aquí por el bien del ejemplo, siempre me aseguro de poder cambiarlo en un archivo de configuración :)
mika
@mika - Personalmente, tiendo a odiar los archivos. No juegan bien con las pruebas unitarias (que deberían ser muy concurrentes).
Telastyn
Yo tiendo a usar yaml extensamente ...
Mika
3

Cuando aprendí TDD por primera vez, estaba trabajando en un entorno . La forma en que aprendí a hacerlo es tener un entorno de IoC completo, que también incluía un ITimeService, que era responsable de los servicios de todos los tiempos, y que fácilmente se podía burlar en las pruebas.

Hoy en día estoy trabajando en , y el tiempo de apriete es mucho más fácil: simplemente se desconecta :

describe 'some time based module' do

  before(:each) do
    @now = Time.now
    allow(Time).to receive(:now) { @now }
  end

  it 'times out after five minutes' do
    subject.do_something

    @now += 5.minutes

    expect { subject.do_another_thing }.to raise TimeoutError
  end
end
Uri Agassi
fuente
1
¡Ah, magia rubí! Simplemente encantador ... Sin embargo, tendría que volver a mi libro para recoger completamente el código;) Los trozos resultan ser muy elegantes en muchos casos.
mika
3

Use Microsoft Fakes : en lugar de alterar su código para adaptarlo a la herramienta, la herramienta altera su código en tiempo de ejecución e inyecta un nuevo objeto Time (que usted define en su prueba) en lugar del reloj estándar.

Si está ejecutando un lenguaje dinámico, entonces Runkit es exactamente el mismo tipo de cosas.

Por ejemplo con falsificaciones:

[TestMethod]
public void MyDateProvider_ShouldReturnDateAsJanFirst1970()
{
    using (Microsoft.QualityTools.Testing.Fakes.ShimsContext.Create())
    {
        // Arrange: set up a shim to our own date object instead of System.DateTime.Now
        System.Fakes.ShimDateTime.NowGet = () => new DateTime(1970, 1, 1);

        //Act: call the method under test
        var curentDate = MyDateTimeProvider.GetTheCurrentDate();

        //Assert: that we have a moustache and a brown suit.
        Assert.IsTrue(curentDate == new DateTime(1970, 1, 1));
    }
}

public class MyDateTimeProvider
{
    public static DateTime GetTheCurrentDate()
    {
        return DateTime.Now;
    }
}

Entonces, cuando el método de texto lo llame return DateTime.Now, devolverá el tiempo que inyectó, en lugar del tiempo actual real.

gbjbaanb
fuente
Falsificaciones, Runkit ... Al final, cada idioma tiene su manera de construir trozos. ¿Podría publicar un breve ejemplo para que podamos tener una idea de cómo va?
mika
3

Exploré la opción de espacios de nombres en PHP. Los espacios de nombres nos dan la oportunidad de agregar algunos comportamientos de prueba a la función DateTime. Por lo tanto, nuestros objetos originales permanecen intactos.

Primero, configuramos un objeto DateTime falso en nuestro espacio de nombres para que podamos, en nuestras pruebas, administrar a qué hora se considera "ahora".

Luego, en nuestras pruebas, podemos administrar este tiempo a través de las funciones estáticas DateTime :: set_now ($ my_time) y DateTime :: modify_now ("agregar algo de tiempo").

Primero viene un objeto de reserva falso . Este objeto es lo que queremos probar: observe el uso de la nueva fecha y hora () sin parámetros en dos lugares. Una breve descripción:

<?php

namespace BookingNamespace;
/**
* Simulate expiration process with two public methods:
*      - book will return a token
*      - confirm will take a token, and return
*                       -> true if called within 5 minutes of corresponding book call
*                       -> false if more than 5 minutes have passed
*/
class MyBookingObject
{
    ...

    private function init_token($token){ $this->tokens[$token] = new DateTime(); }

    ...

    private function has_timed_out(DateTime $booking_time)
    {
            $now = new DateTime();
            ....
    }
}

Nuestro objeto DateTime que anula el objeto DateTime principal tiene este aspecto . Todas las llamadas a funciones permanecen sin cambios. Solo "enganchamos" al constructor para simular nuestro comportamiento "ahora es cuando elijo". Más importante,

namespace BookingNamespace;

/**
 * extend DateTime functionalities with static functions so that we are able 
 * to define what should be considered to be "now"
 *   - set_now allows to define "now"
 *   - modify_now applies the modify function on what is defined as "now"
 *   - reset is for going back to original DateTime behaviour
 *  
 * @author mika
 */
 class DateTime extends \DateTime
 {
     private static $fake_time=null;

     ...

     public function __construct($time = "now", DateTimeZone $timezone = null)
     {
          if($this->is_virtual()&&$this->is_relative_date($time))
          {
           ....
          }
          else
            parent::__construct($time, $timezone);
     }
 }

Nuestras pruebas son entonces muy sencillas. Va así con la unidad php (versión completa aquí ):

....
require_once "DateTime.class.php"; // Override DateTime in current namespace
....

class MyBookingObjectTest extends \PHPUnit_Framework_TestCase
{
    ....

    /**
     * Create test subject before test
     */
    protected function setUp()
    {
        ....
        DateTime::set_now("2014-04-02 16:15");
        ....
    }

   ...

    public function testBookAndConfirmThreeMinutesLater()
    {
        $token = $this->booking_object->book();
        DateTime::modify_now("+3 minutes");
        $this->assertTrue($this->booking_object->confirm($token));
    }

    ...
}

Esta estructura se puede reproducir fácilmente donde sea que necesite administrar "ahora" manualmente: solo necesito incluir el nuevo objeto DateTime y usar sus métodos estáticos en mis pruebas.

mika
fuente
Establecer una instancia de DateTime simulada para probar la clase de destino debería ser un mejor plan, pero necesita un pequeño cambio en la clase de destino: poner new Datetimeen un método separado para ser imitable.
Fwolf