¿Cómo estructura las pruebas unitarias para múltiples objetos que exhiben el mismo comportamiento?

9

En muchos casos, podría tener una clase existente con algún comportamiento:

class Lion
{
    public void Eat(Herbivore herbivore) { ... }
}

... y tengo una prueba unitaria ...

[TestMethod]
public void Lion_can_eat_herbivore()
{
    var herbivore = buildHerbivoreForEating();
    var test = BuildLionForTest();
    test.Eat(herbivore);
    Assert.IsEaten(herbivore);
}

Ahora, lo que sucede es que necesito crear una clase Tiger con un comportamiento idéntico al del León:

class Tiger
{
    public void Eat(Herbivore herbivore) { ... }
}

... y como quiero el mismo comportamiento, necesito ejecutar la misma prueba, hago algo como esto:

interface IHerbivoreEater
{
    void Eat(Herbivore herbivore);
}

... y refactorizo ​​mi prueba:

[TestMethod]
public void Lion_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildLionForTest);
}


public void IHerbivoreEater_can_eat_herbivore(Func<IHerbivoreEater> builder)
{
    var herbivore = buildHerbivoreForEating();
    var test = builder();
    test.Eat(herbivore);
    Assert.IsEaten(herbivore);
}

... y luego agrego otra prueba para mi nueva Tigerclase:

[TestMethod]
public void Tiger_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildTigerForTest);
}

... y luego refactorizo ​​my Lionand Tigerclasses (generalmente por herencia, pero a veces por composición):

class Lion : HerbivoreEater { }
class Tiger : HerbivoreEater { }

abstract class HerbivoreEater : IHerbivoreEater
{
    public void Eat(Herbivore herbivore) { ... }
}

... y todo está bien. Sin embargo, dado que la funcionalidad ahora está en la HerbivoreEaterclase, ahora parece que hay algo malo en tener pruebas para cada uno de estos comportamientos en cada subclase. Sin embargo, son las subclases las que realmente se están consumiendo, y es solo un detalle de implementación que comparten comportamientos superpuestos ( Lionsy Tigerspueden tener usos finales totalmente diferentes, por ejemplo).

Parece redundante probar el mismo código varias veces, pero hay casos en los que la subclase puede anular la funcionalidad de la clase base y lo hace (sí, podría violar el LSP, pero admitámoslo, IHerbivoreEateres solo una interfaz de prueba conveniente: es puede no ser importante para el usuario final). Entonces, estas pruebas tienen algún valor, creo.

¿Qué hacen otras personas en esta situación? ¿Simplemente mueve su prueba a la clase base, o prueba todas las subclases para el comportamiento esperado?

EDITAR :

Basado en la respuesta de @pdr, creo que deberíamos considerar esto: IHerbivoreEateres solo un contrato de firma de método; No especifica el comportamiento. Por ejemplo:

[TestMethod]
public void Tiger_eats_herbivore_haunches_first()
{
    IHerbivoreEater_eats_herbivore_haunches_first(BuildTigerForTest);
}

[TestMethod]
public void Cheetah_eats_herbivore_haunches_first()
{
    IHerbivoreEater_eats_herbivore_haunches_first(BuildCheetahForTest);
}

[TestMethod]
public void Lion_eats_herbivore_head_first()
{
    IHerbivoreEater_eats_herbivore_head_first(BuildLionForTest);
}
Scott Whitlock
fuente
En aras de la discusión, ¿no debería tener una Animalclase que contiene Eat? Todos los animales comen y, por lo tanto, la clase Tigery Lionpodría heredar de los animales.
The Muffin Man el
1
@Nick: ese es un buen punto, pero creo que es una situación diferente. Como señaló @pdr, si coloca el Eatcomportamiento en la clase base, entonces todas las subclases deberían exhibir el mismo Eatcomportamiento. Sin embargo, estoy hablando de 2 clases relativamente no relacionadas que comparten un comportamiento. Considere, por ejemplo, el Flycomportamiento de Bricky Personque, podemos suponer, exhibe un comportamiento de vuelo similar, pero no necesariamente tiene sentido que se deriven de una clase base común.
Scott Whitlock

Respuestas:

6

Esto es genial porque muestra cómo las pruebas realmente impulsan su forma de pensar sobre el diseño. Está detectando problemas en el diseño y haciendo las preguntas correctas.

Hay dos formas de ver esto.

IHerbivoreEater es un contrato. Todos los IHerbivoreEaters deben tener un método Eat que acepte un Herbivore. Ahora, a sus pruebas no les importa cómo se comen; tu León podría comenzar con las ancas y el Tigre podría comenzar en la garganta. Lo único que le importa a su prueba es que después de llamar a Eat, se coma al herbívoro.

Por otro lado, parte de lo que está diciendo es que todos los comedores de herbívoros comen al herbívoro exactamente de la misma manera (de ahí la clase base). Siendo ese el caso, no tiene sentido tener un contrato IHerbivoreEater en absoluto. No ofrece nada. También puedes heredar de HerbivoreEater.

O tal vez acabar con Lion and Tiger por completo.

Pero, si Lion y Tiger son diferentes en todos los sentidos, aparte de sus hábitos alimenticios, entonces debe comenzar a preguntarse si va a tener problemas con un árbol de herencia complejo. ¿Qué sucede si también desea derivar ambas clases de Feline, o solo la clase Lion de KingOfItsDomain (junto con Shark, tal vez)? Aquí es donde realmente entra LSP.

Sugeriría que el código común está mejor encapsulado.

public class Lion : IHerbivoreEater
{
    private IHerbivoreEatingStrategy _herbivoreEatingStrategy;
    private Lion (IHerbivoreEatingStrategy herbivoreEatingStrategy)
    {
        _herbivoreEatingStrategy = herbivoreEatingStrategy;
    }

    public Lion() : this(new StandardHerbivoreEatingStrategy())
    {
    }

    public void Eat(Herbivore herbivore)
    {
        _herbivoreEatingStrategy.Eat(herbivore);
    }
}

Lo mismo va para Tiger.

Ahora, aquí hay algo hermoso en desarrollo (hermoso porque no tenía la intención). Si solo hace que ese constructor privado esté disponible para la prueba, puede pasar una IHerbivoreEatingStrategy falsa y simplemente probar que el mensaje se pasa al objeto encapsulado correctamente.

Y su prueba compleja, la que le preocupaba en primer lugar, solo tiene que probar la estrategia StandardHerbivoreEatingStrategy. Una clase, un conjunto de pruebas, sin código duplicado de qué preocuparse.

Y si, más adelante, quieres decirle a los Tigres que deberían comer sus herbívoros de una manera diferente, ninguna de estas pruebas tiene que cambiar. Simplemente está creando una nueva estrategia HerbivoreEatingStrategy y está probando eso. El cableado se prueba a nivel de prueba de integración.

pdr
fuente
+1 El patrón de estrategia fue lo primero que se me ocurrió al leer la pregunta.
StuperUser
Muy bien, pero ahora reemplace "prueba de unidad" en mi pregunta con "prueba de integración". ¿No terminamos con el mismo problema? IHerbivoreEateres un contrato, pero solo en la medida en que lo necesito para probar. Me parece que este es un caso donde la escritura de patos realmente ayudaría. Solo quiero enviarlos a ambos a la misma lógica de prueba ahora. No creo que la interfaz deba prometer el comportamiento. Las pruebas deberían hacer eso.
Scott Whitlock, el
Gran pregunta, gran respuesta. No tienes que tener un constructor privado; puede conectar IHerbivoreEatingStrategy a StandardHerbivoreEatingStrategy utilizando un contenedor de IoC.
azheglov
@ScottWhitlock: "No creo que la interfaz deba prometer el comportamiento. Las pruebas deberían hacer eso". Eso es exactamente lo que estoy diciendo. Si promete el comportamiento, debe deshacerse de él y simplemente usar la clase (base). No lo necesitas para probar en absoluto.
pdr
@azheglov: De acuerdo, pero mi respuesta ya fue suficiente :)
pdr
1

Parece redundante probar el mismo código varias veces, pero hay casos en los que la subclase puede anular y anula la funcionalidad de la clase base

De manera indirecta, se pregunta si es apropiado utilizar el conocimiento de la caja blanca para omitir algunas pruebas. Desde una perspectiva de caja negra, Liony Tigerson diferentes clases. Entonces, alguien que no esté familiarizado con el código los probaría, pero usted con un conocimiento profundo de implementación sabe que puede salirse con la suya simplemente probando un animal.

Parte de la razón para desarrollar pruebas unitarias es permitirte refactorizar más tarde pero mantener la misma interfaz de caja negra . Las pruebas unitarias lo ayudan a garantizar que sus clases continúen cumpliendo con su contrato con los clientes, o al menos lo obliga a darse cuenta y pensar cuidadosamente cómo podría cambiar el contrato. Usted mismo se da cuenta de eso Liono Tigerpuede anularlo Eaten algún momento posterior. Si esto es remotamente posible, una simple prueba de prueba unitaria que cada animal que apoya puede comer, de la siguiente manera:

[TestMethod]
public void Tiger_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildTigerForTest);
}

debe ser muy simple de hacer y suficiente y garantizará que pueda detectar cuándo los objetos no cumplen con su contrato.

Doug T.
fuente
Me pregunto si esta pregunta realmente se reduce a una preferencia de prueba de caja negra frente a prueba de caja blanca. Me inclino hacia el campamento de caja negra, por lo que tal vez estoy probando la forma en que estoy. Gracias por señalar eso.
Scott Whitlock, el
1

Lo estás haciendo bien. Piense en una prueba unitaria como la prueba del comportamiento de un solo uso de su nuevo código. Esta es la misma llamada que haría desde el código de producción.

En esa situación, tienes toda la razón; un usuario de un León o un Tigre no (al menos no tendrá que preocuparse) de que ambos sean HerbivoreEaters y que el código que realmente se ejecuta para el método sea común para ambos en la clase base. Del mismo modo, a un usuario de un resumen HerbivoreEater (proporcionado por el León o el Tigre concreto) no le importará lo que tenga. Lo que les importa es que su implementación de León, Tigre o concreto desconocido de HerbivoreEater se comerá () un herbívoro correctamente.

Entonces, lo que básicamente está probando es que un león comerá según lo previsto, y que un tigre comerá según lo previsto. Es importante probar ambos, porque no siempre es cierto que ambos comen exactamente de la misma manera; Al probar ambos, se asegura de que el que no desea cambiar, no lo hizo. Debido a que ambos son los HerbivoreEaters definidos, al menos hasta que agregue Cheetah, también ha probado que todos los HerbivoreEaters comerán según lo previsto. Su prueba cubre completamente y ejercita adecuadamente el código (siempre que también haga todas las afirmaciones esperadas de lo que debería resultar de un HerbivoreEater comiendo un Herbivore).

KeithS
fuente