Mocking e patching de dependências externas

1. Introdução ao Mocking em Python

Mocking é uma técnica fundamental em testes de software que permite substituir componentes reais por objetos simulados. Em Python, mocks são objetos que imitam o comportamento de dependências externas, permitindo testar unidades de código isoladamente.

O que são mocks, stubs e fakes?
- Mock: objeto que simula comportamento e registra interações
- Stub: objeto que fornece respostas pré-definidas sem registrar chamadas
- Fake: implementação simplificada que funciona, mas não é adequada para produção

Quando mockar dependências externas:
- Chamadas a APIs REST
- Operações em banco de dados
- Acesso a sistemas de arquivos
- Serviços de terceiros
- Operações de rede

O objetivo principal é eliminar dependências lentas, instáveis ou indisponíveis durante os testes.

2. A Biblioteca unittest.mock

A biblioteca unittest.mock (disponível desde Python 3.3) fornece as classes Mock e MagicMock.

A classe Mock

from unittest.mock import Mock

# Criando um mock básico
mock_obj = Mock()
mock_obj.qualquer_metodo.return_value = 42
resultado = mock_obj.qualquer_metodo("argumento")
print(resultado)  # 42

# Atributos dinâmicos
mock_obj.atributo_qualquer = "valor"
print(mock_obj.atributo_qualquer)  # "valor"

A classe MagicMock

MagicMock herda de Mock e implementa métodos mágicos do Python:

from unittest.mock import MagicMock

mock_magico = MagicMock()
mock_magico.__len__.return_value = 5
mock_magico.__iter__.return_value = iter([1, 2, 3])

print(len(mock_magico))  # 5
print(list(mock_magico))  # [1, 2, 3]

Configuração de retornos e efeitos colaterais

from unittest.mock import Mock

# return_value: valor fixo de retorno
mock = Mock()
mock.metodo.return_value = "sucesso"

# side_effect: função, exceção ou iterável
def processar(arg):
    return f"processado: {arg}"

mock.outro_metodo.side_effect = processar
print(mock.outro_metodo("teste"))  # "processado: teste"

# side_effect pode levantar exceções
mock.erro.side_effect = ValueError("erro simulado")

3. Patching com patch

unittest.mock.patch permite substituir objetos em escopos específicos.

Como decorador e gerenciador de contexto

from unittest.mock import patch
import requests

# Como decorador
@patch('requests.get')
def test_com_decorador(mock_get):
    mock_get.return_value.status_code = 200
    mock_get.return_value.json.return_value = {"dados": "teste"}

    resposta = requests.get('https://api.exemplo.com')
    assert resposta.status_code == 200

# Como gerenciador de contexto
def test_com_contexto():
    with patch('requests.get') as mock_get:
        mock_get.return_value.status_code = 404
        resposta = requests.get('https://api.exemplo.com')
        assert resposta.status_code == 404

Escopo do patch

# Patch em nível de módulo
@patch('modulo_externo.funcao')
def test_funcao_patchada(mock_funcao):
    pass

# Patch em classe inteira
@patch('modulo_externo.ClasseExterna')
def test_classe_patchada(MockClasse):
    instancia = MockClasse.return_value
    instancia.metodo.return_value = "mockado"

Exemplo prático: mockando uma API REST

import requests
from unittest.mock import patch

def buscar_usuario(usuario_id):
    resposta = requests.get(f'https://api.exemplo.com/usuarios/{usuario_id}')
    if resposta.status_code == 200:
        return resposta.json()
    return None

@patch('requests.get')
def test_buscar_usuario_sucesso(mock_get):
    mock_get.return_value.status_code = 200
    mock_get.return_value.json.return_value = {
        "id": 1, "nome": "João", "email": "joao@exemplo.com"
    }

    resultado = buscar_usuario(1)
    assert resultado["nome"] == "João"
    mock_get.assert_called_once_with('https://api.exemplo.com/usuarios/1')

@patch('requests.get')
def test_buscar_usuario_erro(mock_get):
    mock_get.return_value.status_code = 404

    resultado = buscar_usuario(999)
    assert resultado is None

4. Verificando Interações com Mocks

from unittest.mock import Mock

mock = Mock()

# Chamando métodos para testar
mock.metodo("arg1")
mock.metodo("arg2")
mock.outro_metodo()

# Asserts de chamada
mock.metodo.assert_called()
mock.metodo.assert_called_once()
mock.metodo.assert_called_with("arg1")
mock.metodo.assert_any_call("arg2")
mock.assert_has_calls([
    mock.metodo("arg1"),
    mock.metodo("arg2"),
    mock.outro_metodo()
])

# Atributos de inspeção
print(f"Total de chamadas: {mock.metodo.call_count}")  # 2
print(f"Últimos argumentos: {mock.metodo.call_args}")  # call('arg2')
print(f"Todas as chamadas: {mock.metodo.call_args_list}")

5. Mockando Dependências Externas Específicas

Mockando requisições HTTP

from unittest.mock import patch, Mock
import requests

def processar_api():
    response = requests.post('https://api.exemplo.com/dados',
                            json={"chave": "valor"},
                            headers={"Authorization": "Bearer token"})
    return response.json()

@patch('requests.post')
def test_processar_api(mock_post):
    mock_response = Mock()
    mock_response.status_code = 201
    mock_response.json.return_value = {"id": 123}
    mock_post.return_value = mock_response

    resultado = processar_api()
    assert resultado["id"] == 123
    mock_post.assert_called_once_with(
        'https://api.exemplo.com/dados',
        json={"chave": "valor"},
        headers={"Authorization": "Bearer token"}
    )

Mockando acesso a banco de dados

from unittest.mock import patch, MagicMock

def buscar_produtos(conexao):
    cursor = conexao.cursor()
    cursor.execute("SELECT * FROM produtos")
    return cursor.fetchall()

@patch('database.Connection')
def test_buscar_produtos(MockConnection):
    mock_conexao = MockConnection.return_value
    mock_cursor = MagicMock()
    mock_conexao.cursor.return_value = mock_cursor
    mock_cursor.fetchall.return_value = [
        (1, "Produto A", 50.0),
        (2, "Produto B", 30.0)
    ]

    produtos = buscar_produtos(mock_conexao)
    assert len(produtos) == 2
    mock_cursor.execute.assert_called_once_with("SELECT * FROM produtos")

Mockando operações de sistema de arquivos

from unittest.mock import patch, mock_open

def ler_config():
    with open('config.txt', 'r') as f:
        return f.read()

@patch('builtins.open', new_callable=mock_open, read_data="config=produção")
def test_ler_config(mock_file):
    resultado = ler_config()
    assert resultado == "config=produção"
    mock_file.assert_called_once_with('config.txt', 'r')

6. Técnicas Avançadas de Mocking

spec e autospec

from unittest.mock import Mock, patch

class ServicoReal:
    def processar(self, dados):
        return f"processando {dados}"

# spec garante que apenas métodos existentes sejam chamados
mock_com_spec = Mock(spec=ServicoReal)
mock_com_spec.processar("teste")  # OK
# mock_com_spec.metodo_inexistente()  # AttributeError

# autospec verifica assinatura de métodos
@patch('modulo.ServicoReal', autospec=True)
def test_com_autospec(MockServico):
    instancia = MockServico.return_value
    instancia.processar("dados")  # Assinatura correta
    # instancia.processar()  # TypeError: argumentos incorretos

PropertyMock para propriedades

from unittest.mock import PropertyMock, patch

class Configuracao:
    @property
    def ambiente(self):
        return "produção"

@patch('modulo.Configuracao.ambiente', new_callable=PropertyMock)
def test_ambiente(mock_ambiente):
    mock_ambiente.return_value = "teste"
    config = Configuracao()
    assert config.ambiente == "teste"

patch.object e patch.multiple

from unittest.mock import patch

class Servico:
    def metodo1(self):
        return "original1"
    def metodo2(self):
        return "original2"

# patch.object para método específico
with patch.object(Servico, 'metodo1', return_value="mockado"):
    s = Servico()
    print(s.metodo1())  # "mockado"
    print(s.metodo2())  # "original2"

# patch.multiple para múltiplos métodos
with patch.multiple(Servico, metodo1=Mock(), metodo2=Mock()):
    s = Servico()
    s.metodo1()
    s.metodo2()

7. Boas Práticas e Armadilhas Comuns

Evitar over-mocking:
- Mock apenas dependências externas, não lógica interna
- Prefira testar com dados reais quando possível
- Use fixtures para simplificar a configuração

Limpeza de patches:

import unittest
from unittest.mock import patch

class TesteExemplo(unittest.TestCase):
    def setUp(self):
        self.patcher = patch('requests.get')
        self.mock_get = self.patcher.start()

    def tearDown(self):
        self.patcher.stop()

    def test_algo(self):
        self.mock_get.return_value.status_code = 200
        # teste aqui

Problemas com importação:

# ERRADO: patch no módulo importado
from modulo_externo import funcao
@patch('modulo_externo.funcao')  # Não funciona

# CORRETO: patch no local onde é usado
import modulo_externo
@patch('modulo_externo.funcao')  # Funciona

8. Exemplo Completo: Testando um Serviço com Dependências Externas

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

class ServicoClima:
    def __init__(self, api_key, db_path):
        self.api_key = api_key
        self.db_path = db_path

    def salvar_clima(self, cidade):
        # Dependência externa 1: API de clima
        response = requests.get(
            f'https://api.climatempo.com.br/{cidade}',
            headers={'Authorization': f'Bearer {self.api_key}'}
        )

        if response.status_code != 200:
            raise ValueError("Erro ao buscar clima")

        dados_clima = response.json()

        # Dependência externa 2: Banco de dados
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        cursor.execute(
            "INSERT INTO climas (cidade, temperatura, umidade) VALUES (?, ?, ?)",
            (cidade, dados_clima['temperatura'], dados_clima['umidade'])
        )
        conn.commit()
        conn.close()

        return dados_clima

@patch('requests.get')
@patch('sqlite3.connect')
def test_salvar_clima_sucesso(mock_connect, mock_get):
    # Configurar mock da API
    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = {
        "temperatura": 25.5,
        "umidade": 60
    }
    mock_get.return_value = mock_response

    # Configurar mock do banco
    mock_conn = MagicMock()
    mock_cursor = MagicMock()
    mock_connect.return_value = mock_conn
    mock_conn.cursor.return_value = mock_cursor

    # Executar teste
    servico = ServicoClima("api_key_teste", ":memory:")
    resultado = servico.salvar_clima("São Paulo")

    # Verificações
    assert resultado["temperatura"] == 25.5
    assert resultado["umidade"] == 60

    mock_get.assert_called_once_with(
        'https://api.climatempo.com.br/São Paulo',
        headers={'Authorization': 'Bearer api_key_teste'}
    )

    mock_cursor.execute.assert_called_once_with(
        "INSERT INTO climas (cidade, temperatura, umidade) VALUES (?, ?, ?)",
        ("São Paulo", 25.5, 60)
    )
    mock_conn.commit.assert_called_once()

@patch('requests.get')
def test_salvar_clima_erro_api(mock_get):
    mock_response = MagicMock()
    mock_response.status_code = 500
    mock_get.return_value = mock_response

    servico = ServicoClima("api_key_teste", ":memory:")

    import pytest
    with pytest.raises(ValueError, match="Erro ao buscar clima"):
        servico.salvar_clima("Rio de Janeiro")

Este exemplo demonstra como mockar duas dependências externas simultaneamente, garantindo que o teste seja rápido, determinístico e isolado.

Referências