Teste de unidade C ++ e padrões de injeção simulada usando traços

Olá novamente! Falta menos de uma semana para o início das aulas do grupo no curso "Desenvolvedor C ++" . Nesse sentido, continuamos a compartilhar material útil traduzido especificamente para os alunos deste curso.



O teste de unidade do seu código com modelos lembra-se de vez em quando. (Você está testando seus modelos, certo?) Alguns modelos são fáceis de testar. Alguns não são. Às vezes, há uma falta de clareza final em relação à implementação de mock-code (stub) no modelo testado. Eu observei várias razões pelas quais a incorporação de código se torna complicada.

Abaixo, dei alguns exemplos com aproximadamente crescente complexidade da implementação de código.

  1. O modelo usa um argumento de tipo e um objeto do mesmo tipo por referência no construtor.
  2. O modelo usa um argumento de tipo. Faz uma cópia do argumento do construtor ou simplesmente não o aceita.
  3. Um modelo usa um argumento de tipo e cria vários modelos interconectados sem funções virtuais.

Vamos começar com um simples.

O modelo usa um argumento de tipo e um objeto do mesmo tipo por referência no construtor


Este caso parece simples, porque o teste de unidade simplesmente cria uma instância do modelo de teste com o tipo de stub. Alguma declaração pode ser verificada para a classe simulada. E isso é tudo.

Naturalmente, o teste com apenas um argumento de tipo não diz nada sobre o restante do número infinito de tipos que podem ser passados ​​para o modelo. Uma maneira elegante de dizer a mesma coisa: os padrões são conectados por um quantificador de generalidade; portanto, talvez tenhamos que nos tornar um pouco mais perspicazes para testes mais científicos. Mais sobre isso mais tarde.

Por exemplo:

template <class T> class TemplateUnderTest { T *t_; public: TemplateUnderTest(T *t) : t_(t) {} void SomeMethod() { t->DoSomething(); t->DoSomeOtherThing(); } }; struct MockT { void DoSomething() { // Some assertions here. } void DoSomeOtherThing() { // Some more assertions here. } }; class UnitTest { void Test1() { MockT mock; TemplateUnderTest<MockT> test(&mock); test.SomeMethod(); assert(DoSomethingWasCalled(mock)); assert(DoSomeOtherThingWasCalled(mock)); } }; 


O modelo usa um argumento de tipo. Faz uma cópia do argumento do construtor ou simplesmente não o aceita


Nesse caso, o acesso ao objeto dentro do modelo pode não ser possível devido aos direitos de acesso. Você pode usar classes de friend .

 template <class T> class TemplateUnderTest { T t_; friend class UnitTest; public: void SomeMethod() { t.DoSomething(); t.DoSomeOtherThing(); } }; class UnitTest { void Test2() { TemplateUnderTest<MockT> test; test.SomeMethod(); assert(DoSomethingWasCalled(test.t_)); // access guts assert(DoSomeOtherThingWasCalled(test.t_)); // access guts } }; 

UnitTest :: Test2 tem acesso ao corpo de TemplateUnderTest e pode verificar instruções na cópia interna do MockT.

Um modelo usa um argumento de tipo e cria vários modelos interconectados sem funções virtuais


Para este caso, examinarei um exemplo do mundo real: RPC assíncrono do Google .

No C ++, o assíncrono gRPC possui algo chamado CallData, que, como o nome indica, armazena dados relacionados a uma chamada RPC . O modelo CallData pode manipular vários tipos diferentes de RPCs. Portanto, é natural que seja implementado precisamente pelo modelo.

Um CallData genérico aceita dois argumentos de tipo: Solicitação e Resposta. Pode ficar assim:

 template <class Request, class Response> class CallData { grpc::ServerCompletionQueue *cq_; grpc::ServerContext context_; grpc::ServerAsyncResponseWriter<Response> responder_; // ... some more state public: using RequestType = Request; using ResponseType = Response; CallData(grpc::ServerCompletionQueue *q) : cq_(q), responder_(&context_) {} void HandleRequest(Request *req); // application-specific code Response *GetResponse(); // application-specific code }; 

O teste de unidade para o modelo CallData deve verificar o comportamento de HandleRequest e HandleResponse. Essas funções invocam várias funções de membro. Portanto, verificar a integridade da chamada é fundamental para a integridade do CallData. No entanto, existem truques.

  1. Alguns tipos do namespace grpc são criados internamente e não são passados ​​pelo construtor. ServerAsyncResponseWriter e ServerContext , por exemplo.
  2. grpc :: ServerCompletionQueue é passado ao construtor como argumento, mas não possui funções virtuais. Apenas um destruidor virtual.
  3. grpc :: ServerContext é criado internamente e não possui funções virtuais.

A questão é como testar o CallData sem usar o gRPC completo nos testes? Como simular ServerCompletionQueue? Como simular ServerAsyncResponseWriter, que por si só é um modelo? e assim por diante ...

Sem funções virtuais, substituir o comportamento do usuário se torna uma tarefa complexa. Tipos codificados permanentemente, como grpc :: ServerAsyncResponseWriter, não podem ser modelados porque são, hmm, codificados permanentemente e não implementados.

Há pouco sentido em transmiti-los como argumentos construtores. Mesmo se você fizer isso, pode não fazer sentido, pois podem ser classes finais ou simplesmente não ter funções virtuais.

Então o que fazemos?

Solução: Traços




Em vez de incorporar o comportamento personalizado herdando de um tipo genérico (como é feito na programação orientada a objetos), INSIRA O TIPO. Nós usamos traços para isso. Somos especializados em características de diferentes maneiras, dependendo do tipo de código: um código de produção ou um código de teste de unidade.

Considere CallDataTraits

 template <class CallData> class CallDataTraits { using ServerCompletionQueue = grpc::ServerCompletionQueue; using ServerContext = grpc::ServerContext; using ServerAsyncResponseWriter = grpc::ServerAsyncResponseWrite<typename CallData::ResponseType>; }; 

Este é o modelo principal para a característica usada para o código de produção. Vamos usá-lo em um CallDatatemplate.

 /// Unit testable CallData template <class Request, class Response> class CallData { typename CallDataTraits<CallData>::ServerCompletionQueue *cq_; typename CallDataTraits<CallData>::ServerContext context_; typename CallDataTraits<CallData>::ServerAsyncResponseWriter responder_; // ... some more state public: using RequestType = Request; using ResponseType = Response; CallData(typename CallDataTraits::ServerCompletionQueue *q) : cq_(q), responder_(&context_) {} void HandleRequest(Request *req); // application-specific code Response *GetResponse(); // application-specific code }; 

Observando o código acima, fica claro que o código do aplicativo ainda usa tipos do namespace grpc. No entanto, podemos substituir facilmente os tipos grpc por tipos fictícios. Veja abaixo.

 /// In unit test code struct TestRequest{}; struct TestResponse{}; struct MockServerCompletionQueue{}; struct MockServerContext{}; struct MockServerAsyncResponseWriter{}; /// We want to unit test this type. using CallDataUnderTest = CallData<TestRequest, TestResponse>; /// A specialization of CallDataTraits for unit testing purposes only. template <> class CallDataTraits<CallDataUnderTest> { using ServerCompletionQueue = MockServerCompletionQueue; using ServerContext = MockServerContext; using ServerAsyncResponseWriter = MockServerAsyncResponseWrite; }; MockServerCompletionQueue mock_queue; CallDataUnderTest cdut(&mock_queue); // Now injected with mock types. 

As características nos permitiram escolher os tipos implementados no CallData, dependendo da situação. Este método não requer desempenho adicional, pois não foram criadas funções virtuais desnecessárias para adicionar funcionalidade. Esta técnica também pode ser usada nas aulas finais.

Como você gosta do material? Escreva comentários. E até a porta aberta ;-)

Source: https://habr.com/ru/post/pt457110/


All Articles