Testes em C ++ sem macros e memória dinâmica

Muitas bibliotecas populares para teste, por exemplo, Google Test, Catch2, Boost.Test, estão fortemente ligadas ao uso de macros; portanto, como um exemplo de testes nessas bibliotecas, você geralmente vê uma imagem como esta:


namespace { // Tests the default c'tor. TEST(MyString, DefaultConstructor) { const MyString s; EXPECT_STREQ(nullptr, s.c_string()); EXPECT_EQ(0u, s.Length()); } const char kHelloString[] = "Hello, world!"; // Tests the c'tor that accepts a C string. TEST(MyString, ConstructorFromCString) { const MyString s(kHelloString); EXPECT_EQ(0, strcmp(s.c_string(), kHelloString)); EXPECT_EQ(sizeof(kHelloString)/sizeof(kHelloString[0]) - 1, s.Length()); } // Tests the copy c'tor. TEST(MyString, CopyConstructor) { const MyString s1(kHelloString); const MyString s2 = s1; EXPECT_EQ(0, strcmp(s2.c_string(), kHelloString)); } } // namespace 

As macros em C ++ são cautelosas, por que elas são tão prósperas nas bibliotecas para criar testes?


A biblioteca de teste de unidade deve fornecer aos usuários uma maneira de escrever testes para que o tempo de execução do teste possa encontrá-los e executá-los de alguma forma. Quando você pensa em como fazer isso, o uso de macros parece ser mais fácil. A macro TEST () geralmente define de alguma forma uma função (no caso do Google Test, a macro também cria uma classe) e garante que o endereço dessa função chegue a algum contêiner global.


A conhecida biblioteca na qual a abordagem sem uma única macro é implementada é a estrutura do tut . Vamos ver o exemplo dela no tutorial:


 #include <tut/tut.hpp> namespace tut { struct basic{}; typedef test_group<basic> factory; typedef factory::object object; } namespace { tut::factory tf("basic test"); } namespace tut { template<> template<> void object::test<1>() { ensure_equals("2+2=?", 2+2, 4); } } 

A ideia subjacente é bastante interessante e funciona, não é muito difícil. Em resumo, você tem uma classe base que implementa uma função de modelo que envolve parametrização com um número inteiro:


 template <class Data> class test_object : public Data { /** * Default do-nothing test. */ template <int n> void test() { called_method_was_a_dummy_test_ = true; } } 

Agora, quando você escreve esse teste:


 template<> template<> void object::test<1>() { ensure_equals("2+2=?", 2+2, 4); } 

Na verdade, você cria uma especialização de método de teste para um número específico N = 1 (é exatamente isso que o template<>template<> representa). Ao chamar test<N>() o tempo de execução do teste pode entender se foi um teste real ou se um stub está olhando para o valor called_method_was_a_dummy_test_ após a execução do teste.


Em seguida, quando você declara um grupo de teste:


 tut::factory tf("basic test"); 

Primeiro, você enumera todo o test<N> para uma determinada constante conectada à biblioteca e, em segundo lugar, por efeito colateral, adiciona informações sobre o grupo ao contêiner global (nome do grupo e endereços de todas as funções de teste).


Exceções são usadas como condições de teste no tut, portanto, a função tut::ensure_equals() simplesmente lançará uma exceção se os dois valores passados ​​a ela não forem iguais e o ambiente de execução de teste capturar uma exceção e considerar o teste como falhado. Eu gosto dessa abordagem, fica imediatamente claro para qualquer desenvolvedor de C ++ onde essas asserções podem ser usadas. Por exemplo, se meu teste criou um encadeamento auxiliar, é inútil colocar afirmações lá, ninguém as capturará. Além disso, está claro para mim que meu teste deve poder liberar recursos no caso de uma exceção, como se fosse um código comum de exceção para segurança.


Em princípio, a biblioteca tut-framework parece muito boa, mas existem algumas desvantagens em sua implementação. Por exemplo, para o meu caso, eu gostaria que o teste tivesse não apenas um número, mas também outros atributos, em particular o nome, bem como o "tamanho" do teste (por exemplo, é um teste de integração ou um teste de unidade). Isso pode ser resolvido dentro da estrutura do tut API, e até algo já existe, e algo pode ser realizado se você adicionar um método à API da biblioteca e chamá-lo para o corpo do teste para definir qualquer um de seus parâmetros:


 template<> template<> void object::test<1>() { set_name("2+2"); // Set test name to be shown in test report ensure_equals("2+2=?", 2+2, 4); } 

Outro problema é que o ambiente de execução de teste tut não sabe nada sobre um evento como o início de um teste. O ambiente executa o object::test<N>() e não sabe antecipadamente se o teste foi implementado para um determinado N ou se é apenas um esboço. Ela só called_method_was_a_dummy_test_ quando o teste termina, analisando o valor called_method_was_a_dummy_test_ . Esse recurso não se mostra muito bem nos sistemas de CI, capazes de agrupar a saída que o programa produziu entre o início e o final do teste.


No entanto, na minha opinião, a principal coisa que pode ser aprimorada (uma "falha fatal") é a presença de código auxiliar extra necessário para escrever testes. Há muitas coisas no tutorial tut-framework: é proposto primeiro criar uma certa classe de struct basic{} e descrever os testes como métodos do objeto associado a isso. Nesta classe, você pode definir os métodos e dados que deseja usar no grupo de teste, e o construtor e o destruidor enquadram a execução do teste, criando algo como um dispositivo elétrico da jUnit. Na minha prática com tut, esse objeto está quase sempre vazio, mas arrasta-se por um certo número de linhas de código.


Então, vamos à oficina de bicicletas e tentamos organizar a ideia na forma de uma pequena biblioteca.


É assim que o arquivo de teste mínimo se parece na biblioteca testada:


 // Test group for std::vector (illustrative purposes) #include "tested.h" #include <vector> template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime) { runtime->StartCase("emptiness"); std::vector<int> vec; tested::Is(vec.empty(), "Vector must be empty by default"); } template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime) { runtime->StartCase("AddElement"); std::vector<int> vec; vec.push_back(1); tested::Is(vec.size() == 1); tested::Is(vec[0] == 1); tested::FailIf(vec.empty()); } void LinkVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

Além da falta de macros, o bônus é a ausência do uso de memória dinâmica dentro da biblioteca.


Definição de casos de teste


Para o registro de testes, a magia elementar do nível de entrada é usada com o mesmo princípio de tut. Em algum lugar no testing.h existe uma função padrão deste tipo:


 template <int N> static void Case(IRuntime* runtime) { throw TheCaseIsAStub(); } 

Os casos de teste escritos pelos usuários da biblioteca são simplesmente especializações desse método. A função é declarada estática, ou seja, em cada unidade de tradução, criamos especializações que não se cruzam por nome durante a vinculação.


Existe uma regra que primeiro você precisa chamar StartCase() , para a qual você pode passar coisas como o nome do teste e talvez outras que ainda estejam em desenvolvimento.


Quando um teste chama runtime->StartTest() , coisas interessantes podem acontecer. Primeiro, se os testes estiverem agora no modo de execução, você poderá dizer em algum lugar que o teste iniciou a execução. Em segundo lugar, se houver um modo de coletar informações sobre os testes disponíveis, o StartTest() lançará um tipo especial de exceção, o que significa que o teste é real e não um esboço.


Registo


Em algum momento, você precisa coletar os endereços de todos os casos de teste e colocá-los em algum lugar. Nos testados, isso é feito usando grupos. O construtor da classe testing :: Group faz isso como um efeito colateral:


 static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); 

O construtor cria um grupo com o nome especificado e adiciona a todos os Case<N> que encontra na unidade de tradução atual. Acontece que em uma unidade de tradução você não pode ter dois grupos. Isso também significa que você não pode dividir um grupo em várias unidades de tradução.


O parâmetro do modelo é quantos casos de teste procurar na unidade de tradução atual para o grupo criado.


Link


No exemplo acima, a criação do objeto testing :: Group () ocorre dentro da função que devemos chamar de nosso aplicativo para registrar os testes:


 void LinkStdVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

Uma função nem sempre é necessária; às vezes você pode simplesmente declarar um objeto da classe tested::Group dentro de um arquivo. No entanto, minha experiência é que o vinculador às vezes "otimiza" o arquivo inteiro se ele estiver montado dentro da biblioteca e nenhum aplicativo principal usa caracteres desse arquivo cpp:


 calc.lib <- calc_test.lib(calc_test.cpp) ^ ^ | | app.exe run_test.exe 

Quando o calc_test.cpp não está vinculado da fonte run_test.exe, o vinculador simplesmente remove completamente esse arquivo da consideração, juntamente com a criação de um objeto estático, apesar de ter os efeitos colaterais de que precisamos.


Se essa cadeia resultar de run_test.exe, o objeto estático aparecerá no arquivo executável. E não importa exatamente como isso é feito, como no exemplo:


 void LinkStdVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

ou mais:


 static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); void LinkStdVectorTests() { } 

A primeira opção, na minha opinião, é melhor porque o construtor é chamado após o início de main () e o aplicativo tem algum controle sobre esse processo.


Eu acho que essa configuração de muletas é necessária para qualquer biblioteca de teste de unidade que use variáveis ​​globais e efeitos colaterais do construtor para criar um banco de dados de teste. No entanto, provavelmente isso pode ser evitado vinculando a biblioteca de teste à chave --whole-archive (um analógico no MSVC apareceu apenas no Visual Studio 2015.3).


Macros


Prometi que não haverá macros, mas é - CASE_COUNTER . A opção de trabalho é que ele seja usado por __COUNTER__ , uma macro que o compilador incrementa em um cada vez que é usado dentro da unidade de conversão.
Suportado por GCC, CLANG, MSVC, mas não o padrão. Se isso é frustrante, aqui estão algumas alternativas:


  • use os números 0, 1, 2
  • use o padrão __LINE__ .
  • use constexpr magic do nível 80. Você pode procurar por "contador de constexpr" e tentar encontrar o compilador no qual ele funcionará.

O problema com __LINE__ é que o uso de números grandes nas opções do modelo cria um tamanho de arquivo executável grande. É por isso que limitei o tipo do padrão de caractere assinado a 128 como o número máximo de testes no grupo.


Falha na memória dinâmica


Aconteceu que, ao registrar testes, você não pode usar a memória dinâmica, que eu usei. É possível que seu ambiente não tenha memória dinâmica ou você use a procura de vazamentos de memória em casos de teste, portanto, a intervenção do ambiente de execução de teste não é o que você precisa. O Google Test está lutando com isso. Aqui está um trecho:


 // Use the RAII idiom to flag mem allocs that are intentionally never // deallocated. The motivation is to silence the false positive mem leaks // that are reported by the debug version of MS's CRT which can only detect // if an alloc is missing a matching deallocation. // Example: // MemoryIsNotDeallocated memory_is_not_deallocated; // critical_section_ = new CRITICAL_SECTION; class MemoryIsNotDeallocated 

E nós simplesmente não podemos criar dificuldades.


Como, então, obtemos uma lista de testes? Esses são internos mais técnicos, que são mais fáceis de ver no código-fonte, mas eu vou lhe dizer assim mesmo.


Ao criar um grupo, sua classe receberá um ponteiro para a função tested::CaseCollector<CASE_COUNTER>::collect , que coletará todos os testes de unidade de conversão em uma lista. Veja como funciona:


 // Make the anonymouse namespace to have instances be hidden to specific translation unit namespace { template <Ordinal_t N> struct CaseCollector { // Test runtime that collects the test case struct CollectorRuntime final : IRuntime { void StartCase(const char* caseName, const char* description = nullptr) final { // the trick is exit from test case function into the collector via throw throw CaseIsReal(); } }; // Finds the Case<N> function in current translation unit and adds into the static list. It uses the // reverse order, so the case executed in order of appearance in C++ file. static CaseListEntry* collect(CaseListEntry* tail) { CaseListEntry* current = nullptr; CollectorRuntime collector; try { Case<N>(&collector); } catch (CaseIsStub) { current = tail; } catch (CaseIsReal) { s_caseListEntry.CaseProc = Case<N>; s_caseListEntry.Next = tail; s_caseListEntry.Ordinal = N; current = &s_caseListEntry; } return CaseCollector<N - 1>::collect(current); } private: static CaseListEntry s_caseListEntry; }; // This static storage will be instantiated in any cpp file template <Ordinal_t N> CaseListEntry CaseCollector<N>::s_caseListEntry; } 

Acontece que em cada unidade de conversão são criadas muitas variáveis ​​estáticas do tipo CaseListEntry CaseCollector \ :: s_caseListEntry, que são elementos da lista de testes, e o método collect () coleta esses elementos em uma lista conectada individualmente. Aproximadamente da mesma maneira, a lista forma grupos de testes, mas sem padrões e recursão.


Estrutura


Os testes precisam de uma ligação diferente, como saída para o console em letras vermelhas. Falha, criando relatórios de teste em um formato compreensível para CI ou GUI, no qual você pode ver a lista de testes e executar os selecionados - em geral, muitas coisas. Eu tenho uma visão de como isso pode ser feito, o que é diferente do que vi anteriormente na biblioteca de testes. A reivindicação é principalmente para bibliotecas que se autodenominam "somente cabeçalho", incluindo uma grande quantidade de código, que não é essencialmente para arquivos de cabeçalho.


A abordagem que eu assumo é que dividimos a biblioteca em front-end - isso é testado.he as próprias bibliotecas de back-end. Para escrever testes, você só precisa de testing.h, que agora é C ++ 17 (devido ao std :: std :: string_view), mas supõe-se que haverá C ++ 98. Tested.h, na verdade, realiza o registro e pesquisa de testes, uma opção de inicialização minimamente conveniente, bem como a capacidade de exportar testes (grupos, endereços de funções de caso de teste). As bibliotecas de back-end que ainda não existem podem fazer o que precisam em termos de saída de resultados e inicialização usando a funcionalidade de exportação. Da mesma forma, você pode adaptar o lançamento às necessidades do seu projeto.


Sumário


A biblioteca testada ( código do github ) ainda precisa de alguma estabilização. Em um futuro próximo, adicione a capacidade de executar testes assíncronos (necessários para testes de integração no WebAssembly) e indicar o tamanho dos testes. Na minha opinião, a biblioteca ainda não está pronta para o uso em produção, mas de repente passei muito tempo e o estágio parou, respire fundo e peça feedback à comunidade. Você estaria interessado em usar esse tipo de biblioteca? Talvez haja outras idéias no arsenal de C ++, pois seria possível criar uma biblioteca sem macros? Essa afirmação do problema é interessante?

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


All Articles