Python Admin

Mocks em Python: unittest.mock, patch e pytest-mock na Prática: Do Básico ao Avançado Já leu

Introdução: Por que Mocks são Essenciais Quando desenvolvemos software profissional, frequentemente precisamos testar código que depende de recursos externos: APIs, bancos de dados, sistemas de arquivo, ou serviços terceirizados. O problema é que essas dependências tornam os testes lentos, instáveis e difíceis de manter. É aqui que mocks entram em cena. Um mock é um objeto que simula o comportamento de um objeto real, permitindo que você controle completamente como ele se comporta dentro de um teste. Em vez de fazer uma chamada real a uma API, por exemplo, você substitui aquele serviço por um mock que retorna exatamente o que você precisa, quando você precisa. Isso não apenas torna seus testes mais rápidos e confiáveis, mas também permite testar cenários que seriam impossíveis com dependências reais (como simular falhas de rede). Python oferece várias ferramentas para trabalhar com mocks: é a biblioteca padrão da linguagem, é o mecanismo de substituição e é um plugin que torna a experiência ainda

Introdução: Por que Mocks são Essenciais

Quando desenvolvemos software profissional, frequentemente precisamos testar código que depende de recursos externos: APIs, bancos de dados, sistemas de arquivo, ou serviços terceirizados. O problema é que essas dependências tornam os testes lentos, instáveis e difíceis de manter. É aqui que mocks entram em cena.

Um mock é um objeto que simula o comportamento de um objeto real, permitindo que você controle completamente como ele se comporta dentro de um teste. Em vez de fazer uma chamada real a uma API, por exemplo, você substitui aquele serviço por um mock que retorna exatamente o que você precisa, quando você precisa. Isso não apenas torna seus testes mais rápidos e confiáveis, mas também permite testar cenários que seriam impossíveis com dependências reais (como simular falhas de rede).

Python oferece várias ferramentas para trabalhar com mocks: unittest.mock é a biblioteca padrão da linguagem, patch é o mecanismo de substituição e pytest-mock é um plugin que torna a experiência ainda mais intuitiva. Dominar essas ferramentas é fundamental para escrever testes profissionais e manuteníveis.

unittest.mock: Fundamentos e Conceitos Centrais

O que é Mock e MagicMock

A biblioteca unittest.mock oferece dois tipos principais de objetos simulados: Mock e MagicMock. A diferença fundamental é que MagicMock implementa automaticamente métodos mágicos (dunder methods) como __str__, __len__, __iter__, tornando-o mais versátil. Na maioria dos casos reais, você usará MagicMock.

Veja um exemplo prático. Imagine que você tem uma classe que faz requisições HTTP:

# servico.py
import requests

class ProcessadorDados:
    def buscar_usuario(self, usuario_id):
        resposta = requests.get(f"https://api.exemplo.com/usuarios/{usuario_id}")
        return resposta.json()

Testar isso de verdade significa fazer uma requisição real, o que é lento e frágil. Com mocks, você faz isso:

# test_servico.py
from unittest.mock import MagicMock, patch
import pytest
from servico import ProcessadorDados

def test_buscar_usuario_com_mock():
    # Criar um mock para requests
    with patch('servico.requests.get') as mock_get:
        # Configurar o comportamento do mock
        mock_resposta = MagicMock()
        mock_resposta.json.return_value = {'id': 1, 'nome': 'João'}
        mock_get.return_value = mock_resposta

        # Executar o código que será testado
        servico = ProcessadorDados()
        resultado = servico.buscar_usuario(1)

        # Fazer asserções
        assert resultado['nome'] == 'João'
        mock_get.assert_called_once_with('https://api.exemplo.com/usuarios/1')

Neste exemplo, usamos patch para substituir requests.get por um mock, configuramos o retorno esperado, e depois verificamos que a função foi chamada corretamente. O teste executa em milissegundos sem fazer nenhuma requisição real.

Configurando Comportamentos: return_value e side_effect

Um mock é inútil se você não conseguir controlar seu comportamento. Os dois mecanismos principais são return_value (o que o mock retorna quando chamado) e side_effect (efeitos colaterais, como exceções ou sequências de retornos).

Use return_value quando o mock deve sempre retornar a mesma coisa:

from unittest.mock import MagicMock

def test_configurar_return_value():
    mock_banco = MagicMock()
    mock_banco.buscar_usuario.return_value = {'id': 1, 'nome': 'Maria'}

    resultado = mock_banco.buscar_usuario(1)
    assert resultado == {'id': 1, 'nome': 'Maria'}

    # O mock retorna o mesmo valor toda vez que é chamado
    resultado2 = mock_banco.buscar_usuario(999)
    assert resultado2 == {'id': 1, 'nome': 'Maria'}

Use side_effect quando precisa simular comportamentos mais complexos, como lançar exceções ou retornar valores diferentes em cada chamada:

from unittest.mock import MagicMock
import requests

def test_side_effect_excecao():
    mock_requisicao = MagicMock()
    mock_requisicao.get.side_effect = requests.ConnectionError("Conexão recusada")

    with pytest.raises(requests.ConnectionError):
        mock_requisicao.get("https://api.exemplo.com")

def test_side_effect_multiplos_retornos():
    mock_iterador = MagicMock()
    # Retornar valores diferentes em cada chamada
    mock_iterador.processar.side_effect = [1, 2, 3, Exception("Fim")]

    assert mock_iterador.processar() == 1
    assert mock_iterador.processar() == 2
    assert mock_iterador.processar() == 3

    with pytest.raises(Exception):
        mock_iterador.processar()

patch: Substituindo Objetos no Local Correto

Entendendo o Escopo de patch

O conceito mais importante ao usar patch é onde você está substituindo o objeto. Muitos iniciantes comettem o erro de fazer patch no lugar errado e acabam gastando horas investigando por que o mock não funciona.

A regra de ouro é: faça patch onde o objeto é usado, não onde é definido. Se você tem um módulo autenticacao.py que importa requests, você não faz patch em requests, mas sim em autenticacao.requests.

Considere este exemplo real:

# email_service.py
import smtplib

class EnviadorEmail:
    def enviar(self, destinatario, mensagem):
        # Usando smtplib diretamente
        servidor = smtplib.SMTP('smtp.gmail.com', 587)
        servidor.sendmail('seu@email.com', destinatario, mensagem)
        return True

# test_email_service.py
from unittest.mock import patch
from email_service import EnviadorEmail

def test_envio_email_errado():
    # ERRADO: fazendo patch no lugar errado
    with patch('smtplib.SMTP'):  # Isso não vai funcionar!
        servico = EnviadorEmail()
        resultado = servico.enviar('user@example.com', 'Olá')

def test_envio_email_correto():
    # CORRETO: fazendo patch onde é usado
    with patch('email_service.smtplib.SMTP') as mock_smtp:
        mock_smtp.return_value.sendmail.return_value = None

        servico = EnviadorEmail()
        resultado = servico.enviar('user@example.com', 'Olá')

        assert resultado == True
        # Verificar que sendmail foi chamado com os argumentos corretos
        mock_smtp.return_value.sendmail.assert_called_once()

Patch como Context Manager e Decorator

Existem duas formas principais de usar patch: como context manager (com with) ou como decorator (com @patch). Cada uma tem seu uso apropriado.

Use context manager quando você precisa fazer setup/teardown dentro de um teste específico ou quando quer ativar o patch apenas para uma parte do teste:

from unittest.mock import patch
from datetime import datetime

def test_com_context_manager():
    # Apenas dentro deste bloco, datetime.now será substituído
    with patch('datetime.datetime') as mock_datetime:
        mock_datetime.now.return_value = datetime(2024, 1, 1, 12, 0, 0)

        # Seu código aqui usa o mock
        resultado = funcao_que_usa_datetime()
        assert resultado == alguma_coisa

    # Fora do contexto, datetime volta ao normal

Use decorator quando você quer que o patch seja aplicado a todo o teste. O mock é passado como argumento à função de teste:

from unittest.mock import patch

@patch('modulo.servico_externo.fazer_requisicao')
def test_com_decorator(mock_requisicao):
    mock_requisicao.return_value = {'status': 'sucesso'}

    resultado = minha_funcao_que_chama_servico()
    assert resultado == esperado

Múltiplos decorators são aplicados de baixo para cima (reversamente), e aparecem nos argumentos na mesma ordem invertida:

@patch('modulo.banco_dados')
@patch('modulo.cache')
@patch('modulo.servico_api')
def test_multiplos_patches(mock_api, mock_cache, mock_banco):
    # mock_api é de servico_api
    # mock_cache é de cache
    # mock_banco é de banco_dados
    pass

pytest-mock: Simplificando o Trabalho com Fixtures

Introdução ao Plugin pytest-mock

Enquanto unittest.mock é poderoso, pytest-mock é uma camada que torna a experiência muito mais pythônica e menos verbosa. Em vez de usar decorators e context managers, você recebe um fixture chamado mocker que oferece métodos convenientes. Para usar, instale com pip install pytest-mock.

A principal vantagem do pytest-mock é que você não precisa se preocupar com cleanup: pytest cuida disso automaticamente quando o teste termina. Além disso, a sintaxe é mais intuitiva:

# test_com_pytest_mock.py
from processador import ProcessadorDados

def test_buscar_usuario_pytest_mock(mocker):
    # Em vez de patch como context manager ou decorator,
    # você simplesmente chama mocker.patch()
    mock_get = mocker.patch('processador.requests.get')

    # Configurar o retorno é mais direto
    mock_get.return_value.json.return_value = {'id': 1, 'nome': 'João'}

    # Executar e validar
    servico = ProcessadorDados()
    resultado = servico.buscar_usuario(1)

    assert resultado['nome'] == 'João'
    mock_get.assert_called_once()

Fixtures e Mocking: Um Exemplo Realista

A verdadeira força do pytest-mock aparece quando você combina com fixtures. Imagine um projeto real onde múltiplos testes precisam de mocks similares:

# test_autenticacao.py
import pytest
from autenticacao import AutenticadorOAuth

@pytest.fixture
def mock_oauth_provider(mocker):
    """Fixture que fornece um mock já configurado para o OAuth"""
    mock = mocker.patch('autenticacao.requests.post')
    mock.return_value.json.return_value = {
        'access_token': 'token_valido_123',
        'expires_in': 3600
    }
    return mock

def test_login_sucesso(mock_oauth_provider):
    autenticador = AutenticadorOAuth()
    token = autenticador.fazer_login('usuario', 'senha')

    assert token == 'token_valido_123'
    mock_oauth_provider.assert_called_once()

def test_login_com_refresh_token(mock_oauth_provider):
    # O mesmo mock é reutilizado
    autenticador = AutenticadorOAuth()
    autenticador.fazer_login('usuario', 'senha')

    # Configurar novo comportamento para próxima chamada
    mock_oauth_provider.return_value.json.return_value = {
        'access_token': 'novo_token_456',
        'expires_in': 3600
    }

    novo_token = autenticador.renovar_token('token_valido_123')
    assert novo_token == 'novo_token_456'

Neste padrão, você define uma fixture que cria um mock com configurações padrão, e depois múltiplos testes podem usar essa fixture, economizando código e tornando os testes mais legíveis.

Casos de Uso Avançados e Boas Práticas

Spy: Testando Chamadas sem Substituir Completamente

Às vezes você não quer substituir completamente uma função, mas sim "espioná-la" para verificar como foi chamada. O unittest.mock oferece wraps para isso, mas pytest-mock oferece um método mais conveniente: mocker.spy().

# calculadora.py
class Calculadora:
    def somar(self, a, b):
        return a + b

    def calcular_total(self, valores):
        total = 0
        for valor in valores:
            total = self.somar(total, valor)
        return total

# test_calculadora.py
def test_spy_chamadas(mocker):
    calc = Calculadora()

    # Criar um spy: monitora chamadas mas deixa o código rodar normalmente
    spy_somar = mocker.spy(calc, 'somar')

    resultado = calc.calcular_total([1, 2, 3])

    assert resultado == 6
    # Verificar que somar foi chamado 3 vezes
    assert spy_somar.call_count == 3
    # Verificar as chamadas em detalhe
    assert spy_somar.call_args_list[0] == mocker.call(0, 1)
    assert spy_somar.call_args_list[1] == mocker.call(1, 2)
    assert spy_somar.call_args_list[2] == mocker.call(3, 3)

Testando Arquivos e Sistema de Arquivos Sem Criar Arquivos Reais

Um caso comum é testar código que lê ou escreve arquivos. Você não quer criar arquivos reais durante testes. Aqui, mocker.patch com mock_open (da biblioteca unittest.mock) é ideal:

# processador_arquivo.py
class ProcessadorCSV:
    def processar(self, caminho_arquivo):
        with open(caminho_arquivo, 'r') as f:
            linhas = f.readlines()
        return len(linhas)

# test_processador_arquivo.py
from unittest.mock import mock_open

def test_processar_arquivo(mocker):
    # Simular arquivo com 3 linhas
    mock_file_data = "linha1\nlinha2\nlinha3\n"

    mocker.patch('builtins.open', mock_open(read_data=mock_file_data))

    processador = ProcessadorCSV()
    resultado = processador.processar('dados.csv')

    assert resultado == 3

Testando Exceções e Casos de Erro

Um dos maiores valores de mocks é a capacidade de testar casos de erro sem gerar erros reais. Use side_effect com exceções:

# api_service.py
class ClienteAPI:
    def __init__(self, url_base):
        self.url_base = url_base

    def buscar_dados(self, endpoint):
        import requests
        try:
            resposta = requests.get(f"{self.url_base}/{endpoint}")
            resposta.raise_for_status()
            return resposta.json()
        except requests.RequestException as e:
            return {'erro': str(e), 'status': 'falha'}

# test_api_service.py
def test_tratamento_erro_conexao(mocker):
    import requests

    mock_get = mocker.patch('requests.get')
    mock_get.side_effect = requests.ConnectionError("Servidor indisponível")

    cliente = ClienteAPI("https://api.exemplo.com")
    resultado = cliente.buscar_dados("users")

    assert resultado['status'] == 'falha'
    assert 'indisponível' in resultado['erro']

Asserções Avançadas em Mocks

Além de verificar se um mock foi chamado, você pode fazer asserções sofisticadas sobre como foi chamado:

from unittest.mock import call

def test_asercoes_avancadas(mocker):
    mock_obj = mocker.MagicMock()

    # Simular várias chamadas
    mock_obj.metodo(1, 'a')
    mock_obj.metodo(2, 'b')
    mock_obj.metodo(1, 'a')  # Chamada repetida

    # Verificar número total de chamadas
    assert mock_obj.metodo.call_count == 3

    # Verificar sequência exata de chamadas
    mock_obj.metodo.assert_has_calls([
        call(1, 'a'),
        call(2, 'b'),
        call(1, 'a')
    ])

    # Verificar que foi chamado com argumentos específicos em algum momento
    mock_obj.metodo.assert_any_call(2, 'b')

    # Obter o último argumento com que foi chamado
    assert mock_obj.metodo.call_args == call(1, 'a')

Conclusão

Após trabalhar profissionalmente com testes por anos, posso dizer com segurança que mocks são a base de testes rápidos e confiáveis. O aprendizado principal é este: mocks não são truques avançados, são necessidade prática. Sem eles, você fica preso a testes lentos que dependem de sistemas externos, o que cria um ciclo vicioso onde a suite de testes fica tão lenta que você para de executá-la.

O segundo ponto essencial é entender que patch funciona melhor quando você aplica a regra de ouro: patch no lugar onde o objeto é usado, não onde é definido. 99% dos problemas que vejo com mocks em código legado são por aplicar patch no lugar errado.

Finalmente, use pytest-mock em novos projetos. Sim, unittest.mock é padrão, mas pytest-mock é superior em experiência do desenvolvedor e integração com pytest. A economia de linhas de código e a clareza dos testes compensa largamente a dependência de um plugin externo.

Referências

  1. unittest.mock — documentação oficial Python
  2. pytest-mock — documentação do plugin
  3. Real Python — Getting Started With Mocking in Python
  4. Martin Fowler — Mocks Aren't Stubs
  5. Python Testing with pytest — Brian Okken, Cap. 7

Artigos relacionados