<h2>Fundamentos de Estratégias de Testes em Pipelines CI</h2>
<p>A integração contínua (CI) é o coração do desenvolvimento moderno, mas sem uma estratégia de testes bem definida, ela vira apenas um sistema que falha rapidamente. Quando você estabelece um pipeline CI, você está criando um fluxo automatizado que compila, testa e valida o código a cada commit. A questão que surge é: quais testes executar, em que ordem e com qual custo de tempo/recursos?</p>
<p>Aqui entra a pirâmide de testes — um conceito fundamental que você precisa internalizar desde agora. A base são os unit tests (muitos, rápidos, baratos), o meio são os integration tests (moderados, mais lentosque unit tests) e o topo são os smoke tests (poucos, focados em validar o caminho crítico). Ignorar essa estrutura leva a pipelines lentos, caros e pouco confiáveis. Vamos entender cada camada em profundidade.</p>
<h2>Unit Tests: A Fundação Sólida</h2>
<h3>O que são e por que importam</h3>
<p>Unit tests são testes que validam unidades isoladas de código — geralmente uma função ou um método. Eles devem rodar em milissegundos, não depender de banco de dados ou APIs externas, e responder a uma pergunta simples: "essa função faz exatamente o que prometeu fazer?" Se você tem uma função que calcula o preço final de um produto com desconto, um unit test validaria que <code>calcularPreco(100, 0.1)</code> retorna <code>90</code>.</p>
<p>A razão pela qual unit tests são a base da pirâmide é matemática simples: quanto mais cedo você detecta um bug, mais barato custa corrigi-lo. Um bug encontrado em um unit test custa minutos para corrigir. O mesmo bug encontrado em produção pode custar horas, dias ou mais. Além disso, unit tests bem escritos documentam o comportamento esperado do código — funcionam como uma especificação viva.</p>
<h3>Exemplo prático em Python</h3>
<p>Vamos imaginar um sistema de e-commerce. Você tem uma classe que aplica descontos:</p>
<pre><code class="language-python">class DescontoService:
def aplicar_desconto(self, valor_original: float, percentual: int) -> float:
"""Aplica desconto percentual ao valor original."""
if percentual < 0 or percentual > 100:
raise ValueError("Percentual deve estar entre 0 e 100")
return valor_original * (1 - percentual / 100)
def desconto_cliente_premium(self, valor_original: float, dias_cliente: int) -> float:
"""Clientes com mais de 365 dias ganham 15% de desconto."""
if dias_cliente >= 365:
return self.aplicar_desconto(valor_original, 15)
return valor_original</code></pre>
<p>Agora os testes unitários usando <code>pytest</code>:</p>
<pre><code class="language-python">import pytest
from app.services import DescontoService
class TestDescontoService:
@pytest.fixture
def servico(self):
return DescontoService()
def test_aplicar_desconto_valido(self, servico):
resultado = servico.aplicar_desconto(100, 10)
assert resultado == 90
def test_aplicar_desconto_zero(self, servico):
resultado = servico.aplicar_desconto(100, 0)
assert resultado == 100
def test_aplicar_desconto_maximo(self, servico):
resultado = servico.aplicar_desconto(100, 100)
assert resultado == 0
def test_aplicar_desconto_percentual_invalido_negativo(self, servico):
with pytest.raises(ValueError):
servico.aplicar_desconto(100, -5)
def test_aplicar_desconto_percentual_invalido_acima_100(self, servico):
with pytest.raises(ValueError):
servico.aplicar_desconto(100, 150)
def test_desconto_cliente_premium_com_direito(self, servico):
resultado = servico.desconto_cliente_premium(100, 365)
assert resultado == 85 # 15% de desconto
def test_desconto_cliente_premium_sem_direito(self, servico):
resultado = servico.desconto_cliente_premium(100, 364)
assert resultado == 100 # sem desconto</code></pre>
<p>Note como cada teste é focado em um comportamento específico. Os testes validam casos válidos, limites e exceções. Quando você roda <code>pytest app/tests/test_services.py -v</code>, cada teste executa em milissegundos e você sabe imediatamente se suas funções fazem o que prometem.</p>
<h3>Configuração no Pipeline CI</h3>
<p>No seu arquivo <code>.github/workflows/ci.yml</code> (GitHub Actions), você deve rodar unit tests como primeiro passo:</p>
<pre><code class="language-yaml">name: CI Pipeline
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Run unit tests
run: pytest app/tests/unit/ -v --cov=app --cov-report=term-missing
- name: Check coverage
run: |
coverage report --fail-under=80</code></pre>
<p>Essa configuração executa todos os unit tests e garante que sua cobertura de código (coverage) não caia abaixo de 80%. Se algum teste falhar, o pipeline para ali e você é notificado imediatamente.</p>
<h2>Integration Tests: Validando a Orquestração</h2>
<h3>O que são e quando usar</h3>
<p>Enquanto unit tests validam unidades isoladas, integration tests validam como esses componentes trabalham juntos. Um integration test pode testar se sua função de desconto interage corretamente com a função que salva o pedido no banco de dados, ou se o serviço de pagamento comunica corretamente com a API externa de processamento.</p>
<p>Integration tests são mais lentos porque geralmente envolvem I/O — banco de dados, APIs externas, sistemas de arquivos. Você tipicamente usa mocks ou containers para simular essas dependências. A grande diferença é que você está testando o comportamento da integração entre componentes, não apenas um componente isolado.</p>
<p>A regra de ouro é: você precisa de unit tests para cada unidade e integration tests para os fluxos críticos que conectam essas unidades. Não teste cada combinação possível em nível de integração — isso é explosão combinatória. Teste os caminhos que realmente importam para o negócio.</p>
<h3>Exemplo prático com banco de dados em memória</h3>
<p>Vamos expandir o exemplo anterior. Agora você tem um repositório que salva pedidos:</p>
<pre><code class="language-python">from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class Pedido:
id: Optional[int] = None
cliente_id: int = None
valor_final: float = 0.0
desconto_aplicado: float = 0.0
data_criacao: datetime = None
class RepositorioPedidos:
def __init__(self, conexao):
self.conexao = conexao
def criar_pedido(self, pedido: Pedido) -> int:
"""Cria pedido no banco e retorna o ID gerado."""
cursor = self.conexao.cursor()
cursor.execute(
"""INSERT INTO pedidos (cliente_id, valor_final, desconto_aplicado, data_criacao)
VALUES (?, ?, ?, ?)""",
(pedido.cliente_id, pedido.valor_final, pedido.desconto_aplicado, pedido.data_criacao)
)
self.conexao.commit()
return cursor.lastrowid
def obter_pedido(self, pedido_id: int) -> Optional[Pedido]:
"""Obtém um pedido pelo ID."""
cursor = self.conexao.cursor()
cursor.execute("SELECT id, cliente_id, valor_final, desconto_aplicado, data_criacao FROM pedidos WHERE id = ?", (pedido_id,))
row = cursor.fetchone()
if not row:
return None
return Pedido(id=row[0], cliente_id=row[1], valor_final=row[2], desconto_aplicado=row[3], data_criacao=row[4])
class ServicoProcessamentoPedido:
def __init__(self, repositorio: RepositorioPedidos, servico_desconto: DescontoService):
self.repositorio = repositorio
self.servico_desconto = servico_desconto
def processar_pedido(self, cliente_id: int, valor_original: float, dias_cliente: int) -> int:
"""Processa um pedido aplicando desconto e salvando no banco."""
valor_com_desconto = self.servico_desconto.desconto_cliente_premium(valor_original, dias_cliente)
desconto_valor = valor_original - valor_com_desconto
pedido = Pedido(
cliente_id=cliente_id,
valor_final=valor_com_desconto,
desconto_aplicado=desconto_valor,
data_criacao=datetime.now()
)
return self.repositorio.criar_pedido(pedido)</code></pre>
<p>Agora o teste de integração que valida o fluxo completo:</p>
<pre><code class="language-python">import pytest
import sqlite3
from datetime import datetime
from app.services import DescontoService, ServicoProcessamentoPedido
from app.repositories import RepositorioPedidos, Pedido
class TestServicoProcessamentoPedido:
@pytest.fixture
def db_em_memoria(self):
"""Cria um banco de dados em memória para os testes."""
conexao = sqlite3.connect(":memory:")
cursor = conexao.cursor()
cursor.execute("""
CREATE TABLE pedidos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cliente_id INTEGER NOT NULL,
valor_final REAL NOT NULL,
desconto_aplicado REAL NOT NULL,
data_criacao TIMESTAMP NOT NULL
)
""")
conexao.commit()
yield conexao
conexao.close()
@pytest.fixture
def servico_processamento(self, db_em_memoria):
"""Instancia o serviço com dependências."""
repositorio = RepositorioPedidos(db_em_memoria)
servico_desconto = DescontoService()
return ServicoProcessamentoPedido(repositorio, servico_desconto)
def test_processar_pedido_cliente_premium(self, servico_processamento, db_em_memoria):
"""Testa fluxo completo: desconto aplicado e pedido salvo."""
pedido_id = servico_processamento.processar_pedido(
cliente_id=123,
valor_original=100,
dias_cliente=365
)
Valida que o pedido foi criado
assert pedido_id is not None
Valida que os dados foram salvos corretamente
repositorio = RepositorioPedidos(db_em_memoria)
pedido = repositorio.obter_pedido(pedido_id)
assert pedido is not None
assert pedido.cliente_id == 123
assert pedido.valor_final == 85 # 100 - 15%
assert pedido.desconto_aplicado == 15
def test_processar_pedido_cliente_novo(self, servico_processamento, db_em_memoria):
"""Testa que clientes novos não recebem desconto."""
pedido_id = servico_processamento.processar_pedido(
cliente_id=456,
valor_original=100,
dias_cliente=10
)
repositorio = RepositorioPedidos(db_em_memoria)
pedido = repositorio.obter_pedido(pedido_id)
assert pedido.valor_final == 100 # sem desconto
assert pedido.desconto_aplicado == 0</code></pre>
<p>Esse teste de integração executa o fluxo real do seu sistema — calcula desconto através do serviço, salva no banco de dados e valida que tudo foi persistido corretamente. Ele é mais lento que um unit test (tem I/O de banco de dados), mas é absolutamente necessário para garantir que seus componentes trabalham juntos.</p>
<h3>Rodando Integration Tests no Pipeline</h3>
<p>Adicione um segundo step no seu workflow após os unit tests:</p>
<pre><code class="language-yaml"> - name: Run integration tests
run: pytest app/tests/integration/ -v --tb=short
timeout-minutes: 5</code></pre>
<p>Integration tests devem ter um timeout definido. Se estiverem lentos demais, você tem um problema arquitetural para resolver. Idealmente, cada teste deve rodar em menos de 1 segundo.</p>
<h2>Smoke Tests: O Validador do Caminho Crítico</h2>
<h3>O que são e por que não testar tudo</h3>
<p>Smoke tests são testes de alta nível que validam se sua aplicação está "respirando" — se os componentes críticos funcionam end-to-end. Se um unit test pergunta "essa função está correta?", um smoke test pergunta "minha aplicação consegue processar um pedido do início ao fim?" Ele não valida cada detalhe, apenas o caminho crítico.</p>
<p>A razão pela qual você não escreve smoke tests para tudo é simples: eles são caros em tempo de execução. Um smoke test que sobe a aplicação inteira, faz requisições HTTP, interage com bancos de dados e aguarda respostas pode levar segundos ou minutos. Se você tiver centenas deles, seu pipeline fica inviável. A estratégia correta é: muitos unit tests rápidos (80%), alguns integration tests focados (15%), poucos smoke tests críticos (5%).</p>
<h3>Exemplo prático com FastAPI e testes HTTP</h3>
<p>Vamos imaginar que você expôs sua lógica de pedidos através de uma API REST:</p>
<pre><code class="language-python">from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class CriarPedidoRequest(BaseModel):
cliente_id: int
valor_original: float
dias_cliente: int
class PedidoResponse(BaseModel):
pedido_id: int
valor_final: float
desconto_aplicado: float
Instâncias globais (em produção, você usaria injeção de dependência)
servico_processamento = None
@app.post("/pedidos")
def criar_pedido(request: CriarPedidoRequest) -> PedidoResponse:
"""Endpoint que processa um pedido."""
try:
pedido_id = servico_processamento.processar_pedido(
cliente_id=request.cliente_id,
valor_original=request.valor_original,
dias_cliente=request.dias_cliente
)
Busca o pedido criado
pedido = servico_processamento.repositorio.obter_pedido(pedido_id)
return PedidoResponse(
pedido_id=pedido.id,
valor_final=pedido.valor_final,
desconto_aplicado=pedido.desconto_aplicado
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/health")
def health_check():
"""Endpoint de health check."""
return {"status": "ok"}</code></pre>
<p>Agora o smoke test que valida esse endpoint funciona de ponta a ponta:</p>
<pre><code class="language-python">import pytest
from fastapi.testclient import TestClient
import sqlite3
from app.main import app
from app.services import DescontoService, ServicoProcessamentoPedido
from app.repositories import RepositorioPedidos
class TestSmokePedidos:
@pytest.fixture
def cliente_api(self):
"""Instancia o cliente HTTP para testes."""
return TestClient(app)
@pytest.fixture(scope="module", autouse=True)
def setup_app(self):
"""Configura a aplicação com um banco em memória antes de rodar smoke tests."""
conexao = sqlite3.connect(":memory:")
cursor = conexao.cursor()
cursor.execute("""
CREATE TABLE pedidos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cliente_id INTEGER NOT NULL,
valor_final REAL NOT NULL,
desconto_aplicado REAL NOT NULL,
data_criacao TIMESTAMP NOT NULL
)
""")
conexao.commit()
Injeta dependências na app
repositorio = RepositorioPedidos(conexao)
servico_desconto = DescontoService()
import app.main
app.main.servico_processamento = ServicoProcessamentoPedido(repositorio, servico_desconto)
yield
conexao.close()
def test_health_check(self, cliente_api):
"""Smoke test: aplicação está respondendo?"""
response = cliente_api.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_criar_pedido_end_to_end(self, cliente_api):
"""Smoke test: fluxo completo de criação de pedido funciona?"""
response = cliente_api.post("/pedidos", json={
"cliente_id": 999,
"valor_original": 200,
"dias_cliente": 500
})
assert response.status_code == 200
dados = response.json()
assert dados["pedido_id"] is not None
assert dados["valor_final"] == 170 # 200 - 15%
assert dados["desconto_aplicado"] == 30
def test_criar_pedido_invalido(self, cliente_api):
"""Smoke test: validação de erros funciona?"""
response = cliente_api.post("/pedidos", json={
"cliente_id": 999,
"valor_original": -100, # valor negativo
"dias_cliente": 500
})
assert response.status_code == 400</code></pre>
<p>Repare que escrevemos apenas 3 smoke tests — um health check e dois cenários críticos do negócio (sucesso e erro). Não testamos todos os casos de desconto aqui; isso já foi feito nos unit tests. O smoke test apenas valida que a integração HTTP funciona e os dados chegam corretamente até o usuário.</p>
<h3>Smoke Tests no Pipeline</h3>
<p>Adicione um terceiro step, que roda por último:</p>
<pre><code class="language-yaml"> - name: Run smoke tests
run: pytest app/tests/smoke/ -v --tb=short
timeout-minutes: 2</code></pre>
<p>Smoke tests devem ser muito rápidos — idealmente menos de 2-3 minutos no total. Se estiverem lentos, reconsidere se você realmente precisa testar aquilo em cada commit.</p>
<h2>Orquestração Completa: Um Pipeline Realista</h2>
<h3>Estrutura de diretórios e configuração</h3>
<p>Até agora mostramos as peças individuais. Vamos montá-las em um pipeline completo e realista. Aqui está como você deve organizar seu projeto:</p>
<pre><code>meu_projeto/
├── app/
│ ├── main.py
│ ├── services.py
│ ├── repositories.py
│ └── models.py
├── tests/
│ ├── unit/
│ │ └── test_services.py
│ ├── integration/
│ │ └── test_repositories.py
│ └── smoke/
│ └── test_endpoints.py
├── .github/
│ └── workflows/
│ └── ci.yml
├── requirements.txt
├── pytest.ini
└── README.md</code></pre>
<p>O arquivo <code>pytest.ini</code> centraliza as configurações:</p>
<pre><code class="language-ini">[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --strict-markers
markers =
unit: testes unitários rápidos
integration: testes de integração
smoke: smoke tests críticos</code></pre>
<p>Agora o pipeline CI completo em <code>.github/workflows/ci.yml</code>:</p>
<pre><code class="language-yaml">name: CI Pipeline Estratégico
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Cache pip dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov pytest-timeout
STAGE 1: Unit Tests (rápido, falha cedo)
- name: Run unit tests
run: pytest tests/unit/ -v --tb=short --timeout=10 --cov=app --cov-report=term-missing:skip-covered
- name: Check unit test coverage
run: |
coverage report --fail-under=80
STAGE 2: Integration Tests (moderado)
- name: Run integration tests
if: success()
run: pytest tests/integration/ -v --tb=short --timeout=30
STAGE 3: Smoke Tests (validação final)
- name: Run smoke tests
if: success()
run: pytest tests/smoke/ -v --tb=short --timeout=5
Relatório final
- name: Generate coverage report
if: always()
run: coverage report --format=markdown >> $GITHUB_STEP_SUMMARY</code></pre>
<p>Repare nos pontos críticos:</p>
<ol>
<li><strong>Fases sequenciais com <code>if: success()</code></strong>: Se unit tests falham, não roda integration. Se integration falha, não roda smoke. Isso economiza tempo de CI.</li>
<li><strong>Timeouts</strong>: Cada stage tem timeout apropriado. Unit tests são os mais rápidos, smoke tests são os mais lentos.</li>
<li><strong>Coverage check</strong>: Garante que novos testes mantêm a cobertura acima do mínimo aceito.</li>
<li><strong>Caching</strong>: Dependencies são cacheadas entre runs — o CI roda mais rápido.</li>
</ol>
<h3>Exemplo de requirements.txt</h3>
<pre><code>fastapi==0.104.1
pytest==7.4.3
pytest-cov==4.1.0
pytest-timeout==2.2.0
uvicorn==0.24.0
pydantic==2.5.0</code></pre>
<h2>Otimizações e Boas Práticas</h2>
<h3>Quando NÃO escrever um teste</h3>
<p>Aqui está o segredo que ninguém fala: nem tudo precisa de teste. Você desperdiça tempo escrevendo testes para:</p>
<ul>
<li><strong>Código gerado</strong> (migrações de banco, modelos gerados automaticamente)</li>
<li><strong>Código trivial</strong> (getters e setters simples sem lógica)</li>
<li><strong>Código de infraestrutura pura</strong> (configuração de logging, setup de aplicação)</li>
</ul>
<p>Foque seus esforços em <strong>lógica de negócio</strong> — aquilo que diferencia sua aplicação. Se essa função calcula preços, aplica descontos ou determina permissões, precisa de teste.</p>
<h3>Paralelizar sem perder sanidade</h3>
<p>Seu pipeline CI pode rodar testes em paralelo para ir mais rápido:</p>
<pre><code class="language-yaml"> - name: Run unit tests in parallel
run: pytest tests/unit/ -n auto --timeout=10 --cov=app</code></pre>
<p>A flag <code>-n auto</code> do pytest-xdist roda tests em paralelo usando tantos workers quantos cores sua máquina tem. Mas cuidado: se seus testes compartilham estado (banco de dados, cache), a paralelização quebra tudo. Use um banco em memória por worker ou fixtures com escopo apropriado.</p>
<h3>Monitorar performance do pipeline</h3>
<p>Seu pipeline não deve levar mais de 10-15 minutos no total. Se estiver mais lento, algo está errado. Algumas causas comuns:</p>
<ul>
<li>Muitos testes de integração quando deveriam ser unit tests</li>
<li>Testes que não limpam recursos (conexões abertas, arquivos não deletados)</li>
<li>Dependências externas lentas (APIs, bancos de dados reais) em vez de mocks</li>
<li>Falta de cache de dependencies</li>
</ul>
<p>Um truque prático: adicione timestamps aos seu output de teste para identificar gargalos:</p>
<pre><code class="language-bash">pytest tests/ -v --tb=short --durations=10</code></pre>
<p>Isso mostra os 10 testes mais lentos.</p>
<h2>Conclusão</h2>
<p>Você aprendeu uma estratégia de testes que escala: <strong>muitos unit tests rápidos na base (80%), integration tests focados no meio (15%), e smoke tests críticos no topo (5%)</strong>. Essa pirâmide não é arbitrária — ela vem de anos de experiência em engenharia de software e é comprovada em empresas de todo o mundo.</p>
<p>O segundo ponto fundamental é que <strong>seu pipeline CI deve falhar rápido e dar feedback imediato</strong>. Organize seus testes em fases onde unit tests rodam primeiro (milissegundos), integration tests segundo (segundos), e smoke tests terceiro (mais segundos). Se algo quebrar cedo, você economiza tempo não rodando o resto.</p>
<p>Por fim, lembre-se de que <strong>testes são código também</strong> — merecem a mesma qualidade e revisão que seu código de produção. Não escreva testes como prova de que o código funciona; escreva testes como especificações vivas que documentam como o código deve se comportar. Foco em lógica de negócio, evite testes triviais, e sempre monitore a saúde do seu pipeline.</p>
<h2>Referências</h2>
<ul>
<li><a href="https://docs.pytest.org/" target="_blank" rel="noopener noreferrer">Pytest Official Documentation</a></li>
<li><a href="https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions" target="_blank" rel="noopener noreferrer">GitHub Actions - Workflow Syntax</a></li>
<li><a href="https://martinfowler.com/bliki/TestPyramid.html" target="_blank" rel="noopener noreferrer">Testing Strategies: The Test Pyramid by Martin Fowler</a></li>
<li><a href="https://www.atlassian.com/continuous-delivery/continuous-integration" target="_blank" rel="noopener noreferrer">Continuous Integration Best Practices</a></li>
<li><a href="https://fastapi.tiangolo.com/advanced/testing-dependencies/" target="_blank" rel="noopener noreferrer">FastAPI Testing Documentation</a></li>
</ul>
<p><!-- FIM --></p>