¿Cuáles son los principios de diseño que promueven el código comprobable? (diseño de código comprobable vs diseño de conducción a través de pruebas)


54

La mayoría de los proyectos en los que trabajo consideran el desarrollo y las pruebas unitarias de forma aislada, lo que hace que escribir pruebas unitarias en una instancia posterior sea una pesadilla. Mi objetivo es tener en cuenta las pruebas durante las fases de diseño de alto y bajo nivel.

Quiero saber si hay principios de diseño bien definidos que promuevan el código comprobable. Uno de esos principios que he llegado a comprender recientemente es la Inversión de dependencias a través de la inyección de dependencias e Inversión de control.

He leído que hay algo conocido como SÓLIDO. Quiero entender si seguir los principios SOLID indirectamente da como resultado un código que es fácilmente comprobable. Si no, ¿hay principios de diseño bien definidos que promuevan el código comprobable?

Soy consciente de que hay algo conocido como Test Driven Development. Sin embargo, estoy más interesado en diseñar código teniendo en cuenta las pruebas durante la fase de diseño en lugar de conducir el diseño a través de pruebas. Espero que esto tenga sentido.

Una pregunta más relacionada con este tema es si está bien volver a factorizar un producto / proyecto existente y hacer cambios en el código y el diseño con el fin de poder escribir un caso de prueba unitaria para cada módulo.



Gracias. Acabo de empezar a leer el artículo y ya tiene sentido.

1
Esta es una de mis preguntas de la entrevista ("¿Cómo diseña el código para que sea fácilmente probado en la unidad?"). De manera simple, me muestra si entienden las pruebas unitarias, las burlas / tropezones, el OOD y potencialmente el TDD. Lamentablemente, las respuestas suelen ser algo así como "Hacer una base de datos de prueba".
Chris Pitman

Respuestas:


57

Sí, SOLID es una muy buena manera de diseñar código que se pueda probar fácilmente. Como una breve introducción:

S - Principio de responsabilidad única: un objeto debe hacer exactamente una cosa, y debe ser el único objeto en la base de código que hace esa única cosa. Por ejemplo, tome una clase de dominio, diga una factura. La clase Factura debe representar la estructura de datos y las reglas comerciales de una factura tal como se utiliza en el sistema. Debería ser la única clase que representa una factura en la base de código. Esto puede desglosarse aún más para decir que un método debe tener un propósito y debe ser el único método en la base de código que satisfaga esta necesidad.

Siguiendo este principio, aumenta la capacidad de prueba de su diseño al disminuir la cantidad de pruebas que tiene que escribir que prueban la misma funcionalidad en diferentes objetos, y también generalmente termina con piezas de funcionalidad más pequeñas que son más fáciles de probar de forma aislada.

O - Principio abierto / cerrado: una clase debe estar abierta a la extensión, pero cerrada al cambio . Una vez que un objeto existe y funciona correctamente, idealmente no debería haber necesidad de volver a ese objeto para realizar cambios que agreguen nuevas funciones. En cambio, el objeto debe extenderse, ya sea derivándolo o conectando implementaciones de dependencia nuevas o diferentes, para proporcionar esa nueva funcionalidad. Esto evita la regresión; puede introducir la nueva funcionalidad cuando y donde sea necesario, sin cambiar el comportamiento del objeto, ya que ya se usa en otros lugares.

Al adherirse a este principio, generalmente aumenta la capacidad del código para tolerar "simulacros", y también evita tener que reescribir las pruebas para anticipar un nuevo comportamiento; todas las pruebas existentes para un objeto aún deberían funcionar en la implementación no extendida, mientras que las nuevas pruebas para nuevas funcionalidades usando la implementación extendida también deberían funcionar.

L - Principio de sustitución de Liskov: Una clase A, que depende de la clase B, debería poder usar cualquier X: B sin saber la diferencia. Esto básicamente significa que cualquier cosa que use como dependencia debería tener un comportamiento similar al que ve la clase dependiente. Como un breve ejemplo, supongamos que tiene una interfaz IWriter que expone Write (string), que es implementado por ConsoleWriter. Ahora tiene que escribir en un archivo, por lo que debe crear FileWriter. Al hacerlo, debe asegurarse de que FileWriter se pueda usar de la misma manera que lo hizo ConsoleWriter (lo que significa que la única forma en que el dependiente puede interactuar con él es llamando a Write (string)), por lo que es posible que FileWriter necesite información adicional para hacerlo. El trabajo (como la ruta y el archivo para escribir) debe proporcionarse desde otro lugar que no sea el dependiente.

Esto es enorme para escribir código comprobable, porque un diseño que se ajusta al LSP puede tener un objeto "simulado" sustituido por el objeto real en cualquier momento sin cambiar el comportamiento esperado, lo que permite probar pequeñas piezas de código de forma aislada con la confianza que el sistema funcionará con los objetos reales conectados.

I - Principio de segregación de interfaz: una interfaz debe tener tan pocos métodos como sea posible para proporcionar la funcionalidad del rol definido por la interfaz . En pocas palabras, más interfaces más pequeñas son mejores que menos interfaces más grandes. Esto se debe a que una interfaz grande tiene más razones para cambiar y provoca más cambios en otras partes de la base de código que pueden no ser necesarios.

La adhesión al ISP mejora la capacidad de prueba al reducir la complejidad de los sistemas bajo prueba y las dependencias de esos SUT. Si el objeto que está probando depende de una interfaz IDoThreeThings que expone DoOne (), DoTwo () y DoThree (), debe burlarse de un objeto que implemente los tres métodos, incluso si el objeto solo usa el método DoTwo. Pero, si el objeto depende solo de IDoTwo (que expone solo DoTwo), puede burlarse más fácilmente de un objeto que tenga ese método.

D - Principio de inversión de dependencia: las concreciones y abstracciones nunca deberían depender de otras concreciones, sino de abstracciones . Este principio hace cumplir directamente el principio del acoplamiento suelto. Un objeto nunca debería tener que saber qué es un objeto; en cambio, debería importarle lo que HACE un objeto. Por lo tanto, el uso de interfaces y / o clases base abstractas siempre es preferible al uso de implementaciones concretas cuando se definen propiedades y parámetros de un objeto o método. Eso le permite cambiar una implementación por otra sin tener que cambiar el uso (si también sigue LSP, que va de la mano con DIP).

Nuevamente, esto es enorme para la capacidad de prueba, ya que le permite, una vez más, inyectar una implementación simulada de una dependencia en lugar de una implementación de "producción" en el objeto que se está probando, mientras sigue probando el objeto en la forma exacta que tendrá mientras en producción. Esta es la clave para las pruebas unitarias "de forma aislada".


16

He leído que hay algo conocido como SÓLIDO. Quiero entender si seguir los principios SOLID indirectamente da como resultado un código que es fácilmente comprobable.

Si se aplica correctamente, sí. Hay una publicación de blog de Jeff que explica los principios de SOLID de una manera muy breve (vale la pena escuchar el podcast mencionado también), sugiero que eche un vistazo allí si las descripciones más largas lo están desanimando.

Desde mi experiencia, 2 principios de SOLID juegan un papel importante en el diseño de código comprobable:

  • Principio de segregación de interfaz : debe preferir muchas interfaces específicas del cliente en lugar de menos interfaces de uso general. Esto va de la mano con el Principio de Responsabilidad Única y lo ayuda a diseñar clases orientadas a funciones / tareas, que a cambio son mucho más fáciles de probar (en comparación con las más generales, o con frecuencia abusan de "gerentes" y "contextos" ) - menos dependencias , menos complejidad, más fino, pruebas obvias. En resumen, los componentes pequeños conducen a pruebas simples.
  • Principio de inversión de dependencia : diseño por contrato, no por implementación. Esto lo beneficiará más cuando pruebe objetos complejos y se dé cuenta de que no necesita un gráfico completo de dependencias solo para configurarlo , sino que simplemente puede burlarse de la interfaz y terminar con ella.

Creo que estos dos te ayudarán más cuando diseñes para probar. Los restantes también tienen un impacto, pero yo diría que no tan grande.

(...) si está bien volver a factorizar un producto / proyecto existente y hacer cambios en el código y el diseño con el fin de poder escribir un caso de prueba unitaria para cada módulo?

Sin las pruebas unitarias existentes, simplemente se trata de pedir problemas. La prueba unitaria es su garantía de que su código funciona . La introducción de un cambio innovador se detecta de inmediato si tiene una cobertura de pruebas adecuada.

Ahora, si desea cambiar el código existente para agregar pruebas unitarias , esto introduce una brecha en la que aún no tiene pruebas, pero ya ha cambiado el código . Naturalmente, es posible que no tenga idea de qué rompieron sus cambios. Esta es una situación que quieres evitar.

Vale la pena escribir las pruebas unitarias de todos modos, incluso con código que es difícil de probar. Si su código funciona , pero no se prueba la unidad, la solución adecuada sería escribir pruebas para él y luego introducir cambios. Sin embargo, tenga en cuenta que cambiar el código probado para que sea más fácilmente comprobable es algo en lo que su gerencia no querrá gastar dinero (probablemente escuchará que aporta poco o ningún valor comercial).


iaw alta cohesión y bajo acoplamiento
jk.

8

SU PRIMERA PREGUNTA

SOLID es de hecho el camino a seguir. Encuentro que los dos aspectos más importantes del acrónimo SOLID, en lo que respecta a la capacidad de prueba, es la S (responsabilidad única) y la D (inyección de dependencia).

Responsabilidad única : tus clases realmente solo deberían estar haciendo una cosa y solo una cosa. una clase que crea un archivo, analiza alguna entrada y la escribe en el archivo ya está haciendo tres cosas. Si su clase solo hace una cosa, usted sabe exactamente qué esperar de ella, y diseñar los casos de prueba para eso debería ser bastante fácil.

Inyección de dependencias (DI): esto le brinda el control del entorno de prueba. En lugar de crear objetos forreign dentro de su código, lo inyecta a través del constructor de la clase o la llamada al método. Cuando pruebas unitarias, simplemente reemplazas las clases reales por trozos o simulacros, que controlas por completo.

SU SEGUNDA PREGUNTA: Idealmente, usted escribe pruebas que documentan el funcionamiento de su código antes de refactorizarlo. De esta manera, puede documentar que su refactorización reproduce los mismos resultados que el código original. Sin embargo, su problema es que el código de funcionamiento es difícil de probar. Esta es una situación clásica! Mi consejo es: piense detenidamente sobre la refactorización antes de las pruebas unitarias. Si puedes; escriba pruebas para el código de trabajo, luego refactorice el código y luego refactorice las pruebas. Sé que costará horas, pero estarás más seguro de que el código refactorizado hace lo mismo que el anterior. Dicho esto, me he rendido muchas veces. Las clases pueden ser tan feas y desordenadas que una reescritura es la única forma de hacerlas verificables.


4

Además de las otras respuestas, que se centran en lograr un acoplamiento flojo, me gustaría decir una palabra sobre probar lógica complicada.

Una vez tuve que hacer una prueba unitaria de una clase cuya lógica era compleja, con muchos condicionales, y donde era difícil entender el papel de los campos.

Reemplacé este código con muchas clases pequeñas que representan una máquina de estado . La lógica se volvió mucho más simple de seguir, ya que los diferentes estados de la clase anterior se hicieron explícitos. Cada clase de estado era independiente de las demás, por lo que eran fácilmente comprobables.

El hecho de que los estados fueran explícitos hizo que fuera más fácil enumerar todas las rutas posibles del código (las transiciones de estado) y, por lo tanto, escribir una prueba unitaria para cada una.

Por supuesto, no todas las lógicas complejas pueden modelarse como una máquina de estados.


3

SOLID es un excelente comienzo, en mi experiencia, cuatro de los aspectos de SOLID realmente funcionan bien con las pruebas unitarias.

  • Principio de responsabilidad única : cada clase hace una cosa y solo una. Calcular un valor, abrir un archivo, analizar una cadena, lo que sea. La cantidad de entradas y salidas, así como los puntos de decisión, por lo tanto, deben ser muy mínimos. Lo que hace que sea fácil escribir pruebas.
  • Principio de sustitución de Liskov : debe poder sustituir en trozos y simulacros sin alterar las propiedades deseables (los resultados esperados) de su código.
  • Principio de segregación de interfaz : la separación de puntos de contacto por interfaces hace que sea muy fácil usar un marco de imitación como Moq para crear apéndices y simulacros. En lugar de tener que confiar en las clases concretas, simplemente confía en algo que implementa la interfaz.
  • Principio de inyección de dependencia : esto es lo que le permite inyectar esos trozos y simulacros en su código, ya sea a través de un constructor, una propiedad o un parámetro en el método que desea probar.

También examinaría diferentes patrones, especialmente el patrón de fábrica. Digamos que tiene una clase concreta que implementa una interfaz. Crearía una fábrica para crear una instancia de la clase concreta, pero en su lugar devolvería la interfaz.

public interface ISomeInterface
{
    int GetValue();
}  

public class SomeClass : ISomeInterface
{
    public int GetValue()
    {
         return 1;
    }
}

public interface ISomeOtherInterface
{
    bool IsSuccess();
}

public class SomeOtherClass : ISomeOtherInterface
{
     private ISomeInterface m_SomeInterface;

     public SomeOtherClass(ISomeInterface someInterface)
     {
          m_SomeInterface = someInterface;
     }

     public bool IsSuccess()
     {
          return m_SomeInterface.GetValue() == 1;
     }
}

public class SomeFactory
{
     public virtual ISomeInterface GetSomeInterface()
     {
          return new SomeClass();
     }

     public virtual ISomeOtherInterface GetSomeOtherInterface()
     {
          ISomeInterface someInterface = GetSomeInterface();

          return new SomeOtherClass(someInterface);
     }
}

En sus pruebas, puede usar Moq o algún otro marco burlón para anular ese método virtual y devolver una interfaz de su diseño. Pero en lo que respecta al código de implementación, la fábrica no ha cambiado. También puede ocultar muchos de los detalles de su implementación de esta manera, a su código de implementación no le importa cómo se construye la interfaz, lo único que le importa es recuperar una interfaz.

Si desea ampliar esto un poco, le recomiendo leer The Art of Unit Testing . Da algunos buenos ejemplos sobre cómo usar estos principios, y es una lectura bastante rápida.


1
Se llama principio de "inversión" de dependencia, no principio de "inyección".
Mathias Lykkegaard Lorenzen
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.