DEV Community

Richardson
Richardson

Posted on

Pytest: Como Testar Módulos Python com Configuração no Nível Superior

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}
Enter fullscreen mode Exit fullscreen mode

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 o pytest procura e carrega automaticamente. Ele permite definir fixtures, que são funções de setup e teardown reutilizáveis. Tudo que é definido em um conftest.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")
Enter fullscreen mode Exit fullscreen mode

Vamos detalhar essa fixture:

  • @pytest.fixture: Transforma a função em uma fixture do pytest.
  • 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 o pytest 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.
    ...
Enter fullscreen mode Exit fullscreen mode

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.

  1. Setup da Sessão Pytest: pytest inicia e executa nossa fixture load_test_environment devido ao scope="session" e autouse=True. O ambiente de teste, com DEPLOY_ENV=dev, é carregado.
  2. Coleta de Testes: Em seguida, pytest inicia a fase de coleta. Ele encontra tests/test_processor_fail.py e o interpretador Python executa a instrução from data_processor import process_source_file.
  3. 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 no ValueError.
  4. 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
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode
  • 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)