Interface de código de aplicativo com testes de unidade

8

Estou trabalhando em um projeto no qual temos que implementar e testar unidades algum novo módulo. Como eu tinha uma arquitetura bastante clara, escrevi rapidamente as principais classes e métodos e, em seguida, começamos a escrever testes de unidade.

Ao escrever os testes, tivemos que fazer algumas modificações no código original, como

  • Tornar públicos métodos privados para testá-los
  • Adicionando métodos extras para acessar variáveis ​​privadas
  • Adicionando métodos extras para injetar objetos simulados que devem ser usados ​​quando o código é executado dentro de um teste de unidade.

De alguma forma, sinto que estes são sintomas de que estamos fazendo algo errado, por exemplo

  1. o design inicial estava errado (algumas funcionalidades deveriam ter sido públicas desde o início),
  2. o código não foi projetado adequadamente para fazer interface com testes de unidade (talvez devido ao fato de termos começado a projetar os testes de unidade quando algumas classes já haviam sido projetadas),
  3. estamos implementando testes de unidade da maneira errada (por exemplo, testes de unidade devem apenas testar / abordar diretamente os métodos públicos de uma API, não os privados),
  4. uma mistura dos três pontos acima, e talvez algumas questões adicionais em que não tenha pensado.

Como tenho alguma experiência com testes de unidade, mas estou longe de ser um guru, ficaria muito interessado em ler seus pensamentos sobre essas questões.

Além das perguntas gerais acima, tenho algumas questões técnicas mais específicas:

Pergunta 1. Faz sentido testar diretamente um método privado m de uma classe A e até torná-lo público para testá-lo? Ou devo assumir que m é indiretamente testado por testes de unidade que abrangem outros métodos públicos que chamam m?

Pergunta 2. Se uma instância da classe A contém uma instância da classe B (agregação composta), faz sentido zombar de B para testar A? Minha primeira idéia foi que eu não deveria zombar de B porque a instância B faz parte da instância A, mas comecei a duvidar disso. Meu argumento contra zombar de B é o mesmo que para 1: B é privado wrt A e é usado apenas para sua implementação, portanto zombando de B parece que estou expondo detalhes particulares de A como em (1). Mas talvez esses problemas indiquem uma falha de design: talvez não devamos usar agregação composta, mas uma associação simples de A a B.

Pergunta 3. No exemplo acima, se decidirmos zombar de B, como injetamos a instância B em A? Aqui estão algumas idéias que tivemos:

  • Injete a instância B como um argumento para o construtor A em vez de criar a instância B no construtor A.
  • Passe uma interface BFactory como argumento para o construtor A e deixe A usar a fábrica para criar sua instância B privada.
  • Use um singleton BFactory privado para A. Use um método estático A :: setBFactory () para definir o singleton. Quando A deseja criar a instância B, ele usa o singleton de fábrica, se estiver definido (o cenário de teste), ele cria B diretamente se o singleton não estiver definido (o cenário de código de produção).

As duas primeiras alternativas me parecem mais limpas, mas exigem a alteração da assinatura do construtor A: alterar uma API apenas para torná-la mais testável me parece estranho, isso é uma prática comum?

O terceiro tem a vantagem de não exigir a alteração da assinatura do construtor (a alteração na API é menos invasiva), mas exige a chamada do método estático setBFactory () antes de iniciar o teste, que é propenso a erros da IMO ( dependência implícita de uma chamada de método para que os testes funcionem corretamente). Portanto, não sei qual devemos escolher.

Giorgio
fonte
Eu acho que o recurso de classe / função de amigo do C ++ pode ser útil. Você já tentou isso?
Mert Akcakaya
@Ert: Nós não tentamos isso. Pergunta: Ao usar o amigo, teríamos que declarar as classes de código de teste como amigas das principais classes de código. Isso está bom? Teríamos código de produção dependendo do código de teste. isso é uma boa ideia? Ou era outra solução que você tinha em mente?
Giorgio
Não sou especialista em C ++, apenas me veio à mente como uma solução simples.
Mert Akcakaya

Respostas:

8

Eu acho que testar métodos públicos é suficiente na maioria das vezes.

Se você possui muita complexidade em seus métodos particulares, considere colocá-los em outra classe como métodos públicos e use-os como chamadas particulares para esses métodos em sua classe original. Dessa forma, você pode garantir que os dois métodos nas classes original e utilitário funcionem corretamente.

Depender fortemente de métodos privados é algo a considerar sobre desicions de design.

Mert Akcakaya
fonte
Eu concordo com você: se alguém sente a necessidade de testar um método privado, talvez não deva ter sido privado em primeiro lugar e deve colocá-lo em uma classe de utilitário separada que deve ser testada separadamente.
Giorgio
O teste de todos os métodos públicos já deve testar (leia-se: capa ) todos os métodos privados. Senão, o que eles estão fazendo lá? :)
Amadeus Hein
1
@Amadeus Heing, Testar métodos públicos pode simplesmente chamar métodos privados, não testá-los.
Mert Akcakaya
2
@Ert Sim, mas, em geral, querer testar um método privado é um sinal de que algo está errado no código. Mais detalhes: link
Amadeus Hein 28/05
1
"Basear-se fortemente em métodos privados é algo a considerar sobre desicions de design.": No nosso caso, os métodos privados eram métodos utilitários que são chamados uma vez por um método público. Mas então nos perguntamos se seria mais robusto testá-los também. Mas, provavelmente, como muitos apontaram, faria sentido mover esses métodos para uma classe de utilidade e torná-los públicos se forem tão críticos que se queira testá-los.
Giorgio
5

Para a pergunta 1: isso depende. Normalmente, você começa com testes de unidade para métodos públicos. Às vezes, você encontra um método m que deseja manter privado para A, mas acha que faz sentido testar m isoladamente. Se for esse o caso, você deve tornar m público ou tornar a classe de teste TestAuma classe amiga A. Mas tenha cuidado, adicionar um teste de unidade para testar m torna um pouco mais difícil alterar a assinatura ou o comportamento de m posteriormente; se você deseja manter esse "detalhe de implementação" de A, talvez seja melhor não adicionar um teste de unidade direto.

Pergunta 2: A agregação composta (embutida em C ++) não funciona bem quando se trata de simular uma instância. De fato, como a construção do B acontece implicitamente no construtor de A, você não tem chance de injetar a dependência de fora. Se isso é um problema, depende da maneira como você deseja testar A: se você acha mais sensato testar A isoladamente, com uma simulação de B em vez de B, use melhor uma associação simples. Se você acha que pode escrever todos os testes de unidade necessários para A sem zombar de B, um composto provavelmente estará ok.

Pergunta 3: alterar uma API para tornar as coisas mais testáveis ​​é comum, desde que você não tenha muito código até agora confiando nessa API. Quando você faz TDD, não precisa alterar sua API posteriormente para tornar as coisas mais testáveis; você começa inicialmente com uma API projetada para testabilidade. Se você quiser alterar uma API posteriormente para tornar as coisas mais testáveis, poderá encontrar problemas, isso é verdade. Então, eu usaria a primeira ou a segunda das alternativas que você descreveu, desde que você possa alterar sua API sem problemas, e usaria algo como a terceira alternativa (observação: isso funciona também sem o padrão singleton) apenas como último recurso, se for necessário Não altere a API sob nenhuma circunstância.

Sobre suas preocupações de que você pode "fazer errado": todo grande mecanismo ou máquina tem aberturas de manutenção, então IMHO a necessidade de adicionar algo assim ao software não é muito surpreendente.

Doc Brown
fonte
2
+1 para o último parágrafo. Para um bom exemplo, como o mundo da eletrônica testa?
mattnz
+1: Obrigado por uma resposta muito bem motivada. Uma das minhas principais preocupações é que o código do aplicativo deve fornecer funcionalidade ao código do aplicativo, não ao código de teste: o código de teste deve observar o código do aplicativo sem impor requisitos nele. Obviamente, você pode ter alguns requisitos para tornar o código mais observável, mas esses devem ser realmente mínimos. Veja o exemplo composto: O IMO deve escolher uma associação simples escrita composta com base nos requisitos do domínio do aplicativo, não na testabilidade. Os requisitos de aplicação de dobra da IMO para testar os requisitos devem ser o último recurso.
Giorgio
1
@ Giorgio: seu equívoco aqui é que o uso de uma associação versus um composto tem algo a ver com requisitos de domínio - você pode atender a quaisquer requisitos de domínio com ambos os tipos de design. Tornar o software mais testável não é algo que você pode esperar obter apenas com o mínimo de alterações. Se você fizer certo, isso definitivamente influenciará seu software no nível do design.
Doc Brown
@ Brown Doc: Bem, se A <> - B é um composto, instâncias de B só podem existir se gerenciadas por instâncias de A e sua vida útil é controlada pela vida de A. Isso pode ser um requisito de domínio. Por outro lado, uma associação simples de "uso" A -> B não impõe que uma instância A deve gerenciar uma instância B. Talvez no nosso caso houvesse realmente uma falha na análise do domínio do aplicativo (deveríamos usar uma associação em vez da composição).
Giorgio
@ Giorgio: você pode ter um requisito para que as instâncias de A e B tenham a mesma vida útil. Mas como você o cumpre, não há nenhum requisito de domínio que o force a resolver isso usando a forma de composição incorporada em C ++. Se você deseja testar A e B isoladamente, então - pelo menos para o seu teste - você precisa ter uma instância de A sem B e vice-versa. Portanto, nesse caso, é melhor usar um mecanismo de tempo de execução para controlar a vida útil desses objetos (por exemplo, usando ponteiros inteligentes) em vez de um mecanismo de tempo de compilação.
Doc Brown
1

Você deve procurar injeção de dependência e inversão de controle. Misko Hevery explica muito disso em seu blog. DI e IoC são efetivamente um padrão de design para criar código fácil de testar e simular.

Pergunta 1: Não, não torne públicos métodos privados para testá-los. Se o método for suficientemente complexo, você poderá criar uma classe de colaborador que contenha apenas esse método e injetá-lo (passar para o construtor) em sua outra classe. 1 1

Pergunta 2: Quem constrói A neste caso? Se você tem uma classe de fábrica / construtor que constrói A, não há mal nenhum em passar o colaborador B para o construtor. Se A estiver em um pacote / namespace separado do código que o utiliza, você pode tornar o construtor package-private e torná-lo para que o factory / builder seja a única classe que pode construí-lo.

Pergunta 3: Eu respondi isso na Pergunta 2. Algumas notas extras:

  • O uso de um padrão construtor / fábrica permite fazer a injeção de dependência o quanto você quiser, sem ter que se preocupar em dificultar o uso de código usando sua classe.
  • Ele separa o tempo de construção do objeto do tempo de uso do objeto, o que significa que o código usando sua API pode ser mais simples.

1 Esta é a resposta C # / Java - C ++ pode ter recursos extras que facilitam essa

Como resposta aos seus comentários:

O que eu quis dizer foi que seu código de produção seria alterado (por favor, perdoe meu pseudo-C ++):

void MyClass::MyUseOfA()
{
  A* a = new A();
  a->SomeMethod();
}

A::A()
{
  m_b = new B();
}

para:

void MyClass::MyUseOfA()
{
  A* a = AFactory.NewA();
  a->SomeMethod();
}

A* AFactory::NewA()
{
  // Construct dependencies
  B* b = new B();
  return new A(b);
}

A::A(B* b)
{
  m_b = b;
}

Então seu teste pode ser:

void MyTest::TestA()
{
  MockB* b = new MockB();
  b->SetSomethingInteresting(somethingInteresting);

  A* a = new A(b);

  a->DoSomethingInteresting();

  b->DidSomethingInterestingHappen();
}

Dessa forma, você não precisa passar pela fábrica, o código que chama A não precisa saber como construir A e o teste pode construir A personalizado para permitir que o comportamento seja testado.

Em seu outro comentário, você perguntou sobre dependências aninhadas. Então, por exemplo, se suas dependências eram:

A -> C -> D -> B

A primeira pergunta a fazer é se A usa C e D. Se não estão, por que estão incluídos em A? Supondo que eles sejam usados, talvez seja necessário passar em C em sua fábrica e fazer com que seu teste construa um MockC que retorne um MockB, permitindo testar todas as interações possíveis.

Se isso estiver começando a ficar complicado, pode ser um sinal de que seu design talvez esteja acoplado com muita força. Se você pode afrouxar o acoplamento e manter a coesão alta, esse tipo de DI fica mais fácil de implementar.

Bringer128
fonte
Com relação à resposta 2: você quer dizer que o código de produção e o código de teste usariam duas implementações de fábrica diferentes para construir A, onde (1) a fábrica de códigos de produção injetaria a instância de produção B e (2) a fábrica de códigos de teste injetaria o B instância simulada. Na verdade, a instância B está profundamente aninhada em uma árvore de composição. Eu teria que passar a fábrica através de vários níveis da árvore de composição. Instâncias de A são construídos por seu objeto pai (alguma outra classe)
Giorgio
Com relação à pergunta 3, um de nossos problemas é como a fábrica B deve ser injetada em A: usando um argumento construtor no construtor A para definir uma referência local para a fábrica ou como um singleton que A acessa quando precisa usar a fábrica .
Giorgio
@Giorgio Veja minha atualização com seus comentários. Sem conhecer seu exemplo específico, meus exemplos genéricos podem não se aplicar, mas esse é o tipo de abordagem que eu adotaria para ver se posso simplificar o problema de teste.
Bringer128
Muito obrigado pelo seu exemplo (acho o pseudocódigo OK). Duas observações: (1) Por que usar uma fábrica no código de produção e um construtor simples no código de teste? (2) A hierarquia de composição é C -> D -> A -> B, e o usuário de C deve fornecer a instância MockB que deve ser injetada de C em A.
Giorgio
(1) A fábrica deve ocultar o aspecto de DI do código que usa A. Ele tem como objetivo evitar que o código se torne mais complicado para adicionar DI. Para ser mais preciso, permite abstrair o gerenciamento de dependências de A, B, até C e D. (2) O exemplo que você está dando não é realmente um teste de unidade em si. Se você estiver invocando métodos apenas em C, será muito mais difícil obter uma alta cobertura de teste em A. Até você é importante, mas um teste de unidade deve apenas testar A, suas interações com B e seus valores de retorno.
Bringer128