Pruebas unitarias, fábricas y la Ley de Demeter

8

Así es como funciona mi código. Tengo un objeto que representa el estado actual de algo similar a un pedido de carrito de compras, almacenado en una API de compras de terceros. En mi código de controlador, quiero poder llamar:

myOrder.updateQuantity(2);

Para enviar realmente el mensaje al tercero, el tercero también necesita saber varias cosas que son específicas de ESTE pedido, como el orderIDy el loginID, que no cambiarán durante la vigencia de la aplicación.

Entonces, cuando creo myOrderoriginalmente, inyecto un MessageFactory, que sabe loginID. Luego, cuando updateQuantityse llama, Orderpasa orderID. El código de control es fácil de escribir. Otro hilo maneja la devolución de llamada y se actualiza Ordersi su cambio fue exitoso, o informa Orderque su cambio falló si no fue así.

El problema es la prueba. Debido a que el Orderobjeto depende de a MessageFactory, y necesita MessageFactorydevolver Messages reales (que llama .setOrderID(), por ejemplo), ahora tengo que configurar simulaciones muy complicadas MessageFactory. Además, no quiero matar a ninguna hada, ya que "cada vez que un Mock devuelve un Mock, un hada muere".

¿Cómo puedo resolver este problema manteniendo el código del controlador igual de simple? Leí esta pregunta: /programming/791940/law-of-demeter-on-factory-pattern-and-dependency-injection pero no ayudó porque no habló sobre el problema de la prueba .

Algunas soluciones que he pensado:

  1. Refactorice de alguna manera el código para que no requiera que el método de fábrica devuelva objetos reales. ¿Quizás es menos una fábrica y más una MessageSender?
  2. Cree una implementación de solo prueba MessageFactorye inyecte eso.

El código está bastante involucrado, aquí está mi intento de un sscce:

public class Order implements UpdateHandler {
    private final MessageFactory factory;
    private final MessageLayer layer;

    private OrderData data;

    // Package private constructor, this should only be called by the OrderBuilder object.
    Order(OrderBuilder builder, OrderData initial) {
        this.factory = builder.getFactory();
        this.layer = builder.getLayer();
        this.data = original;
    }

    // Lots of methods like this
    public String getItemID() {
        return data.getItemID();
    }

    // Returns true if the message was placed in the outgoing network queue successfully. Doesn't block for receipt, though.
    public boolean updateQuantity(int newQuantity) {
        Message newMessage = factory.createOrderModification(messageInfo);

        // *** THIS IS THE KEY LINE ***
        // throws an NPE if factory is a mock.
        newMessage.setQuantity(newQuantity); 

        return layer.send(newMessage); 
    }

    // from interface UpdateHandler
    // gets called asynchronously
    @Override 
    public handleUpdate(OrderUpdate update) {
        messageInfo.handleUpdate(update);
    }
}
durron597
fuente
Creo que tendrá que mostrarnos el código relevante que ha escrito para el Orderobjeto y el MessageFactory. Esta es una buena descripción, pero es un poco abstracto abordarla directamente con una respuesta clara.
Robert Harvey
@RobertHarvey Espero que la actualización ayude.
durron597
¿Estás tratando de verificar que layer.sendenvía el mensaje o que envía el mensaje correcto ?
Robert Harvey
@RobertHarvey Estaba pensando en llamar verify(messageMock).setQuantity(2), y verify(layer).send(messageMock);también updateQuantitydebería devolver falso si Orderya tiene una actualización pendiente, pero omití ese código por razones de seguridad.
durron597
3
Tendré que mirar su código un poco más ... Pero para que conste, no considero que devolver un simulacro sea un gran problema. La complejidad de las pruebas basadas en simulacros es una consecuencia inevitable de tratar con objetos mutables, y dado que un simulacro no tiene relación con su aplicación final lanzada, no veo cómo devolver uno mata gatitos, y mucho menos hadas.
Robert Harvey

Respuestas:

12

La principal preocupación aquí es que los simulacros no pueden (o no deberían) devolver simulacros. Este es probablemente un buen consejo, pero habla de una solución: devolver un real Message. Si la Messageclase está bien probada y es aprobada, puede considerarla tan amigable como una burla. Quizás sea aún más amigable ya que responderá como si fuera real porque es real.

¿Qué tipo de Messages reales puedes devolver? Bueno, puede devolver un real completo Message, un real simplificado Message(en el que se utilizan valores predeterminados conocidos), o puede devolver un NullMessage(como en el Patrón de objetos nulos). A NullMessagees tan válido Messagecomo cualquier otro, y se puede colocar en cualquier otro lugar de su aplicación. Cuál usar depende de la complejidad de crear y devolver un mensaje completo.

En cuanto a la Ley de Deméter, hay múltiples preocupaciones aquí. Primero, su constructor toma su propio constructor como parámetro, luego extrae elementos de él. Esta es una clara violación de Demeter, y también crea una dependencia superflua. Peor aún, el constructor está actuando como un mini localizador de servicios, enmascarando las dependencias reales de la clase. El OrderBuilderdebe crear estos objetos y pasarlos en como sus propios parámetros.

Para probar esto, entonces, pasaría un simulacro MessageFactory, que devuelve un real Message(ya sea completo, simple o nulo), y un simulacro MessageLayerque toma el mensaje. Si usa uno completo o simplificado Message, puede recuperarlo de su MessageLayersimulacro e inspeccionarlo para comprobar las afirmaciones.

También consideraría el MessageFactoryy MessageLayercomo un grupo funcional en un nivel diferente de abstracción, y entonces extraería una MessageSenderclase que encapsulara esa funcionalidad. Puede probar esta clase utilizando un simulacro simple MessageSendery cambiar todo lo que mencioné anteriormente a las MessageSenderpruebas de 's', y así cumplir más estrechamente con la Responsabilidad Única también.


Veo que realmente hay dos preguntas aquí. Hay una pregunta específica sobre cómo probar este código, y una pregunta general sobre los simulacros que devuelven simulacros. La pregunta específica es lo que traté anteriormente en mayor medida, y tengo más pensamientos al final de esto ahora que algunos detalles más han salido a la luz, pero aún no hay una buena respuesta a la pregunta general: ¿Por qué los simulacros no devuelven simulacros?

La razón por la cual los simulacros no deberían devolver simulacros es que puede terminar probando sus pruebas en lugar de probar su código. En lugar de asegurarse de que la unidad sea completamente funcional, la prueba ahora depende de un código completamente nuevo que se encuentra solo en el caso de prueba en sí (que a menudo no se prueba). Esto crea dos problemas.

Primero, la prueba ahora no puede decirme con certeza si la unidad está rota o si los simulacros interrelacionados están rotos. El objetivo de una prueba es crear un entorno aislado donde solo debe haber una causa de falla. Un simulacro en sí mismo generalmente es muy simple y puede inspeccionarse directamente para detectar problemas, pero el cableado de simulacros múltiples de esta forma se vuelve exponencialmente más difícil de confirmar mediante inspección.

El segundo problema es que, a medida que las API cambian para los objetos reales, las pruebas pueden comenzar a fallar muy lejos, ya que los simulacros no cambian automáticamente también. Aquí entra en juego la Ley de Deméter, ya que estos son exactamente el tipo de efectos que la ley evita. En mis pruebas, tendría que preocuparme por mantener sincronizadas no solo las simulaciones de dependencias directas, sino también las simulaciones de dependencias de dependencias hasta el infinito . Esto tiene el efecto de la cirugía de escopeta en las pruebas cuando las clases cambian.


Ahora, en cuanto a la pregunta específica de cómo probar este fragmento de código en particular, analicemos algunos supuestos.

Pregunta 1: ¿Qué estamos probando realmente? Si bien esta es una parte abreviada del código, podemos ver tres actividades esenciales aquí. Primero, tenemos una fábrica que genera a Message. No estamos probando si la fábrica está produciendo Message, ya que ya te estás burlando de eso. No estamos probando el Message, como debería probarse en otro lugar, presumiblemente en un conjunto de pruebas para la API de terceros que genera el Message. En la segunda línea, podemos ver por inspección que el método simplemente se llama en el Messagey, por lo tanto, realmente no hay nada que probar en la segunda línea. Una vez más, debería haber pruebas en otros lugares que hagan que las pruebas sean redundantes. La tercera línea llama a la MessageLayerAPI y simplemente pasa por el resultado. Una vez más,MessageLayerLa API ya debería probarse en otro lugar. Esto nos deja esencialmente sin nada que probar. No hay efectos secundarios visibles directos en el código externo, y no deberíamos probar la implementación interna. Eso nos lleva a la conclusión de que sería inapropiado probar este código . (Para más información sobre esta línea de razonamiento, vea la presentación de Sandi Metz Trucos mágicos de prueba , [ diapositivas , video ])

Pregunta 2: Espera, entonces ... ¿qué? Sí, es cierto, no pruebes esto en absoluto. Ahora, como se mencionó, esta es una versión abreviada del código. Si tiene otra lógica, pruebe eso, pero encapsule esto en una unidad separada (como la MessageSenderimplementación mencionada anteriormente). A continuación, puede burlarse de este aspecto completo del código fácilmente, sin dejar de tener la capacidad de probar otra lógica.

Básicamente está utilizando una API de terceros directamente en su código. El código de terceros es notoriamente difícil de probar porque puede tener este tipo de problemas de dependencia que tiene aquí. Encapsularlo en un área acorralada puede hacer que sea más fácil probar su otro código y reducir la cirugía de escopeta si ese tercero cambia su código (o simplemente cambia). Si bien aún puede ser difícil probar la parte que interactúa con la API de terceros, está limitada a una pequeña faceta que puede aislar.

cbojar
fuente
+1 Totalmente de acuerdo sobre el constructor. No estoy seguro de que devolver NullMessage sea mejor que devolver un Mock; parece que se está deslizando por un tecnicismo. De cualquier manera, el resultado es un "falso".
Rob
Lol, robé el truco de "tomar el propio constructor como argumento" de la fuente de Guava.
durron597
@RobY Un objeto nulo no tiene que ser falso si lo usa como parte de su aplicación como una operación legítima. Sin embargo, si realmente solo se usa como prueba falsa, entonces se Messagedebe usar otro tipo de real .
cbojar
@cbojar Concedido, pero Opcional / Quizás está emergiendo como una mejor manera de manejar referencias anulables. Si solo está escribiendo un NullObject para pruebas unitarias, entonces el NullObject es realmente solo un simulacro escrito a mano, como en los viejos tiempos. El problema es que, sin una justificación clara de la regla "no se burlan de los simulacros", es difícil decir si un objeto nulo es malo o por qué , excepto que puede o no romper una regla estilística subjetiva.
Rob
Por cierto, la única Messageimplementación real depende de la API de terceros; para poder crear instancias de sus clases, debe iniciar la Engineque requiere, entre otras cosas, una clave de licencia ... no exactamente lo que desea en una prueba unitaria.
durron597
2

Voy a estar de acuerdo con @Robert Harvey. Para ser claros: Demeter es un estilo de programación razonable y bueno. Es el "no burlarse de los simulacros" lo que me parece más una preferencia subjetiva que respalda un estilo de codificación particular en lugar de una práctica generalmente aplicable (y bien justificada). La regla de hadas saca interfaces "fluidas" como:

Thing.create ("zoom"). SetDomain ("bo.com"). Add (1) .flip (). Reverse (). TuneForSemantics (). Run ();

Es un ejemplo extremo, pero esencialmente la regla de hadas no permitiría incluir esa clase en ningún código, porque el código se volvería inestable. Pero ese es un paradigma popular en el código OO.

Además, el problema más general es cómo burlarse de una fábrica para devolver un simulacro con el que desea probar. Por lo general, me da miedo usar las fábricas como dependencias, pero a veces es mucho mejor que la alternativa. Si terminas con

ThirdPartyThing ThirdPartyFactory<ThirdPartyThing>#create()

No veo cómo puedes evitarlo. Necesitas un simulacro para devolver el simulacro. Entonces, esa regla elimina dos patrones de diseño OO realmente poderosos.

No puedo pensar en una forma de solucionar su problema sin dividir ese método en 2 o 3, empujando largas filas hacia el cliente, o creando una extraña clase de contenedor con estado.

Me interesaría mucho ver cómo se ve la alternativa.

Mi respuesta: ¡tu código está bien! Excelcior!

(en realidad tengo curiosidad por las alternativas)

...

Tomemos un caso límite: ¿se les permite a los simulacros regresar? Técnicamente, están devolviendo un simulacro. Si no, entonces eso noquea al prototipo GoF, y ese es uno de los patrones que se ha mantenido con el tiempo:

MuActor prototype = ...
...
MuActor actor = prototype.create();
actor.run();

así lo permite la regla:

prototype = Mock(MuActor.class);
when(prototype.create()).thenReturn(prototype);

Además, la regla de hadas prácticamente prohíbe el uso de mónadas, ya que se basan en la operación de encadenamiento para un tipo de contenedor en particular. Puede probar el tipo Monad, pero no puede probar el código en el que aparece Monad.

Robar
fuente
Las interfaces fluidas pueden seguir a Demeter porque las interfaces fluidas devuelven el mismo objeto. En otras palabras, obj.law().of().demeter();es lo mismo que obj.law(); obj.of(); obj.demeter();, lo cual es perfectamente aceptable. Se puede hacer una explicación similar sobre los prototipos.
cbojar
Sí, pero si realmente funcionan y quieres burlarte de ellos, entonces te topas con la regla de "no burlas de burlas". Lo que significa que el código que los usa se vuelve inestable.
Rob
@cbojar oh, y debería estar claro. Demeter suena razonable y un buen estilo de programación. Es el "no burlarse de los simulacros" lo que me parece más una preferencia subjetiva que respalda un estilo de codificación particular en lugar de una práctica generalmente aplicable (y bien justificada).
Rob
Por cierto, en una parte diferente del código uso la interfaz fluida. Tengo algunas ideas de esta publicación de blog , y aquí está el código real que uso: pastebin.com/D1GxSPsy
durron597
Eso es inteligente. ¡Frio! Lo intentaré en el código en el que estoy trabajando ahora. Solo una nota: una interfaz fluida no tiene que devolverse. Existe la variación donde la interfaz fluida devuelve una serie de instancias inmutables. es decir, en lugar de "devolver esto" usa "return new Fluent (...)". Sin embargo, eso es transparente para el cliente, por lo que no afecta a ninguna de las discusiones aquí. Pero es un hecho divertido.
Rob