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

8

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 doThingsfunção na classe Base retorna resultados diferentes em cada classe Derived devido aos diferentes valores de foo, enquanto a doOtherThingsfunção se comporta de forma idêntica em todas as classes .

Quando eu quiser implementar testes de unidade para essas classes, a manipulação de doThings, doBarThings/ doBazThingsé claro para mim - eles precisam ser cobertas para cada classe derivada. Mas como deve doOtherThingsser tratado? É uma boa prática duplicar essencialmente o caso de teste nas duas classes derivadas? O problema piora se houver meia dúzia de funções como doOtherThingse mais classes derivadas .

CharonX
fonte
Você tem certeza de que apenas o dtor deve ser virtual?
Deduplicator
@Duplicador Para a simplicidade do exemplo, sim. A classe Base é / deve ser abstrata, com as classes derivadas fornecendo funcionalidade adicional ou implementações especializadas. BarDerivede Basepode ter sido uma vez a mesma classe. Quando uma funcionalidade semelhante era adicionada, a parte comum era movida para a classe Base, com diferentes especializações implementadas em cada classe Derivada.
CharonX
Em um exemplo menos abstrato, imagine uma classe que escreve HTML em conformidade com os padrões, mas foi decidido que também seria possível escrever HTML otimizado para <Browser> além da implementação "vanilla" (que faz algumas coisas de maneira diferente e faz não suporta todas as funções fornecidas pelo padrão). (Nota: Eu estou aliviado ao dizer que as classes reais que levam a esta pergunta não tem nada a ver com a escrita "navegador otimizado" HTML)
CharonX

Respostas:

3

Nos seus testes, BarDerivedvocê deseja provar que todos os métodos (públicos) BarDerivedfuncionam 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 esse objetivo de teste para BarDerivede BazDerived. Que leva à conclusão de que Base::doOtherThingsdevem ser testados tanto no contexto de BarDerivede BazDerivede que você começa testes muito semelhantes para essa função.

A vantagem do teste doOtherThingspara cada classe derivada é que, se os requisitos de BarDerivedmudança BarDerived::doOtherThingsdevem retornar 24, a falha do BazDerivedteste no testcase indica que você pode estar quebrando os requisitos de outra classe.

Bart van Ingen Schenau
fonte
2
E não há nada que o impeça de reduzir a duplicação, considerando o código de teste comum em uma única função chamada de ambos os casos de teste separados.
Sean Burton
1
IMHO toda essa resposta só será boa, adicionando claramente o comentário de @ SeanBurton, caso contrário, isso me parece uma violação flagrante do princípio DRY.
Doc Brown
3

Mas como as Outras Coisas devem ser tratadas? É uma boa prática duplicar essencialmente o caso de teste nas duas classes derivadas?

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()); }
VoiceOfUnreason
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 será inerentemente resumido ao testar um valor const , o que não faz 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 significativamente:

var inputValue = 123;

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

Assert.AreEqual(receivedOutputValue, expectedOutputValue);

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

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

  • Teste se GetFirstLetterOfString()retorne "F" quando insiro "Flater".
  • Teste se CountLettersInString()retorna 6 quando insiro "Flater".
  • Teste se ParseStringThatBeginsWithAnA()retorna uma exceção quando insiro "Flater".

Todos esses testes podem inserir o 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ê precisará criar uma expectativa constante e testar se o primeiro corresponde ao segundo. O que é bobagem, isso sempre ou nunca vai passar; nenhum dos quais é um resultado significativo.

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

  • Teste se Math.Pi == 3.1415...
  • Teste se MyApplication.ThisConstValue == 123

Esses testes para um valor específico. 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, está simplesmente testando se alguém é capaz de prever com precisão um resultado sobre o qual não tem controle.

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


Revertendo para o seu exemplo:

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

Por que BarDerivedsempre tem um fooigual a 12? Qual o significado disso?

E, como você já decidiu isso, o que você está tentando ganhar escrevendo um teste que confirma que BarDerivedsempre é fooigual a 12?

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

Eu realmente não consigo pensar em uma razão 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 no teste prova inerentemente que o teste está errado . Não há resultado em que uma falha no 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.

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


Editar

Há casos em que valores codificados permanentemente não são um problema. (novamente, 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 GetMultipliedValue(), o consumidor da sua classe ainda tem controle sobre ele também!
Neste exemplo, testes significativos ainda podem ser gravados:

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 in base(value, 2)corresponde à const in inputValue * 2.
  • No entanto, estamos ao mesmo tempo também testar que esta classe está correctamente multiplicando qualquer valor dado por este fator predeterminado .

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

Flater
fonte
Como já mencionado, essa é uma estrutura de classe bastante simplificada. Dito isto, deixe-me referir ao exemplo talvez menos abstrato: Imagine uma classe de escrita em HTML. Todos sabemos que padrão <e >chaves que encapsulam as tags HTML. Infelizmente (devido à insanidade), um navegador "especializado" usa ![{e }]!, em vez disso, a Intitech decide que você precisa oferecer suporte a esse navegador no seu gravador de HTML. Por exemplo, você tem a função getHeaderStart()e getHeaderEnd()que - até agora - retornou <HEADER>e <\HEADER>.
CharonX
@CharonX Você ainda precisa tornar publicamente configurável o tipo de tag (seja através de uma enumeração ou duas propriedades de string para as tags usadas ou qualquer coisa equivalente) para testar de forma significativa se a classe usa corretamente as tags. Caso contrário, seus testes serão preenchidos com valores constantes não documentados, necessários para que o teste funcione.
quer
Você pode alterar a classe e as funções simplesmente copiando e colando tudo - uma com <e a outra com ![{. Mas isso seria muito ruim. Assim, cada função deve inserir o (s) caractere (s) definido (s) em uma variável nos locais <e >iria e criar classes derivadas que - dependendo se são de padrão ou insanas, fornecem a função apropriada <HEADER>ou ![{HEADER}]!Nenhuma das duas getHeaderStart()depende da entrada e depende no conjunto constante durante a construção da classe derivada. Ainda assim, eu me sentiria desconfortável se você me disse que testá-las não faz sentido ...
CharonX
@CharonX Esse não é o ponto. O ponto é que sua saída (que obviamente decide se o teste passa) é baseada em um valor que o teste em si não tem controle. Caso contrário, você deve testar um valor de saída que não depende dessa const oculta. Seu código de exemplo está testando esse valor const, e mais nada. Isso está incorreto ou simplificado demais a ponto de não mostrar o objetivo real da saída.
quer
O teste getHeaderStart()faz sentido ou não?
CharonX