¿Cuál es el enfoque correcto para evaluar las clases con herencia?

8

Suponiendo que tengo la siguiente estructura de clase (sobre simplificada):

class Base
{
  public:
    Base(int valueForFoo) : foo(valueForFoo) { };
    virtual ~Base() = 0;
    int doThings() { return foo; };
    int doOtherThings() { return 42; };

  protected:
    int foo;
}

class BarDerived : public Base
{
  public:
    BarDerived() : Base(12) { };
    ~BarDerived() { };
    int doBarThings() { return foo + 1; };
}

class BazDerived : public Base
{
  public:
    BazDerived() : Base(25) { };
    ~BazDerived() { };
    int doBazThings() { return 2 * foo; };
}

Como puede ver, la doThingsfunción en la clase Base devuelve resultados diferentes en cada clase Derivada debido a los diferentes valores de foo, mientras que la doOtherThingsfunción se comporta de manera idéntica en todas las clases .

Cuando deseo de implementar las pruebas unitarias para estas clases, el manejo de doThings, doBarThings/ doBazThingses claro para mí - que necesitan ser cubiertos por cada clase derivada. Pero, ¿cómo se debe doOtherThingsmanejar? ¿Es una buena práctica duplicar esencialmente el caso de prueba en ambas clases derivadas? El problema empeora si hay media docena de funciones doOtherThingsy más clases derivadas .

CharonX
fuente
¿Estás seguro de que solo debería ser el dtor virtual?
Deduplicador
@Deduplicator Por la simplicidad del ejemplo, sí. La clase Base es / debería ser abstracta, y las clases derivadas proporcionan funcionalidad adicional o implementaciones especializadas. BarDerivedy Basepuede haber sido una vez la misma clase. Cuando se iba a agregar una funcionalidad similar, la parte común se movía a la clase Base, con diferentes especializaciones implementadas en cada clase Derivada.
CharonX
En un ejemplo menos abstracto, imagine una clase que escriba HTML que cumpla con los estándares, pero luego se decidió que también debería ser posible escribir HTML optimizado para <Browser> además de la implementación "vainilla" (que hace algunas cosas de manera diferente y hace no es compatible con todas las funciones proporcionadas por el estándar). (Nota: me alivia decir que las clases reales que conducen a estas preguntas no tienen nada que ver con escribir HTML "optimizado para el navegador")
CharonX

Respuestas:

3

En sus pruebas BarDerived, quiere demostrar que todos los métodos (públicos) BarDerivedfuncionan correctamente (para las situaciones que ha probado). Del mismo modo para BazDerived.

El hecho de que algunos de los métodos se implementen en una clase base no cambia este objetivo de prueba para BarDerivedy BazDerived. Eso lleva a la conclusión de que Base::doOtherThingsdebe probarse tanto en el contexto de BarDerivedy BazDerivedcomo para obtener pruebas muy similares para esa función.

La ventaja de las pruebas doOtherThingspara cada clase derivada es que si los requisitos para el BarDerivedcambio BarDerived::doOtherThingsdeben devolver 24, la falla de la prueba en el caso de BazDerivedprueba le indica que podría estar incumpliendo los requisitos de otra clase.

Bart van Ingen Schenau
fuente
2
Y no hay nada que le impida reducir la duplicación al factorizar el código de prueba común en una sola función que se llama desde ambos casos de prueba separados.
Sean Burton el
1
En mi humilde opinión, toda esta respuesta solo será buena al agregar claramente el comentario de @ SeanBurton, de lo contrario, me parece una violación flagrante del principio DRY.
Doc Brown
3

Pero, ¿cómo deben manejarse las otras cosas? ¿Es una buena práctica duplicar esencialmente el caso de prueba en ambas clases derivadas?

Normalmente esperaría que Base tuviera su propia especificación, que puede verificar para cualquier implementación conforme, incluidas las clases derivadas.

void verifyBaseCompliance(const Base & systemUnderTest) {
    // checks that systemUnderTest conforms to the Base API
    // specification
}

void testBase () { verifyBaseCompliance(new Base()); }
void testBar () { verifyBaseCompliance(new BarDerived()); }
void testBaz () { verifyBaseCompliance(new BazDerived()); }
VoiceOfUnreason
fuente
1

Tienes un conflicto aquí.

Desea probar el valor de retorno de doThings(), que se basa en un literal (valor constante).

Cualquier prueba que escriba para esto se reducirá inherentemente a la prueba de un valor constante , lo cual no tiene sentido.


Para mostrarle un ejemplo más sensible (soy más rápido con C #, pero el principio es el mismo)

public class TriplesYourInput : Base
{
    public TriplesYourInput(int input)
    {
        this.foo = 3 * input;
    }
}

Esta clase se puede probar de manera significativa:

var inputValue = 123;

var expectedOutputValue = inputValue * 3;
var receivedOutputValue = new TriplesYourInput(inputValue).doThings();

Assert.AreEqual(receivedOutputValue, expectedOutputValue);

Esto tiene más sentido para probar. Su salida se basa en la entrada que eligió darle. En tal caso, puede dar a una clase una entrada elegida arbitrariamente, observar su salida y probar si coincide con sus expectativas.

Algunos ejemplos de este principio de prueba. Tenga en cuenta que mis ejemplos siempre tienen control directo sobre cuál es la entrada del método comprobable.

  • Pruebe si GetFirstLetterOfString()devuelve "F" cuando ingreso "Flater".
  • Pruebe si CountLettersInString()devuelve 6 cuando ingreso "Flater".
  • Probar si ParseStringThatBeginsWithAnA()devuelve una excepción cuando ingreso "Flater".

Todas estas pruebas pueden ingresar cualquier valor que deseen , siempre que sus expectativas estén en línea con lo que están ingresando.

Pero si su salida se decide por un valor constante, entonces tendrá que crear una expectativa constante y luego probar si el primero coincide con el segundo. Lo cual es una tontería, esto siempre o nunca va a pasar; ninguno de los cuales es un resultado significativo.

Algunos ejemplos de este principio de prueba. Observe que estos ejemplos no tienen control sobre al menos uno de los valores que se están comparando.

  • Prueba si Math.Pi == 3.1415...
  • Prueba si MyApplication.ThisConstValue == 123

Estas pruebas para un valor particular. Si cambia este valor, sus pruebas fallarán. En esencia, no está probando si su lógica funciona para cualquier entrada válida, simplemente está probando si alguien puede predecir con precisión un resultado sobre el cual no tiene control.

Eso es esencialmente probar el conocimiento del escritor de prueba de la lógica empresarial. No está probando el código, sino el propio escritor.


Volviendo a su ejemplo:

class BarDerived : public Base
{
  public:
    BarDerived() : Base(12) { };
    ~BarDerived() { };
    int doBarThings() { return foo + 1; };
}

¿Por qué BarDerivedsiempre tiene un fooigual a 12? ¿Cuál es el significado de este?

Y dado que ya has decidido esto, ¿qué estás tratando de ganar escribiendo una prueba que confirme que BarDerivedsiempre tiene un fooigual 12?

Esto empeora aún más si comienzas a tener en cuenta que doThings()se puede anular en una clase derivada. Imagínese si AnotherDerivedanulara doThings()para que siempre regrese foo * 2. Ahora, vas a tener una clase que está codificada como Base(12), cuyo doThings()valor es 24. Si bien es técnicamente comprobable, carece de significado contextual. La prueba no es comprensible.

Realmente no puedo pensar en una razón para usar este enfoque de valor codificado. Incluso si hay un caso de uso válido, no entiendo por qué está intentando escribir una prueba para confirmar este valor codificado . No hay nada que ganar al probar si un valor constante es igual al mismo valor constante.

Cualquier falla en la prueba demuestra inherentemente que la prueba es incorrecta . No hay resultado cuando una falla en la prueba demuestra que la lógica del negocio es incorrecta. De hecho, no puede confirmar qué pruebas se crean para confirmar en primer lugar.

El problema no tiene nada que ver con la herencia, en caso de que te lo estés preguntando. Que acaba de pasar a haber utilizado un valor const en el constructor de la clase base, pero que podría haber utilizado este valor const cualquier otro lugar y entonces no estaría relacionado con una clase heredada.


Editar

Hay casos en que los valores codificados no son un problema. (de nuevo, perdón por la sintaxis de C # pero el principio sigue siendo el mismo)

public class Base
{
    public int MultiplyFactor;
    protected int InitialValue;

    public Base(int value, int factor)
    {
        this.InitialValue = value;
        this.MultiplyFactor= factor;
    }

    public int GetMultipliedValue()
    {
         return this.InitialValue * this.MultiplyFactor;
    }
}

public class DoublesYourNumber : Base
{
    public DoublesYourNumber(int value) :  base(value, 2) {}
}

public class TriplesYourNumber : Base
{
    public TriplesYourNumber(int value) : base(value, 3) {}
}

Si bien el valor constante ( 2/ 3) todavía influye en el valor de salida de GetMultipliedValue(), el consumidor de su clase todavía tiene control sobre él.
En este ejemplo, aún se pueden escribir pruebas significativas:

var inputValue = 123;

var expectedDoubledOutputValue = inputValue * 2;
var receivedDoubledOutputValue = new DoublesYourNumber(inputValue).GetMultipliedValue();

Assert.AreEqual(expectedDoubledOutputValue , receivedDoubledOutputValue);

var expectedTripledOutputValue = inputValue * 3;
var receivedTripledOutputValue = new TriplesYourNumber(inputValue).GetMultipliedValue();

Assert.AreEqual(expectedTripledOutputValue , receivedTripledOutputValue);
  • Técnicamente, todavía estamos escribiendo una prueba que verifica si la constante en base(value, 2)coincide con la constante inputValue * 2.
  • Sin embargo, al mismo tiempo también estamos probando que esta clase está multiplicando correctamente cualquier valor dado por este factor predeterminado .

El primer punto no es relevante para la prueba. El segundo es!

Flater
fuente
Como ya se mencionó, esta es una estructura de clase bastante simplificada. Dicho esto, permítanme referirme al ejemplo quizás menos abstracto: imagine una clase de escritura HTML. Todos conocemos ese estándar <y >llaves que encapsulan las etiquetas HTML. Lamentablemente (debido a la locura) utiliza un navegador "especializado" ![{y }]!, en su lugar, Intitech decide que debe admitir este navegador en su escritor HTML. Por ejemplo, tiene la función getHeaderStart()y getHeaderEnd()que, hasta ahora, devolvió <HEADER>y <\HEADER>.
CharonX
@CharonX Aún necesitaría hacer que el tipo de etiqueta (ya sea a través de una enumeración, o dos propiedades de cadena para las etiquetas usadas, o cualquier cosa equivalente) sea públicamente configurable para probar significativamente si la clase usa correctamente las etiquetas. Si no lo hace, sus pruebas estarán llenas de valores constantes indocumentados que son necesarios para que la prueba funcione.
Flater
Puede alterar la clase y las funciones simplemente copiando y pegando todo, una vez con <, la otra con ![{. Pero eso sería bastante malo. Entonces, cada función inserta el (los) carácter (s) establecido (s) en una variable en los lugares <e >iría, y crea clases derivadas que, dependiendo de si son de conformidad estándar o dementes, proporcionan la función apropiada <HEADER>o ![{HEADER}]!ninguna de getHeaderStart()las dos depende de la entrada y depende en el conjunto constante durante la construcción de la clase derivada. Aún así, me sentiría incómodo si me
dijeras
@CharonX Ese no es el punto. El punto es que su salida (que obviamente decide si la prueba pasa) se basa en un valor sobre el cual la prueba en sí no tiene control. De lo contrario, debería probar un valor de salida que no se base en esta constante oculta. Su código de ejemplo está probando este valor constante, no otra cosa. Eso es incorrecto o demasiado simplificado hasta el punto de no mostrar el propósito real de la salida.
Flater
¿Las pruebas getHeaderStart()tienen sentido o no?
CharonX