Recentemente, a zerocost escreveu um artigo interessante, “Testes em C ++ sem macros e memória dinâmica” , que discute uma estrutura minimalista para testar o código C ++. O autor (quase) conseguiu evitar o uso de macros para registrar testes, mas em vez deles, modelos "mágicos" apareceram no código, o que pessoalmente me parece, desculpe, inimaginavelmente feio. Depois de ler o artigo, tive uma vaga sensação de insatisfação, pois sabia o que poderia ser feito melhor. Não consegui me lembrar imediatamente de onde, mas definitivamente vi o código de teste, que não contém um único caractere extra para registrá-los:
void test_object_addition() { ensure_equals("2 + 2 = ?", 2 + 2, 4); }
Por fim, lembrei-me de que essa estrutura é chamada Cutter e usa uma maneira genial de identificar funções de teste à sua maneira.
(KDPV retirado do site da Cutter sob CC BY-SA.)
Qual é o truque?
O código de teste é montado em uma biblioteca compartilhada separada. As funções de teste são extraídas dos símbolos da biblioteca exportados e identificadas pelos nomes. Os testes são realizados por um utilitário externo especial. Sapienti sentou-se.
$ cat test_addition.c #include <cutter.h> void test_addition() { cut_assert_equal_int(2 + 2, 5); }
$ cc -shared -o test_addition.so \ -I/usr/include/cutter -lcutter \ test_addition.c
$ cutter . F ========================================================================= Failure: test_addition <2 + 2 == 5> expected: <4> actual: <5> test_addition.c:5: void test_addition(): cut_assert_equal_int(2 + 2, 5, ) ========================================================================= Finished in 0.000943 seconds (total: 0.000615 seconds) 1 test(s), 0 assertion(s), 1 failure(s), 0 error(s), 0 pending(s), 0 omission(s), 0 notification(s) 0% passed
Aqui está um exemplo da documentação do cortador . Você pode rolar com segurança tudo relacionado ao Autotools e ver apenas o código. A estrutura é um pouco estranha, sim, como tudo japonês.
Não entrarei em muitos detalhes sobre os recursos de implementação. Também não tenho um código completo (e até pelo menos rascunho), pois pessoalmente não preciso dele (no Rust, tudo está fora da caixa). No entanto, para as pessoas interessadas, este pode ser um bom exercício.
Detalhes e opções de implementação
Considere algumas das tarefas que você precisa resolver ao escrever uma estrutura para teste usando a abordagem Cutter.
Obtendo funções exportadas
Primeiro, você precisa acessar as funções de teste de alguma forma. O padrão C ++, é claro, não descreve bibliotecas compartilhadas. O Windows adquiriu recentemente um subsistema Linux, que permite reduzir os três principais sistemas operacionais para POSIX. Como você sabe, os sistemas POSIX fornecem as funções dlopen()
, dlsym()
, dlclose()
, com as quais você pode obter o endereço da função, sabendo o nome do seu símbolo e ... isso é tudo. A lista de funções contidas na biblioteca carregada não é divulgada pelo POSIX.
Infelizmente (embora felizmente), não existe uma maneira portátil padrão de descobrir todas as funções exportadas da biblioteca. Talvez o fato de o conceito de biblioteca não existir em todas as plataformas (leia-se: incorporado) esteja de alguma forma envolvido aqui. Mas esse não é o ponto. O principal é que você precisa usar recursos específicos da plataforma.
Como uma aproximação inicial, você pode simplesmente chamar o utilitário nm :
$ cat test.cpp void test_object_addition() { }
$ clang -shared test.cpp
$ nm -gj ./a.out __Z20test_object_additionv dyld_stub_binder
analise sua saída e use dlsym()
.
Para uma introspecção mais profunda, bibliotecas como libelf , libMachO , pe-parse são úteis, permitindo analisar programaticamente arquivos executáveis e bibliotecas de plataformas de seu interesse. De fato, a nm e a empresa apenas as usam.
Filtragem da função de teste
Como você deve ter notado, as bibliotecas contêm alguns caracteres estranhos:
__Z20test_object_additionv dyld_stub_binder
Isto é o que __Z20test_object_additionv
quando chamamos a função just test_object_addition
? E o que resta deste dyld_stub_binder
?
Os caracteres " __Z20...
" __Z20...
são a chamada decoração de nome (nome desconcertante). Recurso de compilação em C ++, nada pode ser feito, viva com ele. É assim que as funções são chamadas do ponto de vista do sistema (e dlsym()
). Para mostrá-los a uma pessoa em sua forma normal, você pode usar bibliotecas como libdemangle . Obviamente, a biblioteca que você precisa depende do compilador usado, mas o formato da decoração geralmente é o mesmo dentro da estrutura da plataforma.
Quanto a funções estranhas como dyld_stub_binder
, esses também são recursos da plataforma que devem ser levados em consideração. Você não precisa chamar nenhuma função ao iniciar os testes, pois não há peixes lá.
Uma continuação lógica dessa idéia é filtrar a função pelo nome. Por exemplo, você só pode executar funções com test
no nome. Ou apenas funções do namespace de tests
. E também use namespaces aninhados para agrupar testes. Não há limite para sua imaginação.
Passando o contexto de um teste executável
Os arquivos de objetos com testes são coletados em uma biblioteca compartilhada, cuja execução é completamente controlada por um utilitário externo - cutter
para o Cutter. Consequentemente, as funções de teste interno podem usar isso.
Por exemplo, o contexto de um teste executável ( IRuntime
no artigo original) pode ser passado com segurança por uma variável global (thread-local). O motorista é responsável por gerenciar e passar o contexto.
Nesse caso, as funções de teste não requerem argumentos, mas mantêm todos os recursos avançados, como a nomeação arbitrária dos casos testados:
void test_vector_add_element() { testing::description("vector size grows after push_back()"); }
A função description()
acessa o IRuntime
condicional IRuntime
meio de uma variável global e, portanto, pode passar um comentário para a estrutura de uma pessoa. A segurança do uso do contexto global é garantida pela estrutura e não é de responsabilidade do gravador de teste.
Com essa abordagem, haverá menos ruído no código com a transferência de contexto para as instruções de comparação e funções de teste internas que podem precisar ser chamadas da principal.
Construtores e destruidores
Como a execução dos testes é completamente controlada pelo driver, ele pode executar códigos adicionais em torno dos testes.
A biblioteca do cortador usa as seguintes funções para isso:
cut_setup()
- antes de cada teste individualcut_teardown()
- após cada teste individualcut_startup()
- antes de executar todos os testescut_shutdown()
- após a conclusão de todos os testes
Essas funções são chamadas apenas se definidas no arquivo de teste. Você pode colocar neles a preparação e limpeza do ambiente de teste (dispositivo elétrico): a criação dos arquivos temporários necessários, a difícil configuração dos objetos testados e outros antipadrões de teste.
Para C ++, é possível criar uma interface mais idiomática:
- mais orientado a objeto e tipo seguro
- com melhor suporte ao conceito RAII
- usando lambdas para execução adiada
- envolvendo o contexto de execução de teste
Mas, por enquanto, estou pensando nisso novamente em detalhes agora.
Executáveis de teste independentes
O Cutter usa uma abordagem de biblioteca compartilhada por conveniência. Vários testes são compilados em um conjunto de bibliotecas que um utilitário de teste separado localiza e executa. Naturalmente, se desejado, todo o código do driver de teste pode ser incorporado diretamente no arquivo executável, obtendo os arquivos comuns comuns. No entanto, isso exigirá colaboração com o sistema de compilação para organizar o layout desses arquivos executáveis da maneira correta: sem cortar as funções "não utilizadas", com as dependências corretas, etc.
Outros
O Cutter e outras estruturas também têm muitas outras coisas úteis que podem facilitar a vida ao escrever testes:
- instruções de teste flexíveis e extensíveis
- construindo e obtendo dados de teste de arquivos
- estudos de rastreamento de pilha, manipulação de exceção e queda
- "níveis de detalhamento" personalizáveis de testes
- executando testes em vários processos
Vale a pena olhar para as estruturas existentes ao escrever sua bicicleta. UX é um tópico muito mais profundo.
Conclusão
A abordagem usada pela estrutura do Cutter permite a identificação de funções de teste com carga cognitiva mínima no programador: basta escrever as funções de teste e é isso. O código não requer o uso de modelos ou macros especiais, o que aumenta sua legibilidade.
Os recursos de montagem e execução de testes podem estar ocultos em módulos reutilizáveis para sistemas de montagem como Makefile, CMake, etc. Perguntas sobre uma montagem de testes separada ainda terão que ser feitas de uma maneira ou de outra.
As desvantagens dessa abordagem incluem a dificuldade de colocar os testes no mesmo arquivo (a mesma unidade de tradução) do código principal. Infelizmente, nesse caso, sem dicas adicionais, não é mais possível descobrir quais funções precisam ser ativadas e quais não. Felizmente, em C ++, geralmente é comum distribuir testes e implementação em arquivos diferentes.
Quanto à disposição final das macros, parece-me que, em princípio, elas não devem ser abandonadas. As macros permitem, por exemplo, escrever instruções de comparação mais curtas, evitando a duplicação de código:
void test_object_addition() { ensure_equals(2 + 2, 5); }
mas, ao mesmo tempo, mantendo o mesmo conteúdo informativo do problema em caso de erros:
Failure: test_object_addition <ensure_equals(2 + 2, 5)> expected: <5> actual: <4> test.c:5: test_object_addition()
O nome da função que está sendo testada, o nome do arquivo e o número da linha do início da função, em teoria, podem ser extraídos das informações de depuração contidas na biblioteca que está sendo coletada. O valor esperado e real das expressões comparadas são conhecidas pela função ensure_equals()
. A macro permite "restaurar" a ortografia original da instrução de teste, da qual fica mais claro por que o valor 4
é esperado.
No entanto, isso não é para todos. O benefício das macros para o código de teste termina aí? Ainda não pensei nesse momento, o que pode se tornar um bom campo para mais perversões pesquisa. Uma pergunta muito mais interessante: é possível criar, de alguma forma, uma estrutura simulada para C ++ sem macros?
O leitor atento também observou que realmente não há SMS e amianto na implementação, o que é uma vantagem indubitável para a ecologia e a economia da Terra.