¿Cómo hacer una prueba unitaria de una función que se refactoriza al patrón de estrategia?

10

Si tengo una función en mi código que dice así:

class Employee{

    public string calculateTax(string name, int salary)
    {
        switch (name)
        {
            case "Chris":
                doSomething($salary);
            case "David":
                doSomethingDifferent($salary);
            case "Scott":
               doOtherThing($salary);               
       }
}

Normalmente refactorizaría esto para usar Ploymorphism usando un patrón de estrategia y clase de fábrica:

public string calculateTax(string name)
{
    InameHandler nameHandler = NameHandlerFactory::getHandler(name);
    nameHandler->calculateTax($salary);
}

Ahora, si estuviera usando TDD, tendría algunas pruebas que funcionan en el original calculateTax()antes de refactorizar.

ex:

calculateTax_givenChrisSalaryBelowThreshold_Expect111(){}    
calculateTax_givenChrisSalaryAboveThreshold_Expect111(){}

calculateTax_givenDavidSalaryBelowThreshold_Expect222(){}   
calculateTax_givenDavidSalaryAboveThreshold_Expect222(){} 

calculateTax_givenScottSalaryBelowThreshold_Expect333(){}
calculateTax_givenScottSalaryAboveThreshold_Expect333(){}

Después de refactorizar, tendré una clase Factory NameHandlerFactoryy al menos 3 implementaciones de InameHandler.

¿Cómo debo proceder para refactorizar mis pruebas? ¿Debo eliminar la prueba unitaria claculateTax()de EmployeeTestsy crear una clase de prueba para cada implementación de InameHandler?

¿Debería probar también la clase Factory?

Songo
fuente

Respuestas:

6

Las pruebas anteriores están bien para verificar que calculateTaxtodavía funciona como debería. Sin embargo, no necesita muchos casos de prueba para esto, solo 3 (o tal vez algunos más, si también desea probar el manejo de errores, utilizando valores inesperados de name).

Cada uno de los casos individuales (actualmente implementados en doSomethinget al.) También debe tener su propio conjunto de pruebas, que prueban los detalles internos y los casos especiales relacionados con cada implementación. En la nueva configuración, estas pruebas podrían / ​​deberían convertirse en pruebas directas en la clase de estrategia respectiva.

Prefiero eliminar las pruebas unitarias antiguas solo si el código que ejercen, y la funcionalidad que implementa, deja de existir por completo. De lo contrario, el conocimiento codificado en estas pruebas sigue siendo relevante, solo las pruebas deben ser refactorizadas.

Actualizar

Puede haber cierta duplicación entre las pruebas de calculateTax(llamémoslas pruebas de alto nivel ) y las pruebas para las estrategias de cálculo individuales ( pruebas de bajo nivel ); depende de su implementación.

Supongo que la implementación original de sus pruebas afirma el resultado del cálculo de impuestos específico, verificando implícitamente que la estrategia de cálculo específica se utilizó para producirlo. Si mantiene este esquema, tendrá duplicación de hecho. Sin embargo, como insinuó @Kristof, también puede implementar las pruebas de alto nivel utilizando simulacros, para verificar solo que se seleccionó e invocó el tipo correcto de estrategia (simulada) calculateTax. En este caso no habrá duplicación entre las pruebas de alto y bajo nivel.

Entonces, si refactorizar las pruebas afectadas no es demasiado costoso, preferiría el último enfoque. Sin embargo, en la vida real, cuando realizo una refactorización masiva, tolero una pequeña cantidad de duplicación del código de prueba si me ahorra suficiente tiempo :-)

¿Debería probar también la clase Factory?

De nuevo, depende. Tenga en cuenta que las pruebas de calculateTaxprueba efectivamente la fábrica. Entonces, si el código de fábrica es un switchbloque trivial como su código anterior, estas pruebas pueden ser todo lo que necesita. Pero si la fábrica hace algunas cosas más difíciles, es posible que desee dedicarle algunas pruebas específicamente. Todo se reduce a la cantidad de pruebas que necesita para estar seguro de que el código en cuestión realmente funciona. Si, al leer el código, o al analizar los datos de cobertura del código, ve rutas de ejecución no probadas, dedique algunas pruebas más para ejercitarlas. Luego repita esto hasta que esté completamente seguro de su código.

Péter Török
fuente
Modifiqué un poco el código para acercarlo a mi código práctico real. Ahora se agregó una segunda entrada salarya la función calculateTax(). De esta manera, creo que duplicaré el código de prueba para la función original y las 3 implementaciones de la clase de estrategia.
Songo
@Songo, mira mi actualización.
Péter Török
5

Comenzaré diciendo que no soy un experto en TDD o pruebas unitarias, pero así es como probaría esto (usaré un código similar a pseudo):

CalculateTaxDelegatesToNameHandler()
{
    INameHandlerFactory fakeNameHandlerFactory = Fake(INameHandlerFactory);
    INameHandler fakeNameHandler = Fake(INameHandler);

    A.Call.To(fakeNameHandlerFactory.getHandler("John")).Returns(fakeNameHandler);

    Employee employee = new Employee(fakeNameHandlerFactory);
    employee.CalculateTax("John");

    Assert.That.WasCalled(fakeNameHandler.calculateTax());
}

Por lo tanto, probaría que el calculateTax()método de clase de empleado solicita correctamente NameHandlerFactoryun a NameHandlery luego llama al calculateTax()método de devolución NameHandler.

Kristof Claes
fuente
hmmmm, ¿quieres decir que debería hacer la prueba una prueba de comportamiento en su lugar (prueba de que se llamaron ciertas funciones) y hacer las afirmaciones de valor en las clases delegadas?
Songo
Sí, eso es lo que haría. De hecho, escribiría pruebas separadas para NameHandlerFactory y NameHandler. Cuando los tenga, no hay razón para probar su funcionalidad nuevamente en el Employee.calculateTax()método. De esa manera, no necesita agregar pruebas de empleado adicionales cuando introduce un nuevo NameHandler.
Kristof Claes
3

Estás tomando una clase (empleado que hace todo) y estás formando 3 grupos de clases: la fábrica, el empleado (que solo contiene una estrategia) y las estrategias.

Entonces haga 3 grupos de pruebas:

  1. Probar la fábrica de forma aislada. ¿Maneja las entradas correctamente? ¿Qué sucede cuando pasas en un desconocido?
  2. Probar al empleado de forma aislada. ¿Puedes establecer una estrategia arbitraria y funciona como esperas? ¿Qué sucede si no hay una estrategia o un conjunto de fábrica? (si eso es posible en el código)
  3. Prueba las estrategias de forma aislada. ¿Cada uno realiza la estrategia que espera? ¿Manejan entradas de límite impares de manera consistente?

Por supuesto, puede realizar pruebas automatizadas para todo el shebang, pero ahora se parecen más a las pruebas de integración y deben tratarse como tales.

Telastyn
fuente
2

Antes de escribir cualquier código, comenzaría con una prueba para una Fábrica. Burlándome de las cosas que necesito, me obligaría a pensar en las implementaciones y los casos de uso.

Luego implementaría una Fábrica y continuaría con una prueba para cada implementación y finalmente las implementaciones en sí para esas pruebas.

Finalmente, eliminaría las viejas pruebas.

Patkos Csaba
fuente
2

Mi opinión es que no debe hacer nada, lo que significa que no debe agregar ninguna prueba nueva.

Insisto en que esta es una opinión, y en realidad depende de la forma en que percibes las expectativas del objeto. ¿Crees que al usuario de la clase le gustaría proporcionar una estrategia para el cálculo de impuestos? Si no le importa, entonces las pruebas deberían reflejar eso, y el comportamiento reflejado en las pruebas unitarias debería ser que no debería importarles que la clase haya comenzado a usar un objeto de estrategia para calcular el impuesto.

De hecho, me encontré con este problema varias veces al usar TDD. Creo que la razón principal es que un objeto de estrategia no es una dependencia natural, a diferencia de una dependencia de límite arquitectónico como un recurso externo (un archivo, una base de datos, un servicio remoto, etc.). Como no es una dependencia natural, generalmente no baso el comportamiento de mi clase en esta estrategia. Mi instinto es que solo debería cambiar mis exámenes si las expectativas de mi clase han cambiado.

Hay una gran publicación del tío Bob, que habla exactamente sobre este problema cuando se usa TDD.

Creo que la tendencia a probar cada clase por separado es lo que está matando a TDD. Toda la belleza de TDD es que usa pruebas para estimular esquemas de diseño y no al revés.

Rafi Goldfarb
fuente