Testing scripts Bash com Bats ou Shunit2

1. Por que testar scripts Bash? Motivação e desafios

1.1. Fragilidade de scripts sem testes: erros silenciosos e efeitos colaterais

Scripts Bash frequentemente executam operações críticas como manipulação de arquivos, backups ou deploys. Sem testes, um simples erro de digitação pode deletar arquivos importantes ou corromper dados. Erros silenciosos ocorrem quando comandos falham mas o script continua executando, ignorando o código de retorno ($?). Efeitos colaterais em variáveis globais ou no sistema de arquivos podem causar comportamentos imprevisíveis.

1.2. Dificuldades específicas: variáveis de ambiente, comandos externos, $?

Testar Bash apresenta desafios únicos:
- Dependência de variáveis de ambiente que podem não estar definidas
- Comandos externos (curl, rm, sed) que alteram o sistema real
- Códigos de retorno que precisam ser verificados após cada operação
- Subshells e pipes que criam escopos de variáveis diferentes

1.3. Benefícios: confiabilidade em automações, CI/CD e manutenção colaborativa

Scripts testados oferecem confiança em pipelines de CI/CD, facilitam a colaboração em equipe e previnem regressões. Um conjunto de testes bem escrito documenta o comportamento esperado e acelera a manutenção.

2. Panorama das ferramentas: Bats vs Shunit2

2.1. Bats (Bash Automated Testing System): sintaxe simplificada e integração TAP

Bats usa uma sintaxe limpa com @test e suporte nativo ao formato TAP (Test Anything Protocol). Suas asserções como assert_success e assert_output tornam os testes legíveis e fáceis de escrever.

2.2. Shunit2: inspirado em JUnit, mais verboso e com asserções explícitas

Shunit2 segue o estilo xUnit com funções testSomething() e asserções como assertEquals. É mais verboso mas oferece controle granular sobre cada verificação.

2.3. Critérios de escolha: complexidade do script, time de desenvolvimento, portabilidade

Bats é ideal para equipes que preferem sintaxe moderna e integração TAP. Shunit2 funciona melhor em ambientes que já usam padrões xUnit ou necessitam de portabilidade máxima (um único arquivo .sh).

3. Configuração e primeiros passos com Bats

3.1. Instalação e estrutura básica

# Instalação via apt (Ubuntu/Debian)
sudo apt install bats

# Via brew (macOS)
brew install bats-core

# Estrutura de arquivos
projeto/
├── script.sh
└── test/
    └── script.bats

3.2. Sintaxe fundamental

#!/usr/bin/env bats

# test/validacao.bats

setup() {
    source ../script.sh
}

@test "valida_email retorna sucesso para email valido" {
    run valida_email "usuario@exemplo.com"
    assert_success
    assert_output "Email valido"
}

@test "valida_email retorna erro para email invalido" {
    run valida_email "invalido"
    assert_failure
    assert_output --partial "invalido"
}

3.3. Exemplo prático: testando função de validação

# script.sh
valida_email() {
    local email="$1"
    if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
        echo "Email valido"
        return 0
    else
        echo "Email invalido: $email"
        return 1
    fi
}
# test/valida_email.bats
@test "rejeita email sem arroba" {
    run valida_email "usuariogmail.com"
    assert_failure
    assert_output --partial "invalido"
}

@test "aceita email com subdominio" {
    run valida_email "user@sub.dominio.com.br"
    assert_success
}

4. Configuração e primeiros passos com Shunit2

4.1. Instalação e estrutura

# Download do único script
curl -L https://raw.githubusercontent.com/kward/shunit2/master/shunit2 -o shunit2
chmod +x shunit2

# Estrutura
projeto/
├── backup.sh
└── test/
    ├── test_backup.sh
    └── shunit2

4.2. Asserções principais

#!/bin/bash

# test/test_backup.sh
source ../backup.sh
source ./shunit2

testCriaBackupComSucesso() {
    local dir_teste=$(mktemp -d)
    export BACKUP_DIR="$dir_teste/backup"
    export ORIGEM="$dir_teste/origem"

    mkdir -p "$ORIGEM"
    echo "conteudo" > "$ORIGEM/arquivo.txt"

    cria_backup "$ORIGEM"

    assertTrue "Backup deve existir" "[ -d '$BACKUP_DIR' ]"
    assertContains "$(ls $BACKUP_DIR)" "arquivo.txt"

    rm -rf "$dir_teste"
}

testFalhaQuandoOrigemInexistente() {
    export BACKUP_DIR="/tmp/test_backup"
    local resultado=$(cria_backup "/caminho/inexistente" 2>&1)
    assertEquals 1 $?
    assertContains "$resultado" "Erro"
}

# Executar testes
. ./shunit2

4.3. Exemplo prático: testando script de backup

# backup.sh
cria_backup() {
    local origem="$1"

    if [ ! -d "$origem" ]; then
        echo "Erro: diretorio $origem nao existe" >&2
        return 1
    fi

    if [ -z "$BACKUP_DIR" ]; then
        echo "Erro: BACKUP_DIR nao definido" >&2
        return 1
    fi

    mkdir -p "$BACKUP_DIR"
    cp -r "$origem"/* "$BACKUP_DIR/"
    echo "Backup concluido em $BACKUP_DIR"
}

5. Técnicas avançadas de teste

5.1. Mocking de comandos externos

# Com Bats
mock_curl() {
    echo "resposta mockada"
}

@test "usa mock para curl" {
    function curl() { mock_curl "$@"; }
    export -f curl

    run meu_script_que_usa_curl
    assert_success
}

5.2. Isolamento de ambiente

setup() {
    export TEST_DIR=$(mktemp -d)
    cd "$TEST_DIR"
}

teardown() {
    rm -rf "$TEST_DIR"
}

@test "trabalha em diretorio temporario" {
    touch "arquivo_teste.txt"
    run processa_arquivos
    [ -f "resultado.txt" ]
}

5.3. Teste de saída e código de retorno

@test "verifica pipe e codigo de retorno" {
    run bash -c 'echo "dado" | grep "dado"'
    assert_success
    assert_output "dado"

    run bash -c 'echo "dado" | grep "nao_existe"'
    assert_failure
}

6. Organização e boas práticas

6.1. Estrutura de diretórios

projeto/
├── src/
│   └── utils.sh
├── test/
│   ├── bats/
│   │   └── test_utils.bats
│   ├── fixtures/
│   │   ├── entrada_valida.txt
│   │   └── config_padrao.conf
│   └── helpers/
│       └── mock_comandos.sh
└── Makefile

6.2. Setup e teardown

setup() {
    export VAR_AMBIENTE="teste"
    DIR_TRABALHO=$(mktemp -d)
}

teardown() {
    unset VAR_AMBIENTE
    rm -rf "$DIR_TRABALHO"
}

6.3. Nomenclatura e documentação

@test "CT001: validacao de email - formato padrao" {
    # Cenário: email com formato usuario@dominio.com
    # Resultado esperado: sucesso
    run valida_email "teste@exemplo.com"
    assert_success
}

7. Integração com CI/CD e linting

7.1. GitHub Actions

name: Testes Bash
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Instalar dependencias
        run: sudo apt install bats shellcheck
      - name: Executar lint
        run: shellcheck src/*.sh
      - name: Executar testes
        run: bats test/

7.2. Combinando com ShellCheck

# Makefile
.PHONY: test lint

lint:
    shellcheck src/*.sh

test: lint
    bats test/

7.3. Geração de relatórios

# Gerar relatório TAP
bats --formatter tap test/ > resultados.tap

# Gerar JUnit XML (com bats-extra)
bats --formatter junit test/ > resultados.xml

8. Erros comuns e depuração de testes

8.1. Falsos positivos por estado global não limpo

# ERRADO: variável persiste entre testes
setup() {
    export CONTADOR=0
}

@test "incrementa contador" {
    CONTADOR=$((CONTADOR + 1))
    [ "$CONTADOR" -eq 1 ]
}

# CORRETO: reinicializar no setup
setup() {
    export CONTADOR=0
}

8.2. Problemas de permissão e caminhos relativos

# Sempre usar caminhos absolutos ou relativos ao diretório do teste
setup() {
    cd "$(dirname "$BATS_TEST_FILENAME")"
    source "../src/script.sh"
}

8.3. Debug com logs detalhados

# Ativar debug
export BATS_TRAP_DEBUG=1

# Logs manuais
@test "debug detalhado" {
    echo "# DEBUG: variavel TEMP=$TEMP" >&3
    run meu_comando
    echo "# DEBUG: saida=$output" >&3
}

Referências