Pruebas de unidad en un mundo "sin setter"

23

No me considero un experto en DDD pero, como arquitecto de soluciones, intento aplicar las mejores prácticas siempre que sea posible. Sé que hay mucha discusión sobre los pros y los contras del "estilo" de setter no (público) en DDD y puedo ver ambos lados del argumento. Mi problema es que trabajo en un equipo con una amplia diversidad de habilidades, conocimientos y experiencia, lo que significa que no puedo confiar en que cada desarrollador haga las cosas de la manera "correcta". Por ejemplo, si nuestros objetos de dominio están diseñados para que los cambios en el estado interno del objeto se realicen mediante un método pero proporcionen establecedores de propiedad pública, alguien inevitablemente establecerá la propiedad en lugar de llamar al método. Usa este ejemplo:

public class MyClass
{
    public Boolean IsPublished
    {
        get { return PublishDate != null; }
    }

    public DateTime? PublishDate { get; set; }

    public void Publish()
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        PublishDate = DateTime.Today;

        Raise(new PublishedEvent());
    }
}

Mi solución ha sido hacer que los establecedores de propiedades sean privados, lo cual es posible porque el ORM que estamos usando para hidratar los objetos usa la reflexión para que pueda acceder a los establecedores privados. Sin embargo, esto presenta un problema al intentar escribir pruebas unitarias. Por ejemplo, cuando quiero escribir una prueba unitaria que verifique el requisito de que no podemos volver a publicar, debo indicar que el objeto ya ha sido publicado. Ciertamente puedo hacer esto llamando a Publicar dos veces, pero luego mi prueba asume que Publicar se implementa correctamente para la primera llamada. Eso parece un poco maloliente.

Hagamos el escenario un poco más real con el siguiente código:

public class Document
{
    public Document(String title)
    {
        if (String.IsNullOrWhiteSpace(title))
            throw new ArgumentException("title");

        Title = title;
    }

    public String ApprovedBy { get; private set; }
    public DateTime? ApprovedOn { get; private set; }
    public Boolean IsApproved { get; private set; }
    public Boolean IsPublished { get; private set; }
    public String PublishedBy { get; private set; }
    public DateTime? PublishedOn { get; private set; }
    public String Title { get; private set; }

    public void Approve(String by)
    {
        if (IsApproved)
            throw new InvalidOperationException("Already approved.");

        ApprovedBy = by;
        ApprovedOn = DateTime.Today;
        IsApproved = true;

        Raise(new ApprovedEvent(Title));
    }

    public void Publish(String by)
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        if (!IsApproved)
            throw new InvalidOperationException("Cannot publish until approved.");

        PublishedBy = by;
        PublishedOn = DateTime.Today;
        IsPublished = true;

        Raise(new PublishedEvent(Title));
    }
}

Quiero escribir pruebas unitarias que verifiquen:

  • No puedo publicar a menos que el documento haya sido aprobado
  • No puedo volver a publicar un documento
  • Cuando se publican, los valores PublicadosPor y Publicados están establecidos correctamente
  • Cuando se publica, se publica el Evento publicado

Sin acceso a los setters, no puedo poner el objeto en el estado necesario para realizar las pruebas. Abrir el acceso a los setters anula el propósito de evitar el acceso.

¿Cómo (tiene) que resuelve (d) este problema?

Hijo de pirata
fuente
Cuanto más pienso en esto, más creo que todo tu problema es tener métodos con efectos secundarios. O más bien, un objeto inmutable mutable. En un mundo DDD, ¿no debería devolver un nuevo objeto Documento de Aprobar y Publicar, en lugar de actualizar el estado interno de este objeto?
pdr
1
Pregunta rápida, ¿qué O / RM estás usando? Soy un gran admirador de EF, pero declarar que los setters están protegidos me molesta un poco.
Michael Brown
Tenemos una combinación en este momento debido al desarrollo de rango libre que me han encargado de discutir. Algunos ADO.NET usan AutoMapper para hidratarse de un DataReader, un par de modelos de Linq a SQL (que será el próximo en reemplazar ) y algunos nuevos modelos EF.
SonOfPirate
Llamar a Publicar dos veces no tiene mal olor y es la forma de hacerlo.
Piotr Perak

Respuestas:

27

No puedo poner el objeto en el estado necesario para realizar las pruebas.

Si no puede poner el objeto en el estado necesario para realizar una prueba, entonces no puede poner el objeto en el estado en el código de producción, por lo que no hay necesidad de probar ese estado. Obviamente, esto no es cierto en su caso, puede poner su objeto en el estado necesario, solo llame a Aprobar.

  • No puedo publicar a menos que el documento haya sido aprobado: escriba una prueba de que llamar a publicar antes de llamar a aprobar causa el error correcto sin cambiar el estado del objeto.

    void testPublishBeforeApprove() {
        doc = new Document("Doc");
        AssertRaises(doc.publish, ..., NotApprovedException);
    }
    
  • No puedo volver a publicar un documento: escriba una prueba que apruebe un objeto, luego llame a publicar una vez que tenga éxito, pero la segunda vez causa el error correcto sin cambiar el estado del objeto.

    void testRePublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        AssertRaises(doc.publish, ..., RepublishException);
    }
    
  • Cuando se publican, los valores PublicadosPor y Publicados están establecidos correctamente: escriba una prueba que llame a aprobar y luego llame a publicar, afirme que el estado del objeto cambia correctamente

    void testPublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        Assert(doc.PublishedBy, ...);
        ...
    }
    
  • Cuando se publica, se publica el Evento publicado: conéctese al sistema de eventos y establezca un indicador para asegurarse de que se llame

También necesita escribir una prueba para aprobar.

En otras palabras, no pruebe la relación entre los campos internos y IsPublished e IsApproved, su prueba sería bastante frágil si lo hace, ya que cambiar su campo significaría cambiar su código de prueba, por lo que la prueba sería bastante inútil. En su lugar, debe probar la relación entre llamadas de métodos públicos, de esta manera, incluso si modifica los campos, no necesitaría modificar la prueba.

Lie Ryan
fuente
Cuando Aprobar se rompe, se rompen varias pruebas. Ya no está probando una unidad de código, está probando la implementación completa.
pdr
Comparto la preocupación de pdr, por eso dudé en ir en esta dirección. Sí, parece más limpio, pero no me gusta tener varias razones por las que una prueba individual puede fallar.
SonOfPirate
44
Todavía tengo que ver una prueba unitaria que solo podría fallar por una sola razón posible. Además, puede poner las partes de "manipulación de estado" de la prueba en un setup()método, no en la prueba misma.
Peter K.
12
¿Por qué depende de approve()alguna manera quebradizo, pero depende de setApproved(true)alguna manera no? approve()es una dependencia legítima en las pruebas porque es una dependencia en los requisitos. Si la dependencia solo existiera en las pruebas, ese sería otro problema.
Karl Bielefeldt
2
@pdr, ¿cómo probarías una clase de pila? ¿Tratarías de probar los métodos push()y de forma pop()independiente?
Winston Ewert
2

Otro enfoque es crear un constructor de la clase que permita establecer las propiedades internas en la instanciación:

 public Document(
  String approvedBy,
  DateTime? approvedOn,
  Boolean isApproved,
  Boolean isPublished,
  String publishedBy,
  DateTime? publishedOn,
  String title)
{
  ApprovedBy = approvedBy;
  ApprovedOn = approvedOn;
  IsApproved = isApproved;
  IsApproved = isApproved;
  PublishedBy = publishedBy;
  PublishedOn = publishedOn;
}
Peter K.
fuente
2
Esto no escala bien en absoluto. Mi objeto podría tener muchas más propiedades con cualquier número de ellas con o sin valores en cualquier punto dado del ciclo de vida del objeto. Sigo el principio de que los constructores contienen parámetros para las propiedades que se requieren para que el objeto esté en un estado inicial válido o dependencias que un objeto requiere para funcionar. El propósito de las propiedades en el ejemplo es capturar el estado actual a medida que se manipula el objeto. Tener un constructor con todas las propiedades o sobrecargas con diferentes combinaciones es un gran olor y, como dije, no escala.
SonOfPirate
Entendido. Su ejemplo no mencionó muchas más propiedades, y el número en el ejemplo está "en la cúspide" de tener esto como un enfoque válido. Parece que esto te dice algo sobre tu diseño: no puedes poner tu objeto en ningún estado válido en la instanciación. Eso significa que debe ponerlo en un estado inicial válido y manipularlo en el estado correcto para la prueba. Eso implica que la respuesta de Lie Ryan es el camino a seguir .
Peter K.
Incluso si el objeto tiene una propiedad y nunca cambiará, esta solución es mala. ¿Qué impide que alguien use este constructor en producción? ¿Cómo marcarás este constructor [TestOnly]?
Piotr Perak
¿Por qué es malo en la producción? (Realmente, me gustaría saber). A veces es necesario recrear el estado preciso de un objeto en la creación ... no solo un único objeto inicial válido.
Peter K.
1
Entonces, si bien eso ayuda a poner el objeto en un estado inicial válido, probar el comportamiento del objeto en su progreso a lo largo de su ciclo de vida requiere que el objeto se cambie de su estado inicial. Mi OP tiene que ver con probar estos estados adicionales cuando no puede simplemente establecer propiedades para cambiar el estado del objeto.
SonOfPirate
1

Una estrategia es que herede la clase (en este caso, Documento) y escriba pruebas contra la clase heredada. La clase heredada permite alguna forma de establecer el estado del objeto en las pruebas.

En C # una estrategia podría ser hacer que los setters sean internos y luego exponer los elementos internos para probar el proyecto.

También puede usar la API de clase como la describió ("Ciertamente puedo hacer esto llamando a Publicar dos veces"). Esto sería establecer el estado del objeto utilizando los servicios públicos del objeto, no me parece demasiado maloliente. En el caso de su ejemplo, esta sería probablemente la forma en que lo haría.

simoraman
fuente
Pensé en esto como una posible solución, pero dudé en anular mis propiedades o exponer a los colocadores como protegidos porque sentía que estaba abriendo el objeto y rompiendo la encapsulación. Creo que proteger las propiedades es ciertamente mejor que público o incluso interno / amigo. Definitivamente voy a pensar más en este enfoque. Es simple y efectivo. A veces ese es el mejor enfoque. Si alguien no está de acuerdo, agregue comentarios con detalles.
SonOfPirate
1

Para probar en aislamiento absoluto los comandos y consultas que reciben los objetos de dominio, estoy acostumbrado a proporcionar a cada prueba una serialización del objeto en el estado esperado. En la sección de organización de la prueba, carga el objeto a probar desde un archivo que he preparado previamente. Al principio comencé con las serializaciones binarias, pero json ha demostrado ser mucho más fácil de mantener. Esto demostró funcionar bien, siempre que el aislamiento absoluto en las pruebas proporcione un valor real.

edite solo una nota, algunas veces la serialización JSON falla (como en el caso de los gráficos de objetos cíclicos, eso es un olor, por cierto). En tales situaciones, rescato a la serialización binaria. Es un poco pragmático, pero funciona. :-)

Giacomo Tesio
fuente
¿Y cómo prepara el objeto en el estado esperado si no hay un configurador y no desea llamar a sus métodos públicos para configurarlo?
Piotr Perak
He escrito una pequeña herramienta para eso. Carga una clase por reflexión que crea una nueva entidad usando su constructor público (típicamente tomando solo el identificador) e invoca una matriz de Action <TEntity> en ella, guardando una instantánea después de cada operación (con un nombre convencional basado en el índice de la acción y el nombre de la entidad). La herramienta se ejecuta manualmente en cada refactorización del código de entidad y DCVS realiza un seguimiento de las instantáneas. Obviamente, cada acción llama a un comando público de la entidad, pero esto se hace a partir de las ejecuciones de pruebas que de esta manera son verdaderamente pruebas de unidad .
Giacomo Tesio
No entiendo cómo eso cambia algo. Si todavía llama métodos públicos en sut (sistema bajo prueba), entonces no es diferente, simplemente llama a esos métodos en la prueba.
Piotr Perak
Una vez que se producen las instantáneas, se almacenan en archivos. Cada prueba no depende de la secuencia de las operaciones requeridas para obtener el estado inicial de la entidad, sino del estado mismo (cargado desde la instantánea). El método bajo prueba se aísla de los cambios en los otros métodos.
Giacomo Tesio
¿Qué sucede cuando alguien cambia el método público que se utilizó para preparar el estado serializado para sus pruebas pero se olvida de ejecutar la herramienta para regenerar el objeto serializado? Las pruebas aún son verdes, incluso si hay un error en el código. Aún así digo que esto no cambia nada. Aún ejecuta métodos públicos, así que configure los objetos que prueba. Pero los ejecuta mucho antes de que se ejecuten las pruebas.
Piotr Perak
-7

Tu dices

intente aplicar las mejores prácticas siempre que sea posible

y

El ORM que estamos utilizando para hidratar los objetos usa la reflexión para que pueda acceder a setters privados

y tengo que pensar que usar la reflexión para evitar los controles de acceso en sus clases no es lo que describiría como "mejor práctica". También va a ser terriblemente lento.


Personalmente, descartaría su marco de prueba de unidad e iría con algo en la clase: parece que está escribiendo pruebas desde el punto de vista de probar toda la clase de todos modos, lo cual es bueno. En el pasado, para algunos componentes difíciles que necesitaban pruebas, he incrustado las afirmaciones y el código de configuración en la clase misma (solía ser un patrón de diseño común para tener un método test () en cada clase), por lo que crea un cliente eso simplemente crea una instancia de un objeto y llama al método de prueba, que puede configurarse como desee sin asperezas como hacks de reflexión.

Si le preocupa la acumulación de código, simplemente ajuste los métodos de prueba en #ifdefs para que solo estén disponibles en el código de depuración (probablemente una práctica recomendada en sí misma)

gbjbaanb
fuente
44
-1: Desechar el marco de prueba y volver a los métodos de prueba dentro de la clase volvería a la edad oscura de las pruebas unitarias.
Robert Johnson
99
No -1 de mi parte, pero incluir código de prueba en producción es generalmente una cosa mala (TM) .
Peter K.
¿Qué más hace el OP? ¿Te apetece follar con setters privados? Es como elegir qué veneno quieres beber. Mi sugerencia al OP fue poner la prueba de la unidad en el código de depuración, no en la producción. En mi experiencia, poner pruebas unitarias en un proyecto diferente solo significa que el proyecto se vincula estrechamente con el original de todos modos, por lo que desde un punto de vista del desarrollador, hay poca distinción.
gbjbaanb