it-swarm-es.com

En TDD, ¿debo agregar pruebas unitarias al código refactorizado?

Mientras refactorizo ​​mi código usando Test Driven Development (TDD), ¿debo seguir haciendo nuevos casos de prueba para el nuevo código refactorizado que estoy escribiendo?

Esta pregunta se basa en los siguientes pasos de TDD:

  1. Escriba lo suficiente de una prueba para que el código falle
  2. Escriba el código suficiente para que la prueba pase
  3. Refactor

Mi duda está en el paso refactor. ¿Deberían escribirse nuevos casos de prueba unitaria para código refactorizado?

Para ilustrar eso, daré un ejemplo simplificado:


Supongamos que estoy haciendo un RPG y estoy haciendo un sistema HPContainer que debería hacer lo siguiente:

  • Permitir que el jugador pierda HP.
  • HP no debe ir por debajo de cero.

Para responder eso, escribo las siguientes pruebas:

[Test]
public void LoseHP_LosesHP_DecreasesCurrentHPByThatAmount()
{
    int initialHP = 100;
    HPContainer hpContainer= new HPContainer(initialHP);
    hpContainer.Lose(5)
    int currentHP = hpContainer.Current();
    Assert.AreEqual(95, currentHP);
}
[Test]
public void LoseHP_LosesMoreThanCurrentHP_CurrentHPIsZero()
{
    int initialHP = 100;
    HPContainer hpContainer= new HPContainer(initialHP);
    hpContainer.Lose(200)
    int currentHP = hpContainer.Current();
    Assert.AreEqual(0, currentHP);
}

Para satisfacer los requisitos, implemento el siguiente código:

public class HPContainer
{
    private int currentHP = 0;

    public void HPContainer(int initialHP)
    {
        this.currentHP = initialHP; 
    }

    public int Current()
    {
        return this.currentHP;
    }

    public void Lose(int value)
    {
        this.currentHP -= value;
        if (this.currentHP < 0)
            this.currentHP = 0;
    }
}

¡Bueno!

Las pruebas están pasando.

¡Hicimos nuestro trabajo!


Ahora digamos que el código crece y quiero refactorizar ese código, y decido que agregar una clase Clamper de la siguiente manera es una buena solución.

public static class Clamper
{
    public static int ClampToNonNegative(int value)
    {
        if(value < 0)
            return 0;
        return value;
    }
}

Y como resultado, cambiando la clase HPContainer:

public class HPContainer
{
    private int currentHP = 0;

    public void HPContainer(int initialHP)
    {
        this.currentHP = initialHP; 
    }

    public int Current()
    {
        return this.currentHP;
    }

    public void Lose(int value)
    {
        this.currentHP = Clamper.ClampToNonNegative(this.currentHP - value);
    }
}

Las pruebas aún pasan, por lo que estamos seguros de que no introdujimos una regresión en nuestro código.

Pero mi pregunta es:

¿Deben agregarse pruebas unitarias a la clase Clamper?


Veo dos argumentos opuestos:

  1. Sí, las pruebas deben agregarse porque necesitamos cubrir Clamper de la regresión. Asegurará que si Clamper alguna vez necesita ser cambiado, podemos hacerlo de manera segura con cobertura de prueba.

  2. No, Clamper no forma parte de la lógica empresarial y ya está cubierto por los casos de prueba de HPContainer. Agregarle pruebas solo hará un desorden innecesario y retrasará la futura refactorización.

¿Cuál es el razonamiento correcto, siguiendo los principios y buenas prácticas de TDD?

35
Albuquerque

Prueba antes y después

En TDD, ¿debo agregar pruebas unitarias al código refactorizado?

"código refactorizado" implica que está agregando las pruebas ¡después que ha refactorizado. A este le falta el punto de probar sus cambios. TDD se basa en gran medida en las pruebas antes y después de la implementación/refactorización/fijación del código.

  • Si puede probar que los resultados de la prueba unitaria son los mismos antes y después de la refactorización, ha demostrado que la refactorización no cambió el comportamiento.
  • Si sus pruebas pasaron de fallar (antes) a pasar (después), ha demostrado que sus implementaciones/soluciones han resuelto el problema en cuestión.

No deberías agregar tus pruebas unitarias ¡después refactorizas, sino ¡antes (suponiendo que estas pruebas estén garantizadas, por supuesto).


Refactorizar significa comportamiento sin cambios

¿Deberían escribirse nuevos casos de prueba unitaria para código refactorizado?

El mismo definición de refactorización es cambiar el código sin cambiar su comportamiento.

La refactorización es una técnica disciplinada para reestructurar un cuerpo de código existente, alterando su estructura interna sin cambiar su comportamiento externo .

Como las pruebas unitarias se escriben específicamente para probar el comportamiento, no tiene sentido que requiera pruebas unitarias adicionales después de refactorizar.

  • Si estas nuevas pruebas son relevantes, entonces ya lo eran antes de la refactorización.
  • Si estas nuevas pruebas no son relevantes, entonces obviamente no son necesarias.
  • Si estas nuevas pruebas no fueron relevantes, pero lo son ahora, entonces su refactorización debe haber cambiado invariablemente el comportamiento, lo que significa que ha hecho más que simplemente refactorizar.

La refactorización nunca puede llevar inherentemente a la necesidad de pruebas unitarias adicionales que antes no se necesitaban.


Agregar pruebas debe suceder algunas veces

Dicho esto, si hubo pruebas que debería haber tenido desde el principio pero las había olvidado hasta ahora, por supuesto, puede agregarlas. No tome mi respuesta en el sentido de que no puede agregar pruebas solo porque se había olvidado de escribirlas antes.

Del mismo modo, a veces se olvida de cubrir un caso y solo se hace evidente después de haber encontrado un error. Es una buena práctica escribir una nueva prueba que ahora verifica este caso problemático.


Unidad probando otras cosas

¿Deben agregarse pruebas unitarias a la clase Clamper?

Me parece que Clamper debería ser una clase internal, ya que es una dependencia oculta de su HPContainer. El consumidor de su clase HPContainer no sabe que Clamper existe y no necesita saberlo.

Las pruebas unitarias solo se centran en el comportamiento externo (público) de los consumidores. Como Clamper debería ser internal, no requiere pruebas unitarias.

Si Clamper está en otra Asamblea por completo, entonces necesita pruebas de unidad ya que es público. Pero su pregunta no deja claro si esto es relevante.

Sidenote
No voy a entrar en un sermón completo de IoC aquí. Algunas dependencias ocultas son aceptables cuando son puras (es decir, sin estado) y no necesitan burlarse de ellas, p. Ej. nadie está realmente exigiendo que se inyecte la clase Math de .NET, y su Clamper no es funcionalmente diferente de Math.
Estoy seguro de que otros estarán en desacuerdo y adoptarán el enfoque de "inyectar todo". No estoy en desacuerdo de que se puede hacer, pero no es el foco de esta respuesta, ya que no es pertinente para la pregunta publicada, en mi opinión.


¿Reprimición?

Para empezar, no creo que sea necesario el método de sujeción.

public static int ClampToNonNegative(int value)
{
    if(value < 0)
        return 0;
    return value;
}

Lo que ha escrito aquí es una versión más limitada del método Math.Max() existente. Cada uso:

this.currentHP = Clamper.ClampToNonNegative(this.currentHP - value);

puede ser reemplazado por Math.Max:

this.currentHP = Math.Max(this.currentHP - value, 0);

Si su método no es más que un contenedor alrededor de un único método existente, no tiene sentido tenerlo.

50
Flater

Esto podría verse como dos pasos:

  • primero creará una nueva clase pública Clamper (sin cambiar HPContainer). Esto en realidad no es una refactorización, y cuando se aplica TDD estrictamente, siguiendo literalmente el nano-ciclos de TDD , ni siquiera se le permitiría escribir la primera línea de código para esta clase antes de escribir al menos una unidad de prueba para ello.

  • luego comienza a refactorizar la HPContainer utilizando la clase Clamper. Suponiendo que las pruebas unitarias existentes para esta clase ya brindan cobertura suficiente, no es necesario agregar más pruebas unitarias durante este paso.

Entonces ¡sí, si crea un componente reutilizable con la intención de usarlo para una refactorización en el futuro cercano, debe agregar pruebas unitarias para el componente. Y ¡no, durante la refactorización generalmente no agrega más pruebas unitarias.

Un caso diferente es cuando Clamper todavía se mantiene privado/interno, no está destinado para su reutilización. Entonces, toda la extracción puede verse como un paso de refactorización, y agregar nuevas pruebas unitarias no necesariamente trae ningún beneficio. Sin embargo, para estos casos, también tomaría en consideración cuán complejos son los componentes: si los dos componentes son tan complejos que la causa raíz de una prueba fallida que prueba ambas puede ser difícil de detectar, entonces puede ser una buena idea proporcione pruebas unitarias individuales para ambos: un conjunto de pruebas que evalúa Clamper por sí solo, y una prueba HPContainer con un simulacro inyectado para Clamper.

21
Doc Brown

Clamper es su propia unidad, y las unidades deben probarse con las pruebas de Unidad, ya que las unidades se pueden usar en otros lugares. Lo cual es genial si Clamper también lo está ayudando a implementar ManaContainer, FoodContainer, DamageCalculator, etc.

Si Clamper fuera solo un detalle de implementación, entonces no se puede probar directamente. Esto se debe a que no podemos acceder a él como una unidad para probarlo.

Su primer ejemplo trata la verificación como un detalle de implementación, razón por la cual no escribió una prueba para verificar que la instrucción if funciona de forma aislada. Como detalle de implementación, la única forma de probarlo es probar el comportamiento observable de la unidad de la cual es un detalle de implementación (en este caso, el comportamiento de HPContainer centrado alrededor de Lose(...)) .

Para mantener la refactorización, pero deje un detalle de implementación:

public class HPContainer
{
    private int currentHP = 0;

    public void HPContainer(int initialHP)
    {
        this.currentHP = initialHP; 
    }

    public int Current()
    {
        return this.currentHP;
    }

    public void Lose(int value)
    {
        this.currentHP = ClampToNonNegative(this.currentHP - value);
    }

    private static int ClampToNonNegative(int value)
    {
        if(value < 0)
            return 0;
        return value;
    }
}

Le da la expresividad, pero deja la decisión de presentar una nueva unidad para más adelante. Esperemos que cuando tenga varias instancias de duplicación de las cuales pueda generalizar razonablemente una solución reutilizable. En este momento (su segundo ejemplo) supone que será necesario.

4
Kain0_0

No, no escriba pruebas para la clase Clamper,
porque ya se ha probado mediante pruebas para la clase HPContainer.

Si escribe la solución más simple y rápida posible para que las pruebas pasen, terminará con una gran clase/función que hace todo.

Cuando comience a refactorizar, porque ahora puede ver una imagen completa de la implementación, podrá reconocer duplicaciones o algunos patrones en la lógica.
Durante la refactorización, elimina la duplicación extrayendo duplicaciones a métodos o clases dedicados.

Si decide pasar las clases recién introducidas a través del constructor, deberá cambiar solo un lugar en las pruebas donde configura la clase bajo la prueba para pasar nuevas dependencias. Esto debería ser solo el cambio del código de prueba "permitido" durante la refactorización.

Si escribe pruebas para las clases introducidas durante la refactorización, terminará en un ciclo "infinito".
No podrá "jugar" con diferentes implementaciones, porque se "obligó" a escribir pruebas para nuevas clases, lo cual es una tontería, porque estas clases ya se prueban a través de pruebas para la clase principal.

En la mayoría de los casos, la refactorización es extraer una lógica duplicada o complicada de una manera más legible y estructurada.

2
Fabio

¿Deben agregarse pruebas unitarias a la clase Clamper?

Aún no.

El objetivo es un código limpio que funcione. Los rituales que no contribuyen a este objetivo son un desperdicio.

Me pagan por el código que funciona, no por las pruebas, por lo que mi filosofía es probar lo menos posible para alcanzar un determinado nivel de confianza - Kent Beck, 2008

Su refactorización es un detalle de implementación; El comportamiento externo del sistema bajo prueba no ha cambiado en absoluto. Escribir una nueva colección de pruebas para este detalle de implementación no mejorará su confianza en absoluto.

Trasladar la implementación a una nueva función, o una nueva clase, o un nuevo archivo: hacemos estas cosas por varias razones no relacionadas con el comportamiento del código. Todavía no necesitamos presentar un nuevo conjunto de pruebas. Estos son cambios en la estructura, no en el comportamiento.

Las pruebas del programador deben ser sensibles a los cambios de comportamiento e insensibles a los cambios de estructura. - Kent Beck, 2019

El punto en el que comenzamos a pensar en el cambio es cuando estamos interesados ​​en cambiar el comportamiento de Clamper, y la ceremonia extra de crear un HPContainer comienza a interferir.

Querías un plátano pero lo que obtuviste fue un gorila sosteniendo el plátano y toda la jungla. - Joe Armstrong

Estamos tratando de evitar la situación en la que nuestras pruebas (que sirven como documentación del comportamiento esperado de algún módulo en nuestra solución) están contaminadas con un montón de detalles irrelevantes. Probablemente haya visto ejemplos de pruebas que crean algún sujeto de prueba con un montón de objetos nulos porque las implementaciones reales no son necesarias para el caso de uso actual, pero no puede invocar el código sin ellas.

Sin embargo, para refactorizaciones puramente estructurales, no, no necesita comenzar a introducir nuevas pruebas.

2
VoiceOfUnreason

Personalmente, creo firmemente en probar solo contra interfaces estables (ya sean externas o internas) que no se verán afectadas por la refactorización. No me gusta crear pruebas que inhiban la refactorización (he visto casos en los que las personas no pudieron implementar una refactorización porque rompería demasiadas pruebas). Si un componente o subsistema tiene un contrato con otros componentes o subsistemas que entregará una interfaz particular, entonces pruebe esa interfaz; Si una interfaz es puramente interna, entonces no la pruebe, o deseche sus pruebas una vez que hayan hecho su trabajo.

1
Michael Kay

Las pruebas unitarias son las que le aseguran que su esfuerzo de refactorización no introdujo errores.

Entonces, escribe pruebas unitarias y se asegura de que pasen sin cambiar el código existente.

Luego refactoriza, asegurándose de que las pruebas unitarias no fallen mientras lo hace.

Así es como tienes cierto nivel de certeza de que tu refactorización no rompió las cosas. Por supuesto, eso solo es cierto si las pruebas unitarias son correctas y cubren todas las rutas de código posibles en el código original. Si pierde algo en las pruebas, aún corre el riesgo de refactorizar las cosas rotas.

0
jwenting

Así es como generalmente me gusta estructurar y pensar sobre mis pruebas y código. El código debe organizarse en carpetas, las carpetas pueden tener subcarpetas subdividiéndolas aún más, y las carpetas que son hojas (no tienen subcarpetas) se llaman archivos. Las pruebas también deben organizarse en una jerarquía correspondiente que refleje la jerarquía del código principal.

En idiomas donde las carpetas no tienen sentido, puede reemplazarlo con paquetes/módulos/etc. u otras estructuras jerárquicas similares en su idioma. No importa cuál sea el elemento jerárquico en su proyecto, el punto importante aquí es organizar sus pruebas y código principal con jerarquías coincidentes.

Las pruebas para una carpeta dentro de la jerarquía deben cubrir completamente cada código bajo la carpeta correspondiente de la base de código principal. Una prueba que prueba indirectamente el código de diferentes partes de la jerarquía son accidentales y no cuenta para la cobertura de esa otra carpeta. Idealmente, no debería haber ningún código que solo sea llamado y probado por pruebas de diferentes partes de la jerarquía.

No recomiendo subdividir la jerarquía de prueba al nivel de clase/función. Por lo general, es demasiado fino y no le da mucho beneficio subdividir las cosas en ese detalle. Si un archivo de código principal es lo suficientemente grande como para garantizar varios archivos de prueba, entonces generalmente indica que el archivo está haciendo demasiado y debería haberse desglosado.

Bajo esta estructura de organización, si su nueva clase/función vive bajo la misma carpeta de hojas que todo el código que la está usando, entonces no necesita sus propias pruebas siempre y cuando las pruebas para ese archivo ya lo cubran. Si, por otro lado, considera que la nueva clase/método es lo suficientemente grande o independiente como para garantizar su propio archivo/carpeta en la jerarquía, entonces también debe hacer el archivo/carpeta de prueba correspondiente.

En términos generales, un archivo debe ser del tamaño que pueda ajustar el contorno aproximado en su cabeza y donde puede escribir un párrafo para explicar cuál es el contenido de los archivos para describir qué los une. Como regla general, esto generalmente se trata de una pantalla completa para mí (una carpeta no debe tener más que una pantalla llena de subcarpetas, un archivo no debe tener más que una pantalla llena de clases/funciones de nivel superior, una función no debe tener más de una pantalla llena de líneas). Si imaginar el contorno del archivo se siente difícil, entonces el archivo probablemente sea demasiado grande.

0
Lie Ryan

Como han señalado otras respuestas, lo que estás describiendo no parece una refactorización. Aplicar TDD a la refactorización se vería así:

  1. Identifica tu superficie API. Por definición, la refactorización no cambiará su superficie API. Si el código fue escrito sin una superficie API claramente diseñada, y los consumidores dependen de los detalles de implementación, entonces tiene problemas más grandes que no pueden resolverse refactorizando. Aquí es donde puede definir una superficie API, bloquear todo lo demás y colocar el número de versión principal para indicar que la nueva versión no es compatible con versiones anteriores, o descartar todo el proyecto y volver a escribirlo desde cero.

  2. Escribir pruebas contra la superficie API. Piense en la API en términos de garantías, por ejemplo, el método Foo devuelve un resultado significativo cuando se le da un parámetro que cumple con las condiciones especificadas, y de lo contrario arroja una excepción específica. Escriba pruebas para cada garantía que pueda identificar. Piense en términos de lo que se supone que debe hacer la API, no de lo que realmente hace. Si hubo una especificación o documentación original, estudíelo. Si no hubo, escriba algunos. El código sin documentación no es ni correcto ni incorrecto. No escriba pruebas contra nada que no esté en la especificación API.

  3. Comience a modificar el código, ejecute sus pruebas con frecuencia para asegurarse de que no ha roto ninguna garantía de la API.

Hay una desconexión en muchas organizaciones entre desarrolladores y probadores. Los desarrolladores que no practican TDD, al menos de manera informal, a menudo desconocen las características que hacen que el código sea verificable. Si todos los desarrolladores escribieran código comprobable, no habría necesidad de burlarse de los marcos. El código que no está diseñado para la capacidad de prueba crea un problema de huevo y gallina. No puede refactorizar sin pruebas, y no puede escribir pruebas hasta que haya corregido el código. Los costos de no practicar TDD desde el principio son enormes. Es probable que los cambios cuesten más que el proyecto original. Nuevamente, aquí es donde te resignas a hacer cambios importantes o a tirar todo.

0
StackOverthrow