Nos anos 60, foi considerado uma boa prática básica em engenharia de software testar seu código como você o escreveu. Os pioneiros do desenvolvimento de software naquela época foram proponentes de vários níveis de testes; alguns defendiam testes “unitários” e outros não, mas todos reconheceram a importância de testar o código.
Executable tests may have first been introduced by Margaret Hamilton on the Apollo project in the mid-1960s, where she originated a type of executable checking that we now called “static code analysis”. Ela o chamou de “software de ordem superior”, pelo qual ela quis dizer software que opera contra outro software ao invés de diretamente contra o domínio do problema. Seu software de ordem superior examinou o código fonte para procurar padrões que eram conhecidos por levar a problemas de integração.
Por volta de 1970, as pessoas haviam esquecido em grande parte os testes de executáveis. Claro, as pessoas executavam aplicações e as cutucavam aqui e ali à mão, mas desde que a construção não ardesse ao redor delas, elas achavam que o código era “bom o suficiente”. O resultado tem sido mais de 35 anos de código em produção no mundo inteiro que é inadequadamente testado, e em muitos casos não funciona inteiramente como pretendido, ou de uma forma que satisfaça seus clientes.
A idéia de programadores testando à medida que vão, fez um retorno a partir de meados dos anos 90, embora até o presente momento a grande maioria dos programadores ainda não o fazem. Engenheiros de infraestrutura e administradores de sistemas testam seus scripts ainda menos diligentemente do que programadores testam seu código de aplicação.
A medida que avançamos para uma era em que a implantação rápida de soluções complicadas, compreendendo inúmeros componentes autônomos, está se tornando a norma, e as infraestuturas “em nuvem” exigem que gerenciemos milhares de VMs e containers em uma escala que não pode ser gerenciada utilizando métodos manuais, a importância de testes e verificações executáveis e automatizadas ao longo do processo de desenvolvimento e entrega não pode ser ignorada; não apenas para programadores de aplicações, mas para todos os envolvidos no trabalho de TI.
Com o advento do devops (cruzamento de habilidades, métodos e ferramentas de desenvolvimento e operações), e tendências como “infra-estrutura como código” e “automatizar todas as coisas”, os testes unitários se tornaram uma habilidade básica para programadores, testadores, administradores de sistemas e engenheiros de infra-estrutura.
Nesta série de posts, vamos introduzir a idéia de testes de unidade de scripts shell, e então vamos explorar vários frameworks de teste de unidade que podem ajudar a tornar essa tarefa prática e sustentável em escala.
Outra prática que pode não ser familiar a muitos engenheiros de infraestrutura é o controle de versão. Mais tarde nesta série, vamos tocar em sistemas de controle de versão e fluxos de trabalho que os desenvolvedores de aplicativos usam, e que podem ser eficazes e úteis para os engenheiros de infraestrutura também.
A Script to Test
Vivek Gite publicou um script shell de exemplo para monitorar o uso do disco e para gerar uma notificação por e-mail quando certos sistemas de arquivos excedem um limite. O seu artigo está aqui: https://www.cyberciti.biz/tips/shell-script-to-watch-the-disk-space.html. Vamos usar isso como assunto de teste.
A versão inicial de seu script, com a adição da opção -P no comando df para evitar quebras de linha na saída, como sugerido em um comentário de Per Lindahl, parece assim:
#!/bin/shdf -HP | grep -vE '^Filesystem|tmpfs|cdrom' | awk '{ print " " }' | while read output;do usep=$(echo $output | awk '{ print }' | cut -d'%' -f1 ) partition=$(echo $output | awk '{ print }' ) if ; then echo "Running out of space \"$partition ($usep%)\" on $(hostname) as on $(date)" | mail -s "Alert: Almost out of disk space $usep%" [email protected] fidone
Vivek continua para refinar o script além desse ponto, mas esta versão servirá para os propósitos do presente post.
Verificações Funcionais Automatizadas
Um par de regras sobre verificações funcionais automatizadas, quer estejamos verificando código de aplicação ou um script ou qualquer outro tipo de software:
- a verificação tem que ser idêntica toda vez, sem necessidade de ajustes manuais para preparar cada execução; e
- o resultado não pode ser vulnerável a mudanças no ambiente de execução, ou dados, ou outros fatores externos ao código em teste.
Pass, Fail, and Error
É possível que o script não seja executado. Isso é normal para qualquer tipo de framework de teste de unidade para qualquer tipo de aplicação. Três resultados, ao invés de dois, são possíveis:
- O código em teste exibe o comportamento esperado
- O código em teste é executado, mas não exibe o comportamento esperado
- O código em teste não é executado
Para fins práticos, o terceiro resultado é o mesmo que o segundo; teremos que descobrir o que deu errado e corrigi-lo. Então, geralmente pensamos nestas coisas como binários: Passa ou falha.
O que devemos verificar?
Neste caso, estamos interessados em verificar se o script se comportará como esperado, dados os vários valores de entrada. Não queremos poluir nossas verificações de unidade com qualquer outra verificação além disso.
Revendo o código em teste, vemos que quando o uso do disco atinge um limite de 90%, o script chama o e-mail para enviar uma notificação para o administrador do sistema.
Em conformidade com as boas práticas geralmente aceitas para verificações de unidades, queremos definir casos separados para verificar cada comportamento esperado para cada conjunto de condições iniciais.
Colocando nosso chapéu de “testador”, vemos que este é um tipo de condição de limite. Não precisamos verificar várias percentagens diferentes de uso do disco individualmente. Nós só precisamos verificar o comportamento nos limites. Portanto, o conjunto mínimo de casos para fornecer uma cobertura significativa será:
- Mande um e-mail quando o uso do disco atingir o limite
- Não envia um e-mail quando o uso do disco estiver abaixo do limite
O que não devemos verificar?
Em conformidade com as boas práticas geralmente aceitas para isolamento de teste de unidade, queremos garantir que cada um de nossos casos pode falhar por exatamente um motivo: O comportamento esperado não acontece. Na medida do possível, queremos configurar nossas verificações para que outros fatores não causem falha no caso.
Nem sempre é possível garantir que fatores externos não afetem nossas verificações automatizadas. Há momentos em que não podemos controlar um elemento externo, ou quando fazê-lo envolveria mais tempo, esforço e custo do que o valor da verificação, e/ou envolve um caso de margem obscura que tem uma probabilidade muito baixa de ocorrer ou muito pouco impacto quando ele ocorre. É uma questão para o seu julgamento profissional. Como regra geral, faça o seu melhor para evitar criar dependências de fatores além do escopo do código em teste.
Não precisamos verificar se os comandos df, grep, awk, cut, e mail funcionam. Isso está fora do escopo para os nossos propósitos. Quem quer que mantenha os utilitários é responsável por isso.
Queremos saber se a saída do comando df não é processada da forma que esperamos pelo grep ou awk. Portanto, queremos que os verdadeiros comandos grep e awk sejam executados em nossas verificações, baseados na saída do comando df que corresponda à intenção de cada caso de teste. Isso está no escopo porque os argumentos da linha de comando para df são parte do script, e o script é o código sob test.
Isso significa que precisaremos de uma versão falsa do comando df para usar com nossas verificações de unidade. Esse tipo de componente falso é muitas vezes chamado de mock. Um mock representa um componente real e fornece saída predefinida para conduzir o comportamento do sistema de uma forma controlada, para que possamos verificar o comportamento do código sob teste de forma confiável.
Vemos o script enviar uma notificação por e-mail quando um sistema de arquivos atinge o nível de utilização limite. Nós não queremos que as nossas verificações de unidade vomitem um monte de e-mails inúteis, então vamos querer zombar do comando mail também.
Este script é um bom exemplo para ilustrar zombar destes comandos, pois vamos fazê-lo de uma maneira diferente para o mail do que para o df.
Zombando do comando df
O script é construído em torno do comando df. A linha relevante no script é:
df -HP | grep -vE '^Filesystem|tmpfs|cdrom' | awk '{ print " " }'
Se correr apenas df -HP, sem piping para o grep, verá uma saída semelhante a isto:
Filesystem Size Used Avail Use% Mounted onudev 492M 0 492M 0% /devtmpfs 103M 6.0M 97M 6% /run/dev/sda1 20G 9.9G 9.2G 52% /tmpfs 511M 44M 468M 9% /dev/shmtmpfs 5.3M 0 5.3M 0% /run/locktmpfs 511M 0 511M 0% /sys/fs/cgrouptmpfs 103M 8.2k 103M 1% /run/user/1000
Os comandos grep e awk retiram a saída até isto:
0% udev52% /dev/sda1
Precisamos de controlar a saída do df para conduzir os nossos casos de teste. Não queremos que o resultado da verificação varie com base na utilização real do disco no sistema onde estamos a executar o conjunto de teste. Não estamos verificando o uso do disco; estamos verificando a lógica do script. Quando o script é executado em produção, ele irá verificar o uso do disco. O que estamos a fazer aqui é para validação, não para operações de produção. Portanto, precisamos de um comando df falso ou “mock” com o qual podemos gerar os “dados de teste” para cada caso.
Em uma plataforma *nix é possível sobrepor o comando df real, definindo um alias. Queremos que o comando alias emita valores de teste no mesmo formato que a saída do df -HP. Aqui está uma maneira de o fazer (isto é tudo uma linha; está dividido abaixo para legibilidade):
alias df="shift;echo -e 'Filesystem Size Used Avail Use% Mounted on'; echo -e 'tempfs 511M 31M 481M 6% /dev/shm'; echo -e '/dev/sda1 20G 9.9G 9.2G 52% /'"
O shift salta sobre o argumento ‘-HP’ quando o script é executado, para que o sistema não se queixe que -HP é um comando desconhecido. O comando aliased df emite output na mesma forma que df -HP.
Os valores de teste são piped into grep e depois awk quando o script é executado, então estamos zombando apenas do mínimo necessário para controlar nossos casos de teste. Queremos que o nosso caso de teste seja o mais próximo possível da “coisa real” para que não tenhamos falsos positivos.
Mock criados através de uma biblioteca de mocking podem retornar um valor pré-definido quando chamados. Nossa abordagem para zombar do comando df espelha aquela função de um mock; nós estamos especificando uma saída pré-definida a ser retornada sempre que o código sob teste chamar df.
Mocking the mail Command
Nós queremos saber se o script tenta enviar um email sob as condições certas, mas não queremos que ele envie um email real para qualquer lugar. Portanto, queremos apelidar o comando mail, como fizemos anteriormente com o comando df. Precisamos de configurar algo que possamos verificar após cada caso de teste. Uma possibilidade é escrever um valor num ficheiro quando o mail é chamado, e depois verificar o valor no nosso caso de teste. Isto é mostrado no exemplo abaixo. Outros métodos também são possíveis.
Mocks criados através de uma biblioteca de mocking podem contar o número de vezes que são chamados pelo código em teste, e podemos afirmar o número esperado de invocações. Nossa abordagem para zombar do comando mail espelha aquela função de um mock; se o texto “mail” estiver presente no arquivo mailsent depois de executarmos o script diskusage.sh, significa que o script chamou o comando mail.
Pattern for Running Automated Checks
Verificações automáticas ou executáveis em qualquer nível de abstração, para qualquer tipo de aplicação ou script, em qualquer idioma, tipicamente compreende três passos. Estes normalmente vão pelos nomes:
- Arrange
- Act
- Assert
A razão para isto é provavelmente que todos adoram aliteração, especialmente na letra A, já que a própria palavra “alliteration” começa com a letra A.
Sejam quais forem os motivos, no passo de arranjo nós estabelecemos as condições prévias para o nosso caso de teste. Na etapa de ato, invocamos o código em teste. No passo assert declaramos o resultado que esperamos ver.
Quando usamos uma estrutura de teste ou biblioteca, a ferramenta lida bem com o passo assert para nós, de modo que não precisamos codificar muita lógica de if/else incômoda em nossas suítes de teste. Para nosso exemplo inicial aqui, não estamos usando uma estrutura de teste ou uma biblioteca, então verificamos os resultados de cada caso com um bloco if/else. Na próxima parcela, vamos jogar com framework de teste unitário para linguagens shell, e ver como isso fica.
Aqui está o nosso script de teste bruto-mas eficaz para testar o script shell do Vivek, que chamamos diskusage.sh:
#!/bin/bashshopt -s expand_aliases# Before allalias mail="echo 'mail' > mailsent;false"echo 'Test results for diskusage.sh' > test_resultstcnt=0# It does nothing when disk usage is below 90%# Before (arrange)alias df="echo 'Filesystem Size Used Avail Use% Mounted on';echo '/dev/sda2 100G 89.0G 11.0G 89% /'"echo 'no mail' > mailsent# Run code under test (act). ./diskusage.sh# Check result (assert)((tcnt=tcnt+1))if ]; then echo "$tcnt. FAIL: Expected no mail to be sent for disk usage under 90%" >> test_resultselse echo "$tcnt. PASS: No action taken for disk usage under 90%" >> test_resultsfi # It sends an email notification when disk usage is at 90%alias df="echo 'Filesystem Size Used Avail Use% Mounted on';echo '/dev/sda1 100G 90.0G 10.0G 90% /'"echo 'no mail' > mailsent. ./diskusage.sh((tcnt=tcnt+1))if ]; then echo "$tcnt. PASS: Notification was sent for disk usage of 90%" >> test_resultselse echo "$tcnt. FAIL: Disk usage was 90% but no notification was sent" >> test_resultsfi # After allunalias dfunalias mail# Display test results cat test_results
Aqui está um passo-a-passo do script de teste.
Primeiro, você vê que estamos usando bash para testar um arquivo .sh simples e antigo. Isso é perfeitamente bom. Não é necessário, mas está bem.
Next, você vê um comando shopt. Isso fará com que a shell expanda nossos apelidos de teste quando a subesquema for invocada para executar o script diskusage.sh. Na maioria dos casos de uso, nós não passaríamos apelidos em subshells, mas teste de unidades é uma exceção.
O comentário, “Antes de tudo”, é para pessoas que estão familiarizadas com frameworks de teste de unidades que tenham configurado e desmontado comandos. Estes são frequentemente nomeados algo como “antes” e “depois”, e normalmente há um par que entrelaça toda a suite de testes e outro par que é executado individualmente para cada caso de teste.
Quisemos mostrar que definir o alias para e-mail, inicializar o ficheiro de resultados de testes, e inicializar o contador de casos de teste são todos feitos exactamente uma vez, no início da suite de testes. Este tipo de coisa é normal em suítes de teste executáveis. O fato de estarmos testando um script shell ao invés de um programa aplicativo não muda isso.
O próximo comentário, “Não faz nada…” indica o início do nosso primeiro caso de teste individual. A maioria das unidades de frameworks de teste oferecem uma forma de fornecer um nome para cada caso, para que possamos acompanhar o que está acontecendo e para que outras ferramentas possam procurar, filtrar e extrair casos de teste por várias razões.
Próximo, há um comentário que diz, “Antes (arranjar)”. Este representa uma configuração que se aplica apenas a um caso de teste. Estamos a configurar o df alias para emitir a saída que precisamos para este caso em particular. Estamos também a escrever o texto, “no mail”, para um ficheiro. É assim que poderemos dizer se o script diskusage.sh tentou enviar um email de notificação.
O passo act vem a seguir, onde exercitamos o código em teste. Neste caso, isso significa executar o script diskusage.sh em si. Nós o fonte em vez de executá-lo diretamente .
Agora fazemos o passo assert, que estamos fazendo da maneira mais difícil neste exemplo porque ainda não introduzimos um framework de teste. Nós incrementamos o contador de testes para que possamos numerar os casos de teste no arquivo de resultados. Caso contrário, se tivéssemos um grande número de casos, poderia tornar-se difícil descobrir quais falharam. As frameworks de teste tratam disto para nós.
O pseudónimo que definimos para o comando mail escreve o texto ‘mail’ para o ficheiro mailsent. Se diskusage.sh chama mail, então o ficheiro mailent irá conter ‘mail’ em vez do valor inicial, ‘no mail’. Você pode ver quais são as condições de aprovação e reprovação lendo as strings ecoadas no arquivo de resultados do teste.
Iniciando com o comentário, “Ele envia uma notificação por e-mail…” nós repetimos o arranjo, agimos, afirmamos os passos para outro caso de teste. Vamos ter o nosso falso comando df a emitir dados diferentes desta vez, para conduzir um comportamento diferente do código em test.
Onde o comentário “Afinal” aparece, estamos a limpar depois de nós próprios, eliminando as definições que criámos na configuração “Antes de tudo”, perto do topo do script de teste.
Finalmente, despejamos o conteúdo do ficheiro test_results para que possamos ver o que temos. Parece assim:
Test results for diskusage.sh1. PASS: No action taken for disk usage under 90%2. PASS: Notification was sent for disk usage of 90%
Porquê Usar um Test Framework/Biblioteca?
Acabamos de escrever alguns casos de teste de unidades para um script shell sem usar um test framework, biblioteca de mocking, ou biblioteca de asserções. Descobrimos que comandos de sistema podem ser zombados definindo aliases (pelo menos em sistemas *nix), que asserções podem ser implementadas como declarações condicionais, e a estrutura básica de um teste de unidade é fácil de configurar manualmente.
Não foi difícil fazer isto sem um framework ou biblioteca. Então, qual é o benefício?
Test frameworks e bibliotecas simplificam e padronizam o código de teste e permitem suítes de teste muito mais legíveis do que scripts feitos à mão contendo uma grande quantidade de declarações condicionais. Algumas bibliotecas contêm recursos adicionais úteis, como a capacidade de capturar exceções ou a capacidade de escrever casos de teste guiados por tabelas e por dados. Algumas são adaptadas para suportar produtos específicos de interesse dos engenheiros de infra-estrutura, como o Chef e o Puppet. E alguns incluem funcionalidades para rastrear a cobertura de código e/ou formatar resultados de testes em um formulário consumível através de ferramentas no pipeline CI/CD, ou pelo menos um navegador da Web.
Unit Test Frameworks for Scripts
Nesta série estaremos explorando várias unidades de frameworks de teste para scripts shell e linguagens de scripting. Aqui está uma visão geral:
- shunit2 é um projeto Open Source muito sólido com uma história de dez anos. Desenvolvido originalmente por Kate Ward, Engenheira e Gerente de Confiabilidade de Sites no Google com sede em Zurique, é ativamente desenvolvido e apoiado por uma equipe de seis pessoas. Desde o seu humilde começo como uma solução pontual para testar uma biblioteca de registo para scripts shell, foi desenvolvido intencionalmente para uma estrutura de teste de unidades de uso geral que suporta várias linguagens shell e sistemas operativos. Ela inclui uma série de recursos úteis além de simples afirmações, incluindo suporte a testes controlados por dados e por tabelas. Ele usa o estilo tradicional de afirmações “assertthat”. O site do projeto contém excelente documentação. Para testes unitários de uso geral de scripts shell, esta é minha recomendação principal.
- BATS (Bash Automated Testing System) é um framework de testes unitários para bash. Foi criado por Sam Stephenson cerca de sete anos atrás, e teve uma dúzia de contribuidores ou mais. A última atualização foi há quatro anos atrás, mas isto não é motivo de preocupação, pois este tipo de ferramenta não requer atualizações ou manutenção freqüentes. O BATS é baseado no Test Anything Protocol (TAP), que define uma interface consistente baseada em texto entre módulos em qualquer tipo de arnês de teste. Ele permite uma sintaxe limpa e consistente em casos de teste, embora não pareça acrescentar muito açúcar sintáctico além de declarações de bash retas. Por exemplo, não há uma sintaxe especial para asserções; você escreve comandos bash para testar os resultados. Com isso em mente, seu principal valor pode estar na organização de conjuntos de teste e casos de uma forma lógica. Note, também, que escrever scripts de teste em bash não nos impede de testar scripts não-bash; nós fizemos isso anteriormente neste post. O facto da sintaxe BATS ser tão próxima da sintaxe simples da bash dá-nos muita flexibilidade para lidar com diferentes linguagens shell nas nossas suites de teste, ao possível custo de legibilidade (dependendo do que você achar “legível”;” o público pretendido para este post provavelmente acha a sintaxe simples da linguagem shell bastante legível). Uma característica particularmente interessante (na minha opinião) é que você pode configurar o seu editor de texto com destaque de sintaxe para BATS, conforme documentado no wiki do projeto. Emacs, Sublime Text 2, TextMate, Vim, e Atom foram suportados a partir da data deste post.
- zunit (não o IBM, o outro) é um framework de teste unitário para zsh desenvolvido por James Dinsdale. O site do projeto diz que zunit foi inspirado no BATS, e inclui as variáveis $state, $output, e $lines, altamente úteis. Mas ele também tem uma sintaxe de afirmação definitiva que segue o padrão, “afirmam que se espera uma correspondência real”. Cada uma destas estruturas tem algumas características únicas. Uma característica interessante da ZUnit, na minha opinião, é que ela marcará quaisquer casos de teste que não contenham uma asserção como “arriscado”. Você pode anular isto e forçar os casos a correr, mas por defeito a framework ajuda a lembrar de incluir uma asserção em cada caso de teste.
- bash-spec é uma framework de teste ao estilo comportamental que suporta apenas bash (ou pelo menos, só foi testada contra bash scripts). É um humilde projeto lateral meu que existe há mais de quatro anos e que tem alguns usuários “reais”. Ele não é muito atualizado, pois atualmente faz o que se pretendia fazer. Um dos objectivos do projecto era fazer uso de funções bash num estilo “fluido”. As funções são chamadas em sequência, cada uma passando toda a lista de argumentos para a seguinte depois de consumir todos os argumentos de que necessita para realizar a sua tarefa. O resultado é uma suite de teste legível, com declarações como “esperar que o nome do pacote seja_instalado” e “esperar que o arrayname não_contenha valor”. Quando usado para orientar o desenvolvimento de scripts de teste-primeiro, seu design tende a levar o desenvolvedor a escrever funções que suportam a idéia de “modularidade” ou “responsabilidade única” ou “separação de preocupações” (chame-lhe o que você quiser), resultando em facilidade de manutenção e funções prontas para uso. “Behavioral style” significa que as afirmações assumem a forma, “esperar que isto corresponda a isso”
- korn-spec é uma porta de bash-spec para a concha korn.
- Pester é a estrutura de teste unitário de escolha para Powershell. Powershell se parece mais com uma linguagem de programação de aplicativos do que puramente uma linguagem de scripting, e Pester oferece uma experiência de desenvolvimento totalmente consistente. Pester é fornecido com Windows 10 e pode ser instalado em qualquer outro sistema que suporte Powershell. Ele tem uma robusta biblioteca de asserções, suporte embutido para zombaria e coleta métricas de cobertura de código.
- ChefSpec é construído em rspec para fornecer uma estrutura de teste de estilo comportamental para receitas de chef. Chef é um aplicativo Ruby, e ChefSpec aproveita ao máximo os recursos do rspec mais o suporte embutido para funcionalidades específicas do Chef.
- rspec-puppet é uma estrutura de estilo comportamental para o Puppet, funcionalmente similar ao ChefSpec.