Introdução
Em engenharia de dados, é comum criar scripts procedurais em Python para orquestrar pipelines. Um padrão frequente nesses scripts é a definição de configurações globais — como nomes de buckets — no nível superior do módulo, baseadas em variáveis de ambiente que mudam entre dev
e prd
.
Embora seja uma abordagem direta, ela introduz um desafio significativo para a criação de testes unitários com pytest
. Frequentemente, os testes falham durante a fase de importação, antes da execução de qualquer lógica, devido a configurações de ambiente ausentes.
Este artigo apresenta uma análise técnica da causa raiz desse problema, explica o papel fundamental do conftest.py
e oferece duas soluções práticas para garantir que seus módulos sejam testáveis.
O Padrão de Código e o Desafio do Teste
Considere o script data_processor.py
. Sua função é ler dados de um bucket "raw" e escrevê-los em um bucket "processed". Os nomes dos buckets são determinados pela variável de ambiente DEPLOY_ENV
.
data_processor.py
import os
# Suponha uma biblioteca interna para operações S3
import s3_utils
# 1. Configuração lida no nível do módulo
ENV_NAME = os.getenv("DEPLOY_ENV") # Ex: 'dev' ou 'prd'
# 2. Validação que ocorre durante a importação
if not ENV_NAME:
raise ValueError("A variável de ambiente DEPLOY_ENV não foi definida.")
# 3. Variáveis globais construídas a partir da configuração
RAW_DATA_BUCKET = f"company-data-{ENV_NAME}-raw"
PROCESSED_DATA_BUCKET = f"company-data-{ENV_NAME}-processed"
def process_source_file(source_id: str) -> dict:
"""
Lê um arquivo da zona raw, processa e salva na zona processed.
"""
source_path = f"s3://{RAW_DATA_BUCKET}/sources/{source_id}.csv"
destination_path = f"s3://{PROCESSED_DATA_BUCKET}/reports/{source_id}.parquet"
data = s3_utils.read_csv(source_path)
# ...lógica de processamento...
s3_utils.write_parquet(data, destination_path)
return {"source": source_path, "destination": destination_path}
O desafio é claro: a linha raise ValueError
será executada assim que o módulo for importado se DEPLOY_ENV
não estiver definida, impedindo qualquer teste.
A Estratégia de Teste Inicial: Usando conftest.py
Para testar nosso script, precisamos controlar suas dependências externas. Neste caso, a dependência é a variável de ambiente DEPLOY_ENV
. A ferramenta padrão e mais poderosa do pytest
para gerenciar configurações e dependências compartilhadas é o arquivo conftest.py
.
O que é o
conftest.py
?
É um arquivo especial que opytest
procura e carrega automaticamente. Ele permite definir fixtures, que são funções de setup e teardown reutilizáveis. Tudo que é definido em umconftest.py
fica disponível para todos os testes no mesmo diretório e em subdiretórios.
Nossa estratégia inicial seria criar um conftest.py
para carregar um ambiente de teste.
tests/conftest.py
import pytest
from dotenv import load_dotenv
@pytest.fixture(scope="session", autouse=True)
def load_test_environment():
"""
Carrega variáveis de um arquivo .env para a sessão de testes.
"""
load_dotenv(dotenv_path="tests/.env.test")
Vamos detalhar essa fixture:
-
@pytest.fixture
: Transforma a função em uma fixture dopytest
. -
scope="session"
: Define que a fixture será executada apenas uma vez por sessão de teste, e não antes de cada teste. É ideal para configurações que não mudam. -
autouse=True
: Este é o parâmetro chave. Ele instrui opytest
a executar esta fixture automaticamente para todos os testes, sem que precisemos solicitá-la explicitamente. É perfeito para um setup de ambiente global.
Com essa configuração, parece que nosso problema está resolvido. No entanto, o seguinte teste ainda falhará:
tests/test_processor_fail.py
# Este import irá disparar a validação em data_processor.py
from data_processor import process_source_file
def test_file_processing(mocker):
# O erro ocorre antes que o corpo do teste seja executado.
...
Análise da Causa Raiz: Ordem de Execução no Pytest
Apesar de nossa configuração correta no conftest.py
, a falha ocorre devido à interação entre o mecanismo de importação do Python e o ciclo de vida do pytest
.
- Setup da Sessão Pytest:
pytest
inicia e executa nossa fixtureload_test_environment
devido aoscope="session"
eautouse=True
. O ambiente de teste, comDEPLOY_ENV=dev
, é carregado. - Coleta de Testes: Em seguida,
pytest
inicia a fase de coleta. Ele encontratests/test_processor_fail.py
e o interpretador Python executa a instruçãofrom data_processor import process_source_file
. - Falha na Importação: É neste momento que o código no nível superior do
data_processor.py
é executado. Por razões de "timing" e isolamento de processos na fase de coleta, o ambiente recém-configurado pela fixture pode não estar visível para este processo de importação imediato, resultando noValueError
. - Execução do Teste: A fase de execução do teste nunca é alcançada.
A solução é garantir que a importação do módulo problemático ocorra somente após o ambiente de teste do pytest
estar completamente estabelecido e visível.
Solução 1: Importação Local
A abordagem mais direta é mover a instrução import
do topo do arquivo para dentro da função de teste.
tests/test_processor_solution1.py
def test_process_source_file_with_local_import(mocker):
"""
Testa o processamento de arquivos adiando a importação do módulo.
"""
# 1. A importação ocorre aqui, dentro do escopo de execução do teste.
from data_processor import process_source_file
# 2. Agora podemos mockar as dependências do módulo recém-importado.
mock_s3_utils = mocker.patch("data_processor.s3_utils")
# 3. Executamos a função e validamos o resultado.
result = process_source_file("user_123")
expected_dest = "s3://company-data-dev-processed/reports/user_123.parquet"
mock_s3_utils.write_parquet.assert_called_once_with(mocker.ANY, expected_dest)
assert result["destination"] == expected_dest
- Vantagens: Simples, explícito e resolve o problema de forma eficaz.
-
Desvantagens: Pode levar à repetição do
import
se múltiplos testes no mesmo arquivo precisarem da mesma função.
Solução 2: Fixture para Injeção do Módulo
Uma alternativa mais escalável é encapsular a importação local dentro de uma fixture. O teste então declara sua dependência nesta fixture, que fornece o módulo importado como um objeto. Para maior clareza, esta fixture pode ser definida no próprio arquivo de teste.
tests/test_processor_solution2.py
import pytest
@pytest.fixture(scope="module")
def data_processor_module():
"""
Fixture que importa e retorna o módulo data_processor.
A importação ocorre apenas quando a fixture é utilizada.
"""
import data_processor
return data_processor
def test_process_source_file_with_fixture(mocker, data_processor_module):
"""
Testa o processamento de arquivos usando um módulo injetado via fixture.
"""
# 1. A fixture `data_processor_module` é executada, importando o módulo.
mock_s3_utils = mocker.patch("data_processor.s3_utils")
# 2. Chamamos a função através do objeto do módulo injetado.
result = data_processor_module.process_source_file("user_123")
expected_dest = "s3://company-data-dev-processed/reports/user_123.parquet"
mock_s3_utils.write_parquet.assert_called_once_with(mocker.ANY, expected_dest)
assert result["destination"] == expected_dest
- Vantagens: Promove a reutilização de código (DRY), mantém os testes limpos e centraliza a lógica de importação tardia.
- Desvantagens: Adiciona um nível de indireção que pode ser menos óbvio para desenvolvedores não familiarizados com o padrão.
Conclusão e Recomendações
O acoplamento entre a lógica de um módulo e sua configuração no nível superior é um desafio comum para a testabilidade. Compreender o ciclo de vida do pytest
e o papel do conftest.py
é fundamental para diagnosticar e resolver os problemas de importação resultantes.
- A importação local (Solução 1) é a abordagem mais direta e recomendada para casos simples.
- O uso de uma fixture para injeção (Solução 2) é preferível em cenários onde múltiplos testes precisam acessar diferentes funções de um mesmo módulo, oferecendo uma solução mais limpa e organizada.
Ambas as técnicas são ferramentas valiosas para aumentar a cobertura de testes e a robustez de aplicações Python que não foram inicialmente projetadas com a testabilidade em mente.
Top comments (0)