DevOps Admin

Estratégias de Testes em Pipelines CI: Unit, Integration e Smoke Tests na Prática Já leu

Fundamentos de Estratégias de Testes em Pipelines CI 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? 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. Unit Tests: A Fundação Sólida O que são e por que importam Unit tests são testes que validam unidades isoladas

Fundamentos de Estratégias de Testes em Pipelines CI

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?

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.

Unit Tests: A Fundação Sólida

O que são e por que importam

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 calcularPreco(100, 0.1) retorna 90.

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.

Exemplo prático em Python

Vamos imaginar um sistema de e-commerce. Você tem uma classe que aplica descontos:

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

Agora os testes unitários usando pytest:

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

Note como cada teste é focado em um comportamento específico. Os testes validam casos válidos, limites e exceções. Quando você roda pytest app/tests/test_services.py -v, cada teste executa em milissegundos e você sabe imediatamente se suas funções fazem o que prometem.

Configuração no Pipeline CI

No seu arquivo .github/workflows/ci.yml (GitHub Actions), você deve rodar unit tests como primeiro passo:

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

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.

Integration Tests: Validando a Orquestração

O que são e quando usar

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.

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.

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.

Exemplo prático com banco de dados em memória

Vamos expandir o exemplo anterior. Agora você tem um repositório que salva pedidos:

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)

Agora o teste de integração que valida o fluxo completo:

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

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.

Rodando Integration Tests no Pipeline

Adicione um segundo step no seu workflow após os unit tests:

      - name: Run integration tests
        run: pytest app/tests/integration/ -v --tb=short
        timeout-minutes: 5

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.

Smoke Tests: O Validador do Caminho Crítico

O que são e por que não testar tudo

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.

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%).

Exemplo prático com FastAPI e testes HTTP

Vamos imaginar que você expôs sua lógica de pedidos através de uma API REST:

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"}

Agora o smoke test que valida esse endpoint funciona de ponta a ponta:

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

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.

Smoke Tests no Pipeline

Adicione um terceiro step, que roda por último:

      - name: Run smoke tests
        run: pytest app/tests/smoke/ -v --tb=short
        timeout-minutes: 2

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.

Orquestração Completa: Um Pipeline Realista

Estrutura de diretórios e configuração

Até agora mostramos as peças individuais. Vamos montá-las em um pipeline completo e realista. Aqui está como você deve organizar seu projeto:

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

O arquivo pytest.ini centraliza as configurações:

[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

Agora o pipeline CI completo em .github/workflows/ci.yml:

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

Repare nos pontos críticos:

  1. Fases sequenciais com if: success(): Se unit tests falham, não roda integration. Se integration falha, não roda smoke. Isso economiza tempo de CI.
  2. Timeouts: Cada stage tem timeout apropriado. Unit tests são os mais rápidos, smoke tests são os mais lentos.
  3. Coverage check: Garante que novos testes mantêm a cobertura acima do mínimo aceito.
  4. Caching: Dependencies são cacheadas entre runs — o CI roda mais rápido.

Exemplo de requirements.txt

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

Otimizações e Boas Práticas

Quando NÃO escrever um teste

Aqui está o segredo que ninguém fala: nem tudo precisa de teste. Você desperdiça tempo escrevendo testes para:

  • Código gerado (migrações de banco, modelos gerados automaticamente)
  • Código trivial (getters e setters simples sem lógica)
  • Código de infraestrutura pura (configuração de logging, setup de aplicação)

Foque seus esforços em lógica de negócio — aquilo que diferencia sua aplicação. Se essa função calcula preços, aplica descontos ou determina permissões, precisa de teste.

Paralelizar sem perder sanidade

Seu pipeline CI pode rodar testes em paralelo para ir mais rápido:

      - name: Run unit tests in parallel
        run: pytest tests/unit/ -n auto --timeout=10 --cov=app

A flag -n auto 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.

Monitorar performance do pipeline

Seu pipeline não deve levar mais de 10-15 minutos no total. Se estiver mais lento, algo está errado. Algumas causas comuns:

  • Muitos testes de integração quando deveriam ser unit tests
  • Testes que não limpam recursos (conexões abertas, arquivos não deletados)
  • Dependências externas lentas (APIs, bancos de dados reais) em vez de mocks
  • Falta de cache de dependencies

Um truque prático: adicione timestamps aos seu output de teste para identificar gargalos:

pytest tests/ -v --tb=short --durations=10

Isso mostra os 10 testes mais lentos.

Conclusão

Você aprendeu uma estratégia de testes que escala: muitos unit tests rápidos na base (80%), integration tests focados no meio (15%), e smoke tests críticos no topo (5%). 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.

O segundo ponto fundamental é que seu pipeline CI deve falhar rápido e dar feedback imediato. 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.

Por fim, lembre-se de que testes são código também — 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.

Referências


Artigos relacionados