Testes com unittest: fundamentos

1. Introdução ao módulo unittest

O módulo unittest é o framework de testes unitários nativo do Python, presente na biblioteca padrão desde a versão 2.1. Inspirado no JUnit (Java) e na filosofia xUnit, ele fornece uma estrutura robusta para criar, organizar e executar testes automatizados. Diferentemente do pytest, que adota uma abordagem mais concisa com funções simples, o unittest segue um modelo orientado a objetos, exigindo que os testes sejam escritos como métodos dentro de classes que herdam de TestCase.

A estrutura básica de um arquivo de teste consiste em:
- Importação do módulo unittest
- Criação de uma classe que herda de unittest.TestCase
- Definição de métodos que começam com test_

import unittest

class TestExemplo(unittest.TestCase):
    def test_soma(self):
        self.assertEqual(1 + 1, 2)

if __name__ == '__main__':
    unittest.main()

2. Escrevendo o primeiro caso de teste

Para criar um caso de teste, basta definir métodos dentro da classe de teste com o prefixo test_. O unittest identifica automaticamente esses métodos e os executa. Os métodos de asserção mais comuns incluem:

import unittest

def calcular_area(base, altura):
    return base * altura

def validar_email(email):
    return '@' in email and '.' in email.split('@')[-1]

class TestCalculadora(unittest.TestCase):
    def test_area_retangulo(self):
        self.assertEqual(calcular_area(5, 3), 15)

    def test_area_zero(self):
        self.assertEqual(calcular_area(0, 10), 0)

    def test_email_valido(self):
        self.assertTrue(validar_email('usuario@exemplo.com'))

    def test_email_invalido(self):
        self.assertFalse(validar_email('usuarioexemplo.com'))

    def test_divisao_por_zero(self):
        with self.assertRaises(ZeroDivisionError):
            10 / 0

if __name__ == '__main__':
    unittest.main()

Para executar os testes, utilize o terminal:

python -m unittest test_calculadora.py

Ou, para descoberta automática em um diretório:

python -m unittest discover

3. Organização e ciclo de vida dos testes

O unittest oferece métodos especiais para gerenciar o ciclo de vida dos testes:

import unittest
import os
import tempfile

class TestArquivo(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        """Executado uma vez antes de todos os testes da classe"""
        cls.diretorio_temp = tempfile.mkdtemp()

    @classmethod
    def tearDownClass(cls):
        """Executado uma vez após todos os testes da classe"""
        os.rmdir(cls.diretorio_temp)

    def setUp(self):
        """Executado antes de cada teste"""
        self.arquivo = os.path.join(self.diretorio_temp, 'teste.txt')
        with open(self.arquivo, 'w') as f:
            f.write('dados iniciais')

    def tearDown(self):
        """Executado após cada teste"""
        if os.path.exists(self.arquivo):
            os.remove(self.arquivo)

    def test_leitura_arquivo(self):
        with open(self.arquivo, 'r') as f:
            conteudo = f.read()
        self.assertEqual(conteudo, 'dados iniciais')

    def test_escrita_arquivo(self):
        with open(self.arquivo, 'a') as f:
            f.write('\nnovos dados')
        with open(self.arquivo, 'r') as f:
            linhas = f.readlines()
        self.assertEqual(len(linhas), 2)

if __name__ == '__main__':
    unittest.main()

Boas práticas importantes:
- Cada teste deve ser independente e não depender do resultado de outro teste
- Use setUp() para criar objetos reutilizáveis
- Use tearDown() para limpar recursos (arquivos, conexões)
- A ordem de execução dos testes não é garantida

4. Asserções avançadas e mensagens personalizadas

O unittest oferece asserções especializadas para diferentes tipos de dados:

import unittest

class TestAssercoesAvancadas(unittest.TestCase):
    def test_listas(self):
        self.assertListEqual([1, 2, 3], [1, 2, 3])
        self.assertIn(2, [1, 2, 3])
        self.assertNotIn(4, [1, 2, 3])

    def test_dicionarios(self):
        self.assertDictEqual({'a': 1, 'b': 2}, {'b': 2, 'a': 1})
        self.assertIn('a', {'a': 1, 'b': 2})

    def test_excecoes_com_contexto(self):
        with self.assertRaises(ValueError) as contexto:
            int('abc')
        self.assertEqual(str(contexto.exception), "invalid literal for int() with base 10: 'abc'")

    def test_mensagem_personalizada(self):
        resultado = 2 + 2
        self.assertEqual(resultado, 5, f"Esperado 5, mas obtive {resultado}")

    def test_aproximacao_numerica(self):
        self.assertAlmostEqual(3.14159, 3.1416, places=3)

    def test_identidade(self):
        lista = [1, 2, 3]
        self.assertIs(lista, lista)  # mesmo objeto
        self.assertIsNot(lista, [1, 2, 3])  # objetos diferentes

if __name__ == '__main__':
    unittest.main()

5. Testes com dependências externas (mocking básico)

O módulo unittest.mock permite simular dependências externas, como APIs, bancos de dados ou sistemas de arquivos:

import unittest
from unittest.mock import patch, MagicMock
import requests

def buscar_dados_api(url):
    resposta = requests.get(url)
    if resposta.status_code == 200:
        return resposta.json()
    return None

def processar_usuario(usuario_id):
    dados = buscar_dados_api(f'https://api.exemplo.com/usuarios/{usuario_id}')
    if dados:
        return f"Usuário: {dados['nome']}, Email: {dados['email']}"
    return "Usuário não encontrado"

class TestMocking(unittest.TestCase):
    @patch('requests.get')
    def test_buscar_dados_sucesso(self, mock_get):
        mock_resposta = MagicMock()
        mock_resposta.status_code = 200
        mock_resposta.json.return_value = {'nome': 'João', 'email': 'joao@exemplo.com'}
        mock_get.return_value = mock_resposta

        resultado = buscar_dados_api('https://api.exemplo.com/usuarios/1')

        self.assertEqual(resultado, {'nome': 'João', 'email': 'joao@exemplo.com'})
        mock_get.assert_called_once_with('https://api.exemplo.com/usuarios/1')

    @patch('test_mocking.buscar_dados_api')
    def test_processar_usuario(self, mock_buscar):
        mock_buscar.return_value = {'nome': 'Maria', 'email': 'maria@exemplo.com'}

        resultado = processar_usuario(2)

        self.assertEqual(resultado, "Usuário: Maria, Email: maria@exemplo.com")
        self.assertEqual(mock_buscar.call_count, 1)

    @patch('test_mocking.buscar_dados_api')
    def test_usuario_nao_encontrado(self, mock_buscar):
        mock_buscar.return_value = None

        resultado = processar_usuario(999)

        self.assertEqual(resultado, "Usuário não encontrado")

if __name__ == '__main__':
    unittest.main()

6. Organizando suites de teste e descoberta automática

Para projetos maiores, é essencial organizar os testes em suites e utilizar a descoberta automática:

# test_suites.py
import unittest
from test_calculadora import TestCalculadora
from test_arquivo import TestArquivo
from test_mocking import TestMocking

def criar_suite():
    suite = unittest.TestSuite()

    # Adicionando testes individuais
    suite.addTest(TestCalculadora('test_area_retangulo'))
    suite.addTest(TestArquivo('test_leitura_arquivo'))

    # Adicionando todos os testes de uma classe
    suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestMocking))

    return suite

if __name__ == '__main__':
    runner = unittest.TextTestRunner()
    runner.run(criar_suite())

Estrutura de diretórios recomendada para projetos reais:

projeto/
├── src/
│   ├── __init__.py
│   ├── calculadora.py
│   └── usuario.py
├── tests/
│   ├── __init__.py
│   ├── test_calculadora.py
│   ├── test_usuario.py
│   └── test_suites.py
└── requirements.txt

Para descoberta automática:

python -m unittest discover -s tests -p "test_*.py" -v

7. Dicas de boas práticas e armadilhas comuns

import unittest

# BOA PRÁTICA: Nomeação clara
class TestSistemaAutenticacao(unittest.TestCase):
    def test_usuario_valido_autentica_com_sucesso(self):
        pass

    def test_senha_invalida_retorna_erro(self):
        pass

# ARMADILHA: Dependência entre testes
contador_global = 0

class TestComEstadoGlobal(unittest.TestCase):
    def test_incremento(self):
        global contador_global
        contador_global += 1
        self.assertEqual(contador_global, 1)  # Funciona isoladamente

    def test_outro_incremento(self):
        global contador_global
        self.assertEqual(contador_global, 0)  # FALHA! Depende da ordem

# CORREÇÃO: Isolamento
class TestCorrigido(unittest.TestCase):
    def setUp(self):
        self.contador = 0

    def test_incremento(self):
        self.contador += 1
        self.assertEqual(self.contador, 1)

    def test_outro_incremento(self):
        self.assertEqual(self.contador, 0)  # Sempre 0, independente

# ARMADILHA: assertEqual vs assertIs
class TestIdentidade(unittest.TestCase):
    def test_igualdade(self):
        self.assertEqual([1, 2, 3], [1, 2, 3])  # OK: mesmo conteúdo

    def test_identidade(self):
        self.assertIs([1, 2, 3], [1, 2, 3])  # FALHA: objetos diferentes

    def test_identidade_correta(self):
        lista = [1, 2, 3]
        self.assertIs(lista, lista)  # OK: mesmo objeto

if __name__ == '__main__':
    unittest.main()

Referências