Automação Avançada: UI, CI/CD e Performance
Controlar um browser real, correr num container, e saber se o sistema aguenta.
Por Telmo Silva
Ir para o teu progresso ↓Porque escrevi este curso
Os dois primeiros cursos ensinaram a testar dados: ficheiros, APIs, uma base de dados. Este é sobre testar o resto: uma interface que um utilizador de facto vê e clica, um sistema sob a versão dele mesmo (num container), e a pergunta que nenhum teste anterior fazia, o sistema aguenta carga a sério?
É também o curso onde a tua suite deixa de ser um conjunto de scripts avulsos e passa a ser um pipeline: constrói-se numa imagem, corre a cada commit, sem ninguém se lembrar de o fazer à mão.
Uma honestidade sobre o que aqui está
É o terceiro de quatro cursos que escrevi; os Cursos 1 e 2 já estão publicados aqui, o Curso 4 ainda não. Pressupõe os dois primeiros feitos: assertions, pytest básico, SQL, requests.
Serve para quem já testa dados e quer testar também o que se vê no ecrã, e para quem quer parar de correr a suite à mão e começar a correr sozinha, a cada commit.
Um teste de API confirma o que entra e sai. Um teste de UI vê o que um utilizador via.
Por onde começar, consoante a tua situação
Índice
12 capítulos, com código a sério em cada um. Pressupõe os Cursos 1 e 2 feitos.
01WebDriver, e Seletores CSS e XPath
Um teste de API, como os do Curso 2, fala diretamente com o servidor. Nunca renderiza nada, nunca corre JavaScript, nunca vê um pixel. Isso apanha uma classe de bugs; não apanha outra: um botão escondido por um CSS mal escrito, uma validação que só corre no browser. Para esses, precisas de controlar um browser real: abrir, clicar, escrever, ler o que ficou no ecrã.
O Selenium faz isto através do protocolo WebDriver, um standard W3C, mas obriga-te a gerir um driver à parte (chromedriver, geckodriver) que tem de corresponder à versão exata do browser instalado, uma fonte comum de fricção. O Playwright resolve o mesmo problema sem essa gestão: playwright install descarrega e mantém os browsers já preparados para automação. É por isso que vais usá-lo neste curso; o que aprenderes sobre seletores, esperas e Page Object Model transfere-se quase 1:1 para Selenium, se um dia mudares de ferramenta.
bash
pip install playwright
playwright install chromiumpython
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://www.saucedemo.com/")
print(page.title()) # Swag Labs
browser.close()headless=True corre o browser sem interface visível: mais rápido, o que vais usar sempre em CI. Enquanto escreves e depuras um teste, headless=False deixa-te ver o browser a agir em tempo real.
Seletores CSS e XPath
page.locator(seletor) devolve uma referência a um elemento. Por definição, interpreta a string como CSS:
python
page.locator("#user-name") # por id
page.locator(".inventory_item") # por classe
page.locator('[data-test="login-button"]') # por atributo
page.locator("button.btn_primary") # tag + classePara casos que o CSS não cobre bem, usa XPath, prefixado com xpath=. Há ainda um terceiro caminho, orientado ao que o utilizador vê, não à implementação: get_by_role(), get_by_text().
Escolhe seletores que sobrevivem ao redesign
Um seletor baseado numa classe de estilo (.btn_primary) parte-se assim que o design muda: a classe existe para aparência, não para identidade. Um id estável ou um atributo pensado para testes (data-test="login-button") sobrevive. A pergunta antes de escolheres um seletor: isto sobrevive à próxima alteração visual que não tem nada a ver com o meu teste?
02Esperas Explícitas, e Page Object Model
Esperas explícitas
Uma página real não fica pronta no instante em que page.goto() devolve. Um teste que assume "o elemento já está ali" falha de forma inconsistente: o clássico teste flaky. A solução ingénua é um tempo fixo (time.sleep(3)): falha no dia em que o servidor está mais lento, e desperdiça tempo nos dias em que era demasiado.
O Playwright poupa-te grande parte deste trabalho: toda a ação (.click(), .fill()) já espera automaticamente que o elemento exista e esteja pronto, até um timeout de 30 segundos. Chama-se auto-waiting. Onde precisas mesmo de uma espera explícita é para afirmares um estado, não para agir:
python
from playwright.sync_api import expect
expect(page.locator('[data-test="title"]')).to_have_text("Products")
expect(page.locator('[data-test="error"]')).to_be_visible()Ao contrário de um assert normal, que verifica o estado naquele instante, expect(...) volta a tentar até a condição se cumprir ou o timeout esgotar: a diferença entre "isto é verdade agora" e "isto vai tornar-se verdade em breve".
Page Object Model
Sem estrutura, um seletor como #login-button acaba escrito em cada teste que passa pelo login. Muda o id no próximo redesign, e corriges dez ficheiros para uma alteração que só devia existir num sítio. O Page Object Model separa como interagir com uma página (seletores, passos) do que o teste verifica (assertions). Cada página ganha a sua própria classe:
python
class LoginPage:
URL = "https://www.saucedemo.com/"
def __init__(self, page):
self.page = page
self.campo_utilizador = page.locator("#user-name")
self.campo_password = page.locator("#password")
self.botao_login = page.locator("#login-button")
def navegar(self):
self.page.goto(self.URL)
def login(self, utilizador, password):
self.campo_utilizador.fill(utilizador)
self.campo_password.fill(password)
self.botao_login.click()Um teste que usa esta classe nunca vê #login-button, só vê login(). Quando o id do botão mudar, corriges LoginPage uma vez, e todos os testes que chamam login() continuam a funcionar sem lhes tocar.
O custo é real
Para um teste isolado, criar uma classe é trabalho a mais. O padrão paga-se a partir do segundo ou terceiro teste que reutiliza a mesma página. O que importa manter é a fronteira: seletores só dentro da classe da página, assertions só do lado do teste.
03Prática: a Tua Primeira Suite de UI
Automatizar login e navegação num site real
Objetivo: Construir paginas_saucedemo.py (os Page Objects) e test_login_saucedemo.py (os testes), para automatizar o login em saucedemo.com, um site público construído para se praticar automação de UI.
- Cria paginas_saucedemo.py com LoginPage (seletores #user-name, #password, #login-button, um método login(), e um locator para a mensagem de erro) e InventoryPage (título da página e um método que confirme que a URL contém inventory.html).
- Escreve test_login_valido_chega_a_pagina_de_produtos: entra com standard_user/secret_sauce e confirma, com expect(), que o título é "Products" e que a lista de produtos não está vazia.
- Escreve test_login_bloqueado_mostra_mensagem_de_erro: usa locked_out_user/secret_sauce e confirma que a mensagem de erro fica visível.
- Os dois testes correm em modo headless.
Dica: Estás a repetir p.chromium.launch()/browser.close() em cada teste: código de preparação e limpeza sem nada a ver com o que o teste verifica. É exatamente o que as fixtures do pytest, no capítulo 4, vêm eliminar.
Ver solução
# paginas_saucedemo.py
class LoginPage:
URL = "https://www.saucedemo.com/"
def __init__(self, page):
self.page = page
self.campo_utilizador = page.locator("#user-name")
self.campo_password = page.locator("#password")
self.botao_login = page.locator("#login-button")
self.mensagem_erro = page.locator('[data-test="error"]')
def navegar(self):
self.page.goto(self.URL)
def login(self, utilizador, password):
self.campo_utilizador.fill(utilizador)
self.campo_password.fill(password)
self.botao_login.click()
class InventoryPage:
URL_PARCIAL = "inventory.html"
def __init__(self, page):
self.page = page
self.titulo = page.locator('[data-test="title"]')
self.produtos = page.locator(".inventory_item")
def esta_carregada(self):
return self.URL_PARCIAL in self.page.url
# test_login_saucedemo.py
from playwright.sync_api import sync_playwright, expect
from paginas_saucedemo import LoginPage, InventoryPage
def test_login_valido_chega_a_pagina_de_produtos():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
page = browser.new_page()
pagina_login = LoginPage(page)
pagina_login.navegar()
pagina_login.login("standard_user", "secret_sauce")
pagina_produtos = InventoryPage(page)
expect(pagina_produtos.titulo).to_have_text("Products")
assert pagina_produtos.esta_carregada()
assert pagina_produtos.produtos.count() > 0
finally:
browser.close()
def test_login_bloqueado_mostra_mensagem_de_erro():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
page = browser.new_page()
pagina_login = LoginPage(page)
pagina_login.navegar()
pagina_login.login("locked_out_user", "secret_sauce")
expect(pagina_login.mensagem_erro).to_be_visible()
assert "locked out" in pagina_login.mensagem_erro.inner_text()
finally:
browser.close()04Fixtures, Parametrização e Markers
O pytest, desde o Curso 2, organiza e corre os teus testes sozinho; não geria, até aqui, o que cada teste precisa antes de correr. Uma fixture separa o que o teste precisa de como esse precisa é preparado e desfeito. Declaras uma fixture uma vez, tipicamente num ficheiro conftest.py, e qualquer teste que precise dela só declara um parâmetro com o mesmo nome:
python
# conftest.py
import pytest
from playwright.sync_api import sync_playwright
@pytest.fixture(scope="session")
def browser():
with sync_playwright() as p:
navegador = p.chromium.launch(headless=True)
yield navegador
navegador.close()
@pytest.fixture
def page(browser):
pagina = browser.new_page()
yield pagina
pagina.close()yield separa preparação de limpeza dentro da mesma função: tudo antes corre como setup, tudo depois como teardown, e o pytest garante que o teardown corre mesmo que o teste falhe a meio. Repara em scope="session" em browser, contra page sem scope (por definição "function"): lançar um Chromium custa tempo, scope="session" lança-o uma única vez para a corrida completa; page continua a criar um separador novo por teste, para um teste não herdar estado do anterior.
Atualiza também o ficheiro do capítulo anterior
test_login_saucedemo.py, do exercício anterior, abre o seu próprio with sync_playwright() em cada teste. Se o deixares como está, na mesma pasta que este conftest.py, o pytest recusa-se a correr os dois ao mesmo tempo, com um erro confuso sobre "Sync API inside the asyncio loop": não é possível ter duas instâncias do Playwright Sync API abertas ao mesmo tempo no mesmo processo. A correção é atualizar esse ficheiro para receber page como parâmetro, tal como test_login_invalido_mostra_mensagem_de_erro faz a seguir, em vez de abrir o seu próprio browser.
Parametrização
@pytest.mark.parametrize corre a mesma função de teste uma vez por cada combinação de valores:
python
import pytest
from paginas_saucedemo import LoginPage
from playwright.sync_api import expect
@pytest.mark.parametrize(
"utilizador, password",
[
("locked_out_user", "secret_sauce"),
("standard_user", "password_errada"),
("utilizador_inexistente", "secret_sauce"),
],
)
def test_login_invalido_mostra_mensagem_de_erro(page, utilizador, password):
pagina_login = LoginPage(page)
pagina_login.navegar()
pagina_login.login(utilizador, password)
expect(pagina_login.mensagem_erro).to_be_visible()Com um for dentro do teste, uma combinação a falhar esconde-se num único resultado, e paras no primeiro assert que rebenta. Com parametrize, cada combinação é um item independente no relatório, corre mesmo que outra falhe, e o nome já diz qual delas partiu.
Markers
Um marker etiqueta um teste, para correres ou ignorares esse subconjunto sem o separares fisicamente noutro ficheiro:
python
@pytest.mark.ui
def test_login_valido_chega_a_pagina_de_produtos(page):
...
@pytest.mark.slow
@pytest.mark.ui
def test_fluxo_completo_de_compra(page):
...bash
pytest -m "ui" # só os testes marcados como ui
pytest -m "not slow" # tudo menos os lentosMarkers personalizados precisam de ser declarados uma vez, num pytest.ini, ou o pytest avisa a cada corrida:
ini
[pytest]
markers =
ui: testes que abrem um browser real
slow: testes lentos, fora do pipeline por commitTrês markers já vêm embutidos: skip(reason="...") ignora incondicionalmente; skipif(condição, reason="...") ignora só quando a condição é verdadeira; xfail marca um teste como "espera-se que falhe", continua a correr, mas uma falha não conta contra a suite.
05conftest.py, e Porque os Testes Correm num Container
As fixtures browser e page do capítulo anterior não vivem dentro do ficheiro de teste; vivem em conftest.py, ao lado. Não é preciso importá-las: o pytest procura automaticamente um ficheiro com este nome em cada pasta de testes, e torna as suas fixtures disponíveis para qualquer teste nessa pasta. Uma fixture usada só por um ficheiro pode continuar a viver nesse ficheiro; só sobe para conftest.py quando mais do que um ficheiro precisa dela.
Por que os testes correm num container
A tua máquina tem uma versão de Python, um conjunto de pacotes instalados; nada disso garante que é igual na máquina de outra pessoa da equipa, ou no servidor que corre os testes a cada commit. "Na minha máquina funciona" é a consequência lógica de testares num ambiente que só existe numa máquina. Um container empacota o ambiente inteiro como um único artefacto que corre da mesma forma em qualquer sítio com Docker instalado.
Uma imagem é o artefacto (só de leitura, construído a partir de um Dockerfile). Um container é uma instância dessa imagem, em execução. Constróis a imagem uma vez; corres containers a partir dela quantas vezes quiseres.
dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["pytest", "-v"]- FROM python:3.12-slim: imagem-base já com Python e pip. slim é o meio-termo entre a imagem completa (mais lenta) e alpine (troca glibc por musl, parte pacotes com wheels pré-compiladas).
- WORKDIR /app: define a pasta de trabalho dentro da imagem.
- COPY requirements.txt seguido de RUN pip install, antes de copiares o resto: o Docker guarda em cache o resultado de cada instrução. Alterar um ficheiro de teste, sem tocar em dependências, não invalida a camada do pip install.
- CMD ["pytest", "-v"]: o comando que corre quando o container arranca.
__pycache__/
*.pyc
.pytest_cache/
.git/Um .dockerignore evita que COPY . . leve também o que sobra da tua máquina: cache local, e no caso do .git, um risco real de a imagem carregar histórico que não devia sair de lá.
bash
docker build -t pipeline-testes .
docker run --rm pipeline-testes-t dá um nome à imagem; --rm apaga o container assim que termina. O detalhe que faz tudo isto valer a pena para CI: o pytest termina com código de saída 0 quando tudo passa, e diferente de 0 quando algo falha, e o docker run propaga esse código de saída para fora do container.
06Integração Contínua no GitHub Actions
Tens agora um container que corre a suite de forma idêntica em qualquer máquina. Falta correr isto sozinho, a cada commit. CI (integração contínua) é isso: cada push dispara automaticamente um build e uma corrida de testes, o mais próximo possível do momento em que o problema foi introduzido. CD (entrega contínua) é o passo seguinte, publicar automaticamente depois de o CI ficar verde, fora do âmbito deste módulo.
GitHub Actions é a ferramenta de CI/CD já integrada no GitHub. Um workflow é um ficheiro YAML dentro de .github/workflows/.
- trigger (on:): o evento que dispara o workflow: um push, um pull_request, um agendamento.
- job: um conjunto de passos que corre numa única máquina. Por definição correm em paralelo, salvo dependência declarada com needs:.
- runner: a máquina que executa o job. ubuntu-latest é a escolha por omissão razoável.
- step: um comando de shell (run:), ou uma action reutilizável do marketplace (uses:).
yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
testes:
runs-on: ubuntu-latest
steps:
- name: Obter o código
uses: actions/checkout@v4
- name: Construir a imagem
run: docker build -t pipeline-testes .
- name: Correr os testes dentro do container
run: docker run --rm pipeline-testesRepara no que este workflow não faz: não reescreve nada para o ambiente de CI. Os dois run: finais são exatamente os dois comandos que já correste na tua máquina. É esse o ganho real de teres containerizado primeiro: o que testaste localmente é, byte a byte, o que corre em CI.
Um pipeline verde não é um portão
Um workflow destes é só um indicador até o tornares um portão. Sem uma regra de proteção de branch a exigir que este job passe antes de um merge, ele corre, fica vermelho, e nada impede ninguém de fazer merge na mesma.
07Prática: Pipeline Completo num Container
Converter os scripts num pipeline que corre a cada commit, dentro de um container
Objetivo: Pegar numa pequena suite de testes pytest já existente e transformá-la num pipeline real: um Dockerfile que a containeriza, e um workflow de GitHub Actions que constrói essa imagem e a corre a cada push.
- Confirma primeiro a base, sem Docker: pytest -v deve mostrar 8 testes a passar.
- Constrói a imagem: docker build -t pipeline-testes . Confirma que o build termina sem erros.
- Corre o container: docker run --rm pipeline-testes. Confirma os mesmos 8 testes.
- Confirma a propagação do código de saída: docker run --rm pipeline-testes seguido de echo $? deve dar 0. Estraga um teste de propósito, repete, confirma 1. Desfaz, reconstrói, confirma 0 outra vez.
- Publica num repositório GitHub com o Dockerfile, .dockerignore, requirements.txt, test_*.py/conftest.py, e ci.yml em .github/workflows/ci.yml.
- No separador Actions do repositório, confirma que o workflow correu sozinho, com o mesmo resultado da tua máquina.
Dica: Antes de fazeres push, confirma que o YAML não tem erro de sintaxe: python -c "import yaml; yaml.safe_load(open('ci.yml', encoding='utf-8'))". Sem erro, o YAML está sintaticamente válido.
Ver solução
# requirements.txt
# pytest==8.3.4
# conftest.py
import pytest
@pytest.fixture
def emails_validos():
return ["ana@example.com", "bruno@teste.pt", "carla@empresa.io"]
# test_validacoes.py
def validar_email(valor):
if valor.count("@") != 1:
return False
if valor.startswith("@") or valor.endswith("@"):
return False
return "." in valor.split("@")[1]
def test_email_valido_e_aceite():
assert validar_email("ana@example.com") is True
def test_email_sem_arroba_e_rejeitado():
assert validar_email("ana.example.com") is False
def test_email_com_dois_arrobas_e_rejeitado():
assert validar_email("ana@@example.com") is False
def test_todos_os_emails_de_exemplo_sao_validos(emails_validos):
assert all(validar_email(email) for email in emails_validos)
# test_operacoes.py
import pytest
def dobrar(numero):
return numero * 2
@pytest.mark.parametrize(
"entrada, esperado",
[(0, 0), (2, 4), (-3, -6), (10, 20)],
)
def test_dobrar(entrada, esperado):
assert dobrar(entrada) == esperadotext
docker run --rm pipeline-testes
echo $?
0
# com (10, 21) de propósito em vez de (10, 20):
test_operacoes.py::test_dobrar[10-21] FAILED
1 failed, 7 passed in 0.01s
echo $?
108Faker: Geração de Dados Fictícios
Ao longo do curso, os exemplos repetiram sempre os mesmos dois ou três valores. Serve bem para ensinar; é um hábito perigoso numa suite a sério. Um teste que só alguma vez viu "Ana Ferreira" nunca exercita um nome com apóstrofo, um email com +, e é exatamente na variedade que não testaste que os bugs se escondem. Há uma segunda razão para não usares dados reais: gravar registos verdadeiros de clientes num ambiente de testes é um problema de proteção de dados, RGPD incluído.
bash
pip install fakerpython
from faker import Faker
fake = Faker("pt_PT")
Faker.seed(42)
print(fake.name())
print(fake.email())
print(fake.phone_number())
print(fake.city())O Faker é aleatório por definição. Faker.seed(42) fixa a sequência: a mesma seed produz sempre o mesmo dataset. É independente da seed do módulo random: se usares random.choice() ao lado do Faker, precisas de fixar as duas seeds em separado.
fake.unique.email(), em vez de fake.email(), garante que não sais com dois emails iguais na mesma corrida, importante quando o campo se comporta como chave.
09Mocking de Respostas
Nem tudo o que o teu código depende está sob o teu controlo no momento do teste: uma API de terceiros, um gateway de pagamento, um serviço lento. Testar sempre contra a coisa real é lento, instável, e às vezes impossível de provocar a pedido. Mocking substitui a dependência real por um substituto que controlas por completo.
python
from unittest.mock import patch
import pytest
import requests
def obter_titulo_do_post(post_id):
resposta = requests.get(f"https://jsonplaceholder.typicode.com/posts/{post_id}")
resposta.raise_for_status()
return resposta.json()["title"]
@patch("requests.get")
def test_obter_titulo_do_post_sem_rede(mock_get):
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"id": 1, "title": "Título de teste"}
titulo = obter_titulo_do_post(1)
assert titulo == "Título de teste"
mock_get.assert_called_once_with("https://jsonplaceholder.typicode.com/posts/1")
@patch("requests.get")
def test_obter_titulo_do_post_falha_de_rede(mock_get):
mock_get.side_effect = requests.exceptions.ConnectionError("falha de rede simulada")
with pytest.raises(requests.exceptions.ConnectionError):
obter_titulo_do_post(1)@patch("requests.get") troca requests.get por um Mock só durante a função de teste. mock_get.return_value é o que requests.get(...) "devolve" quando chamado lá dentro. mock_get.assert_called_once_with(...) confirma não só o resultado, mas que o teu código chamou a API da forma certa. O segundo teste mostra side_effect: faz o Mock levantar uma exceção, um cenário difícil de provocar de forma fiável contra um serviço real.
O que perdes com um mock
Um mock só testa a forma como o teu código reage a uma forma de resposta que tu assumiste ser a certa. Se a API real mudar amanhã e o teu mock não acompanhar, o mock continua a mentir-te, o teste continua verde, e a produção parte na mesma. Mocking não substitui um teste de integração contra a coisa real, complementa-o.
10Locust: Testes de Carga
Tudo o que testaste até este módulo faz sempre a mesma pergunta: "está certo?" Um teste de carga faz uma pergunta estruturalmente diferente: o que acontece quando centenas de pedidos chegam ao sistema no mesmo segundo? Não "está certo?", "aguenta?"
bash
pip install locustpython
from locust import HttpUser, task, between
class UtilizadorLoja(HttpUser):
wait_time = between(1, 3)
@task(3)
def ver_produtos(self):
self.client.get("/produtos")
@task(1)
def fazer_login(self):
self.client.post("/login", json={"utilizador": "ana", "password": "segredo123"})
@task(1)
def pagina_inicial(self):
self.client.get("/")Cada HttpUser representa um tipo de utilizador simulado. @task(N) marca um método como uma ação possível, e o número é um peso relativo. wait_time = between(1, 3) é o tempo de pausa entre ações: sem isto, não estás a simular tráfego realista, estás só a martelar o alvo o mais depressa possível.
bash
locust -f locustfile.py --headless -u 5 -r 1 -t 10s --host=http://127.0.0.1:8000-u é o número de utilizadores simulados no pico; -r é o ritmo de arranque por segundo; -t é a duração total; --host é o alvo. O código de saída importa: uma corrida limpa termina com 0; uma corrida onde o alvo recusa ligação ou devolve erros termina com código diferente de zero.
Nunca apontes um teste de carga a um site que não é teu sem autorização explícita. Do lado de quem recebe, um teste de carga e um ataque de negação de serviço parecem a mesma coisa.
11Relatórios Visuais, Sem Depender de Nada Novo
O pytest-html do Curso 2 dá-te um relatório de uma corrida isolada, sem memória do que aconteceu antes. Não responde a "isto está a piorar?". O Allure resolve isso, mantendo resultados de corridas anteriores e um gráfico de tendência, mas exige a Allure commandline, uma ferramenta à parte em Java, infraestrutura genuína a mais.
Por isso, o exercício deste módulo constrói uma versão pequena e caseira, só com a biblioteca padrão: o pytest já produz um resultado estruturado em XML com --junitxml (sem plugin nenhum); um script lê esse XML, acrescenta uma linha a um historico.csv, e desenha um gráfico simples em SVG dentro de uma página HTML autocontida.
É claramente menos do que o Allure: sem anexos, sem categorização automática de falhas. Em troca, ganhas zero infraestrutura nova, e a perceção de que "histórico" é só resultados passados, guardados nalgum lado, e depois desenhados.
12Prática: Dados, Carga e Dashboard
Gerar datasets de teste variados
Objetivo: Escrever gerar_dados_teste.py, um script que usa o Faker para gerar um dataset fictício mas internamente coerente de clientes e encomendas, e grava tudo em dois CSV.
- Usa Faker("pt_PT"), e fixa Faker.seed(...) e random.seed(...) para o dataset ser reprodutível.
- Gera 20 clientes: id, nome, email (único), data_nascimento, telefone, cidade.
- Gera 50 encomendas: id, cliente_id (escolhido de dentro da lista de clientes), produto, quantidade, preco_unitario, data_encomenda.
- Grava clientes.csv e encomendas.csv com csv.DictWriter.
- No final, imprime quantos registos foram gerados, e uma amostra de 5 linhas de cada.
Dica: O Faker e o módulo random são dois geradores independentes: fixar só um deixa o outro a variar entre corridas.
Ver solução
from faker import Faker
import csv
import random
random.seed(42)
fake = Faker("pt_PT")
Faker.seed(42)
PRODUTOS = ["Rato sem fios", "Teclado mecânico", "Monitor 27", "Auscultadores", "Webcam HD", "Hub USB-C"]
def gerar_clientes(quantidade):
clientes = []
for id_cliente in range(1, quantidade + 1):
clientes.append({
"id": id_cliente,
"nome": fake.name(),
"email": fake.unique.email(),
"data_nascimento": fake.date_of_birth(minimum_age=18, maximum_age=80).isoformat(),
"telefone": fake.phone_number(),
"cidade": fake.city(),
})
return clientes
def gerar_encomendas(quantidade, clientes):
encomendas = []
for id_encomenda in range(1, quantidade + 1):
cliente = random.choice(clientes)
encomendas.append({
"id": id_encomenda,
"cliente_id": cliente["id"],
"produto": random.choice(PRODUTOS),
"quantidade": random.randint(1, 5),
"preco_unitario": round(random.uniform(5.0, 250.0), 2),
"data_encomenda": fake.date_between(start_date="-1y", end_date="today").isoformat(),
})
return encomendas
def gravar_csv(caminho, linhas, campos):
with open(caminho, "w", newline="", encoding="utf-8") as ficheiro:
escritor = csv.DictWriter(ficheiro, fieldnames=campos)
escritor.writeheader()
escritor.writerows(linhas)
def main():
clientes = gerar_clientes(20)
encomendas = gerar_encomendas(50, clientes)
gravar_csv("clientes.csv", clientes, ["id", "nome", "email", "data_nascimento", "telefone", "cidade"])
gravar_csv("encomendas.csv", encomendas, ["id", "cliente_id", "produto", "quantidade", "preco_unitario", "data_encomenda"])
print(f"{len(clientes)} clientes gerados em clientes.csv")
print(f"{len(encomendas)} encomendas geradas em encomendas.csv")
if __name__ == "__main__":
main()Antes deste exercício, cria servidor_alvo.py, um servidor mínimo (só biblioteca padrão, sem pip install nenhum) para o Locust ter algo real contra que correr, sem dependeres de um site que não é teu:
python
import json
import random
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
PRODUTOS = [
{"id": 1, "nome": "Rato sem fios", "preco": 19.90},
{"id": 2, "nome": "Teclado mecânico", "preco": 59.90},
{"id": 3, "nome": "Monitor 27", "preco": 189.90},
]
class Handler(BaseHTTPRequestHandler):
def _responder_json(self, status, corpo):
dados = json.dumps(corpo).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(dados)))
self.end_headers()
self.wfile.write(dados)
def do_GET(self):
if self.path == "/":
self._responder_json(200, {"status": "ok"})
elif self.path == "/produtos":
time.sleep(random.uniform(0.02, 0.08)) # simula trabalho real do servidor
self._responder_json(200, {"produtos": PRODUTOS})
else:
self._responder_json(404, {"erro": "não encontrado"})
def do_POST(self):
tamanho = int(self.headers.get("Content-Length", 0))
corpo = self.rfile.read(tamanho) if tamanho else b"{}"
try:
dados = json.loads(corpo)
except json.JSONDecodeError:
dados = {}
if self.path == "/login":
if dados.get("utilizador") and dados.get("password"):
self._responder_json(200, {"status": "ok", "token": "fake-token-123"})
else:
self._responder_json(401, {"erro": "credenciais em falta"})
else:
self._responder_json(404, {"erro": "não encontrado"})
def log_message(self, format, *args):
pass # silencia o log padrão do http.server, verboso demais para uma corrida de carga
if __name__ == "__main__":
servidor = HTTPServer(("127.0.0.1", 8000), Handler)
print("Servidor de teste a correr em http://127.0.0.1:8000")
servidor.serve_forever()Escrever um teste de carga simples
Objetivo: Escrever locustfile.py e correr, em modo headless, um teste de carga curto contra um pequeno servidor local.
- Corre python servidor_alvo.py (um servidor mínimo de apoio, só biblioteca padrão) e deixa-o a correr em http://127.0.0.1:8000.
- Escreve locustfile.py com UtilizadorLoja(HttpUser): wait_time entre 1 e 3, tarefa ver_produtos (peso 3, GET /produtos), fazer_login (peso 1, POST /login), pagina_inicial (peso 1, GET /).
- Corre em headless: locust -f locustfile.py --headless -u 5 -r 1 -t 10s --host=http://127.0.0.1:8000.
- Confirma na tabela final: as três rotas, 0 falhas, tempo de resposta médio de /produtos claramente mais alto (atraso artificial do servidor).
Dica: Usa sempre 127.0.0.1, não localhost: nalgumas máquinas Windows, resolver localhost passa primeiro por IPv6, acrescentando latência que não tem nada a ver com o teu servidor.
Ver solução
from locust import HttpUser, task, between
class UtilizadorLoja(HttpUser):
wait_time = between(1, 3)
@task(3)
def ver_produtos(self):
self.client.get("/produtos")
@task(1)
def fazer_login(self):
self.client.post("/login", json={"utilizador": "ana", "password": "segredo123"})
@task(1)
def pagina_inicial(self):
self.client.get("/")Construir um relatório visual com histórico de execuções
Objetivo: Escrever test_dados_gerados.py (valida os CSVs do primeiro exercício) e gerar_dashboard.py (acrescenta o resultado a um histórico e reconstrói um dashboard HTML a partir dele).
- 5 testes: emails válidos, ids de cliente únicos, encomendas referenciam cliente existente, quantidades positivas, preços positivos.
- Corre pytest test_dados_gerados.py --junitxml=resultados.xml.
- gerar_dashboard.py lê o XML, acrescenta uma linha a historico.csv, gera dashboard.html com um gráfico de barras empilhadas em SVG e uma tabela.
- Corre a sequência pelo menos três vezes; numa delas, estraga um dado de propósito para veres uma execução a vermelho, depois corrige e confirma que volta a verde.
Dica: A estrutura do XML de --junitxml é <testsuite tests="..." failures="..." errors="...">, com um <testcase> por teste.
Ver solução
# test_dados_gerados.py
import csv
def ler_csv(caminho):
with open(caminho, encoding="utf-8") as ficheiro:
return list(csv.DictReader(ficheiro))
def test_todos_os_clientes_tem_email_valido():
clientes = ler_csv("clientes.csv")
invalidos = [c["email"] for c in clientes if c["email"].count("@") != 1]
assert invalidos == [], f"emails inválidos: {invalidos}"
def test_todos_os_ids_de_cliente_sao_unicos():
clientes = ler_csv("clientes.csv")
ids = [c["id"] for c in clientes]
assert len(ids) == len(set(ids))
def test_todas_as_encomendas_referenciam_um_cliente_existente():
clientes = ler_csv("clientes.csv")
encomendas = ler_csv("encomendas.csv")
ids_clientes = {c["id"] for c in clientes}
orfas = [e["id"] for e in encomendas if e["cliente_id"] not in ids_clientes]
assert orfas == []
def test_todas_as_quantidades_sao_positivas():
encomendas = ler_csv("encomendas.csv")
invalidas = [e["id"] for e in encomendas if int(e["quantidade"]) <= 0]
assert invalidas == []
def test_todos_os_precos_sao_positivos():
encomendas = ler_csv("encomendas.csv")
invalidos = [e["id"] for e in encomendas if float(e["preco_unitario"]) <= 0]
assert invalidos == []
# gerar_dashboard.py
import csv
import os
import xml.etree.ElementTree as ET
from datetime import datetime
FICHEIRO_RESULTADOS = "resultados.xml"
FICHEIRO_HISTORICO = "historico.csv"
FICHEIRO_DASHBOARD = "dashboard.html"
CAMPOS_HISTORICO = ["execucao", "data_hora", "total", "passados", "falhados", "duracao_s"]
LARGURA_GRAFICO = 640
ALTURA_BARRA = 28
ESPACO_BARRA = 12
MARGEM_ESQUERDA = 90
MARGEM_DIREITA = 60
def ler_resultado_da_corrida(caminho):
raiz = ET.parse(caminho).getroot()
suite = raiz.find("testsuite") if raiz.tag == "testsuites" else raiz
total = int(suite.get("tests"))
falhados = int(suite.get("failures", 0)) + int(suite.get("errors", 0))
passados = total - falhados - int(suite.get("skipped", 0))
return {
"data_hora": suite.get("timestamp", datetime.now().isoformat(timespec="seconds")),
"total": total,
"passados": passados,
"falhados": falhados,
"duracao_s": round(float(suite.get("time", 0)), 3),
}
def acrescentar_ao_historico(resultado):
ficheiro_existe = os.path.exists(FICHEIRO_HISTORICO)
numero_execucao = 1
if ficheiro_existe:
with open(FICHEIRO_HISTORICO, encoding="utf-8") as f:
numero_execucao = len(list(csv.DictReader(f))) + 1
with open(FICHEIRO_HISTORICO, "a", newline="", encoding="utf-8") as f:
escritor = csv.DictWriter(f, fieldnames=CAMPOS_HISTORICO)
if not ficheiro_existe:
escritor.writeheader()
escritor.writerow({"execucao": numero_execucao, **resultado})
def ler_historico():
with open(FICHEIRO_HISTORICO, encoding="utf-8") as f:
return list(csv.DictReader(f))
def gerar_svg_barras(historico):
"""Um gráfico de barras horizontais empilhadas (passados a verde, falhados
a vermelho), uma barra por execução, sem depender de nenhuma biblioteca de
gráficos: só texto SVG construído com f-strings."""
altura_total = len(historico) * (ALTURA_BARRA + ESPACO_BARRA) + ESPACO_BARRA
maximo = max(int(linha["total"]) for linha in historico) or 1
largura_disponivel = LARGURA_GRAFICO - MARGEM_ESQUERDA - MARGEM_DIREITA
barras = []
for indice, linha in enumerate(historico):
y = ESPACO_BARRA + indice * (ALTURA_BARRA + ESPACO_BARRA)
total = int(linha["total"])
passados = int(linha["passados"])
falhados = int(linha["falhados"])
largura_passados = round((passados / maximo) * largura_disponivel, 1)
largura_falhados = round((falhados / maximo) * largura_disponivel, 1)
barras.append(f'<text x="{MARGEM_ESQUERDA - 10}" y="{y + ALTURA_BARRA / 2 + 4}" '
f'text-anchor="end" font-size="12" font-family="monospace">#{linha["execucao"]}</text>')
barras.append(f'<rect x="{MARGEM_ESQUERDA}" y="{y}" width="{largura_passados}" '
f'height="{ALTURA_BARRA}" fill="#2e7d32" />')
if falhados:
barras.append(f'<rect x="{MARGEM_ESQUERDA + largura_passados}" y="{y}" width="{largura_falhados}" '
f'height="{ALTURA_BARRA}" fill="#c62828" />')
barras.append(f'<text x="{MARGEM_ESQUERDA + largura_passados + largura_falhados + 8}" '
f'y="{y + ALTURA_BARRA / 2 + 4}" font-size="12" font-family="monospace">'
f'{passados}/{total}</text>')
conteudo_svg = "\n".join(barras)
return f'<svg width="{LARGURA_GRAFICO}" height="{altura_total}" xmlns="http://www.w3.org/2000/svg">{conteudo_svg}</svg>'
def gerar_linhas_tabela(historico):
linhas = []
for linha in historico:
estado = "OK" if linha["falhados"] == "0" else "FALHOU"
cor = "#2e7d32" if estado == "OK" else "#c62828"
linhas.append(
f"<tr><td>#{linha['execucao']}</td><td>{linha['data_hora']}</td>"
f"<td>{linha['total']}</td><td>{linha['passados']}</td><td>{linha['falhados']}</td>"
f"<td>{linha['duracao_s']}s</td><td style='color:{cor}; font-weight:bold'>{estado}</td></tr>"
)
return "\n".join(linhas)
def gerar_dashboard_html(historico):
grafico = gerar_svg_barras(historico)
linhas_tabela = gerar_linhas_tabela(historico)
return f"""<!doctype html>
<html lang="pt">
<head>
<meta charset="utf-8">
<title>Dashboard de execuções</title>
<style>
body {{ font-family: sans-serif; max-width: 720px; margin: 40px auto; color: #222; }}
table {{ border-collapse: collapse; width: 100%; margin-top: 24px; }}
th, td {{ padding: 6px 10px; text-align: left; border-bottom: 1px solid #ddd; }}
th {{ background: #f4f4f4; }}
</style>
</head>
<body>
<h1>Dashboard de execuções</h1>
<p>{len(historico)} execução(ões) registada(s), a mais recente é #{historico[-1]['execucao']}.</p>
{grafico}
<table>
<thead>
<tr><th>Execução</th><th>Data/hora</th><th>Total</th><th>Passados</th><th>Falhados</th><th>Duração</th><th>Estado</th></tr>
</thead>
<tbody>
{linhas_tabela}
</tbody>
</table>
</body>
</html>"""
def main():
resultado = ler_resultado_da_corrida(FICHEIRO_RESULTADOS)
acrescentar_ao_historico(resultado)
historico = ler_historico()
html = gerar_dashboard_html(historico)
with open(FICHEIRO_DASHBOARD, "w", encoding="utf-8") as f:
f.write(html)
print(f"Histórico atualizado em {FICHEIRO_HISTORICO} ({len(historico)} execução(ões))")
print(f"Dashboard gerado em {FICHEIRO_DASHBOARD}")
if __name__ == "__main__":
main()gerar_svg_barras() não usa nenhuma biblioteca de gráficos: desenha o SVG diretamente, com f-strings, à mão. Não é a forma mais elegante de gerar SVG, mas é a mais direta de perceberes exatamente o que está a acontecer, sem uma dependência nova só para três retângulos e um pouco de texto.
O teu progresso
Marca os exercícios à medida que os fores fazendo a sério, não só a ler a solução. Fica guardado só neste browser.
Conclusão
Em seis semanas: controlar um browser real com Playwright e Page Object Model, organizar a suite com fixtures, parametrização e markers, containerizar tudo com Docker, correr sozinho a cada commit com GitHub Actions, e responder à pergunta que nenhum teste anterior fazia, o sistema aguenta carga, com Locust, mocking e dados fictícios com Faker.
Se fizeste os cinco exercícios a sério, tens um pipeline de CI/CD real a correr no GitHub Actions, e um dashboard próprio com histórico de execuções. É literalmente a saída que este curso promete.
Se ficares só com uma decisão deste curso, fica com esta: da próxima vez que correres uma suite à mão antes de um deploy, pergunta-te porque é que ainda não corre sozinha a cada commit.
Se ainda não fizeste os anteriores: Curso 1 e Curso 2. E o Curso 4, o último, já está aqui: IA Aplicada a Testes de Software.
Isto ajudou-te?
Fontes, e o que é só observação minha
O resto
- É o terceiro de quatro cursos que escrevi, a continuar o programa a partir dos Cursos 1 e 2. Não é investigação, é o programa com que ensino automação de testes a sério, calibrado módulo a módulo.