Qual é a abordagem correta para testar classes com herança?

5

Supondo que eu tenha a seguinte estrutura de classe (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 você pode ver, a função doThings na Classe base retorna resultados diferentes em cada Classe derivada devido aos diferentes valores de foo , enquanto o A função doOtherThings se comporta de maneira idêntica em todas as classes .

Quando desejo implementar testes de unidade para essas classes, o tratamento de doThings , doBarThings / doBazThings está claro para mim - eles precisam ser cobertos para cada classe derivada. Mas como doOtherThings deve ser tratado? É uma boa prática essencialmente duplicar o caso de teste em ambas as classes derivadas? O problema fica pior se houver meia dúzia de funções, como doOtherThings e mais Classes derivadas .

    
por CharonX 17.04.2018 / 11:40
fonte

3 respostas

3

Nos testes para BarDerived , você quer provar que todos os métodos (públicos) de BarDerived funcionam corretamente (para as situações que você testou). Da mesma forma para BazDerived .

O fato de alguns dos métodos serem implementados em uma classe base não altera essa meta de teste para BarDerived e BazDerived . Isso leva à conclusão de que Base::doOtherThings deve ser testado no contexto de BarDerived e BazDerived e que você obtém testes muito semelhantes para essa função.

A vantagem de testar doOtherThings para cada classe derivada é que, se os requisitos para BarDerived mudarem de tal forma que BarDerived::doOtherThings deva retornar 24, a falha de teste no BazDerived testcase informará que você pode estar quebrando os requisitos de outra classe.

    
por 17.04.2018 / 12:18
fonte
3

But how should doOtherThings be handled? Is it good practice to essentially duplicate the test case in both derived classes?

Eu normalmente esperaria que o Base tivesse sua própria especificação, que você pode verificar para qualquer implementação em conformidade, incluindo as classes 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()); }
    
por 17.04.2018 / 14:38
fonte
1

Você tem um conflito aqui.

Você deseja testar o valor de retorno de doThings() , que depende de um literal (valor const).

Qualquer teste que você escrever para isso é inerentemente reduzir o teste de um valor constante , que é sem sentido.

Para mostrar um exemplo mais sensato (sou mais rápido com C #, mas o princípio é o mesmo)

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

Esta classe pode ser testada de forma significativa:

var inputValue = 123;

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

Assert.AreEqual(receivedOutputValue, expectedOutputValue);

Isso faz mais sentido testar. Sua saída é baseada na entrada que você escolheu para fornecer. Nesse caso, você pode dar a uma classe uma entrada arbitrariamente escolhida, observar sua saída e testar se ela corresponde às suas expectativas.

Alguns exemplos desse princípio de teste. Observe que meus exemplos sempre têm controle direto sobre o que é a entrada do método testável.

  • Test if GetFirstLetterOfString() return "F" when I input "Flater".
  • Test if CountLettersInString() returns 6 when I input "Flater".
  • Test if ParseStringThatBeginsWithAnA() returns an exception when I input "Flater".

Todos esses testes podem inserir qualquer valor que quiserem , desde que suas expectativas estejam alinhadas com o que estão inserindo.

Mas se sua saída for decidida por um valor constante, você terá que criar uma expectativa constante e, em seguida, testar se a primeira corresponde à segunda. O que é bobo, isso é sempre ou nunca vai passar; nenhum dos quais é um resultado significativo.

Alguns exemplos desse princípio de teste. Observe que esses exemplos não têm controle sobre pelo menos um dos valores que estão sendo comparados.

  • Test if Math.Pi == 3.1415...
  • Test if MyApplication.ThisConstValue == 123

Esses testes valem para um valor específico um . Se você alterar esse valor, seus testes falharão. Em essência, você não está testando se sua lógica funciona para qualquer entrada válida, você está simplesmente testando se alguém é capaz de prever com precisão um resultado sobre o qual eles não têm controle.

Isso é essencialmente testar o conhecimento do autor do teste sobre a lógica de negócios. Não está testando o código, mas o próprio escritor.

Voltando ao seu exemplo:

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

Por que BarDerived sempre tem foo igual a 12 ? Qual é o significado disso?

E, como você já decidiu isso, o que você está tentando obter ao escrever um teste que confirme que BarDerived sempre tem foo igual a 12 ?

Isso fica ainda pior se você começar a considerar que doThings() pode ser substituído em uma classe derivada. Imagine se AnotherDerived substituísse doThings() para que sempre retornasse foo * 2 . Agora, você terá uma classe que é codificada como Base(12) , cujo valor doThings() é 24. Embora tecnicamente testável, ela é desprovida de qualquer significado contextual. O teste não é compreensível.

Eu realmente não consigo pensar em um motivo para usar essa abordagem de valor codificado. Mesmo se houver um caso de uso válido, não entendo por que você está tentando escrever um teste para confirmar esse valor codificado . Não há nada a ganhar testando se um valor constante é igual ao mesmo valor constante.

Qualquer falha de teste prova que o teste está errado . Não há nenhum resultado em que uma falha de teste comprove que a lógica de negócios está errada. Você é efetivamente incapaz de confirmar quais testes são criados para confirmar, em primeiro lugar.

O problema não tem nada a ver com herança, caso você esteja se perguntando. Você acabou de acontecer ter usado um valor const no construtor da classe base, mas você poderia ter usado esse valor const em qualquer outro lugar e então não estaria relacionado a uma classe herdada.

Editar

Existem casos em que os valores codificados não são um problema. (mais uma vez, desculpe pela sintaxe do C #, mas o princípio ainda é o mesmo)

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) {}
}

Enquanto o valor constante ( 2 / 3 ) ainda está influenciando o valor de saída de GetMultipliedValue() , o consumidor da sua classe ainda tem controle sobre ele também!
Neste exemplo, testes significativos ainda podem ser escritos:

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);
  • Tecnicamente, ainda estamos escrevendo um teste que verifica se a const em base(value, 2) corresponde à const em inputValue * 2 .
  • No entanto, estamos ao mesmo tempo testando também que essa classe está corretamente multiplicando qualquer valor determinado por esse fator predeterminado .

O primeiro ponto não é relevante para o teste. O segundo é!

    
por 17.04.2018 / 14:59
fonte