Um formulário que retorna HTTP 200 e exibe uma mensagem de sucesso ainda pode falhar em escrever no banco de dados se o handler de erro silenciosamente engolir uma violação de constraint. Consultas SQL diretas pegam isso: elas verificam que a linha esperada existe na tabela esperada com os valores esperados, ignorando completamente a UI e a API.
Por que SQL pertence ao toolkit de um QA engineer
Todo aplicativo web tem a mesma estrutura: um frontend que exibe dados, uma API que processa requisições, e um banco de dados que armazena o estado. Testes automatizados via UI verificam a primeira camada. Testes de API verificam a segunda. Mas a terceira camada (se os dados certos realmente pousaram na tabela certa com os valores certos) fica invisível a menos que você olhe diretamente.
A lacuna aparece constantemente no trabalho real de testes. Um envio de formulário retorna HTTP 200 e a UI mostra sucesso. A escrita no banco falhou silenciosamente por uma violação de constraint que o handler de erro engoliu. Uma atualização de status reflete visualmente na UI porque o frontend atualizou seu estado local, mas a chamada de API que deveria escrever no backend nunca foi disparada. Um script de seed de teste roda, o teste executa, e você descobre que os dados foram inseridos no banco do ambiente errado.
SQL te dá três capacidades: verificar o efeito das ações nos dados, configurar estados de teste sem UI, e debugar falhas que a interface do aplicativo oculta.
Nada disso exige conhecimento avançado de banco de dados. Cinco padrões de consulta cobrem a vasta maioria do que um QA engineer faz na prática. O resto deste artigo ensina esses padrões, mostra como usá-los para verificação e gerenciamento de dados de teste, e cobre como rodar consultas diretamente de testes Playwright.
As cinco consultas que cobrem 80% do trabalho de QA
Comece aqui. Esses cinco padrões cobrem a maioria das situações que você vai encontrar como tester.
SELECT com WHERE recupera linhas que correspondem aos seus critérios:-- Buscar um usuário específico por email
SELECT id, email, role, is_active
FROM users
WHERE email = 'testuser@example.com';
-- Buscar todos os pedidos de um cliente
SELECT id, status, total_amount, created_at
FROM orders
WHERE customer_id = 42;
-- Buscar pedidos das últimas 24 horas
SELECT id, customer_id, status, total_amount
FROM orders
WHERE created_at > NOW() - INTERVAL '24 hours';-- Pedidos com email do cliente
SELECT orders.id, orders.status, orders.total_amount, users.email
FROM orders
JOIN users ON orders.customer_id = users.id
WHERE orders.id = 1001;-- Quantos pedidos por status
SELECT status, COUNT(*) AS total
FROM orders
GROUP BY status;INSERT INTO users (email, password_hash, role, created_at)
VALUES ('qa_test_user@test.com', 'hashed_value', 'customer', NOW());UPDATE orders
SET status = 'cancelled'
WHERE id = 1001;UPDATE orders SET status = 'cancelled' sem filtro cancela todos os pedidos na tabela. Sempre escreva a cláusula WHERE primeiro, depois adicione a ação.Esses cinco padrões formam a base. Tudo mais neste artigo se baseia neles.
Verificando resultados de testes: asserting o que a UI não consegue mostrar
Após uma ação de teste, a verificação mais direta é uma consulta que verifica o estado esperado no banco de dados. Se a consulta retornar a linha esperada, a operação foi bem-sucedida de ponta a ponta. Se retornar vazio, algo falhou entre a ação do usuário e o estado persistente, mesmo que a UI parecesse ter tido sucesso.
Após um registro de usuário:
-- O usuário foi criado com os defaults corretos?
SELECT id, email, role, is_active, email_verified
FROM users
WHERE email = 'newuser@test.com';Você espera uma linha, com role = 'customer', is_active = true, e email_verified = false. Qualquer desvio é um bug que vale investigar antes de chegar à produção.
Após realizar um pedido:
-- O pedido chegou com o status e valor corretos?
SELECT
o.id,
o.status,
o.total_amount,
o.created_at,
u.email AS customer_email
FROM orders o
JOIN users u ON o.customer_id = u.id
WHERE u.email = 'testuser@test.com'
ORDER BY o.created_at DESC
LIMIT 1;Você espera status = 'pending' e total_amount correspondendo ao que a UI exibiu. Se o total tiver um centavo de diferença, você encontrou um bug de arredondamento. Se o status for qualquer coisa diferente de 'pending', você encontrou um bug de transição de estado.
Para assertions de contagem de linhas, verificando que exatamente N registros foram criados, atualizados ou deletados:
-- Verificar que exatamente três itens de pedido foram criados para esse pedido
SELECT COUNT(*) AS item_count
FROM order_items
WHERE order_id = 1001;
-- Verificar que não existem registros duplicados de usuário para esse email
SELECT COUNT(*) AS duplicate_count
FROM users
WHERE email = 'testuser@test.com';Essas consultas estão prontas para assertion. Em um teste automatizado, você compara o count retornado com o valor esperado. Em testes manuais, você as roda no seu cliente SQL e verifica o resultado.
Configurando dados de teste com INSERT, e limpando depois
Passar pela UI para criar dados de teste é lento. Um teste que precisa de um usuário com cinco pedidos concluídos, dois pendentes, e um cancelado pode levar dez minutos para configurar manualmente. Com SQL, leva segundos.
-- Criar um usuário de teste
INSERT INTO users (email, password_hash, role, is_active, created_at)
VALUES (
'qa_load_test_user@test.com',
'$2b$10$placeholder_hash_value',
'customer',
true,
NOW()
);
-- Buscar o ID do novo usuário para inserções subsequentes
SELECT id FROM users WHERE email = 'qa_load_test_user@test.com';
-- Criar pedidos para esse usuário (assumindo id = 99)
INSERT INTO orders (customer_id, status, total_amount, created_at)
VALUES
(99, 'completed', 89.99, NOW() - INTERVAL '10 days'),
(99, 'completed', 124.50, NOW() - INTERVAL '7 days'),
(99, 'completed', 45.00, NOW() - INTERVAL '4 days'),
(99, 'pending', 67.25, NOW() - INTERVAL '1 day'),
(99, 'pending', 210.00, NOW()),
(99, 'cancelled', 30.00, NOW() - INTERVAL '5 days');Isso coloca o banco exatamente no estado que seu teste requer, sem clicar em nenhuma UI. O teste agora consegue verificar comportamentos que dependem do histórico de pedidos: como descontos de fidelidade, aplicação de limites de pedido, ou estatísticas do dashboard.
A limpeza é igualmente importante. Dados de teste deixados em bancos compartilhados causam poluição de teste. Um teste posterior encontra registros inesperados, as contagens voltam erradas, e você passa uma hora debugando algo que nunca estava quebrado de verdade.
-- Limpar após os testes usando uma convenção de nomenclatura consistente
DELETE FROM orders WHERE customer_id IN (
SELECT id FROM users WHERE email LIKE '%@test.com'
);
DELETE FROM users WHERE email LIKE '%@test.com';A convenção importa: use um domínio consistente como @test.com ou um prefixo como qa_ para todos os dados gerados por testes. Isso torna as consultas de limpeza seguras e confiáveis. Você pode rodá-las ao final de cada execução de testes sem se preocupar em tocar dados reais.
SELECT * FROM users WHERE email LIKE '%@test.com' antes do DELETE FROM users WHERE email LIKE '%@test.com' leva cinco segundos e pode prevenir um dia muito ruim.JOINs para QA: encontrando registros órfãos e verificando relacionamentos
Bancos de dados relacionais forçam relacionamentos através de chaves estrangeiras, mas bugs acontecem na fronteira entre tabelas. Um item de pedido pode referenciar um produto que foi deletado. Um registro de sessão pode apontar para um usuário que não existe mais. Esses registros órfãos causam falhas silenciosas difíceis de rastrear sem olhar os dados diretamente.
LEFT JOIN expõe essas lacunas. Ao contrário do JOIN regular, o LEFT JOIN retorna todas as linhas da tabela esquerda, preenchendo NULL nas colunas da tabela direita onde não há correspondência:
-- Encontrar itens de pedido sem produto correspondente (registros órfãos)
SELECT
order_items.id AS item_id,
order_items.order_id,
order_items.product_id,
products.name AS product_name
FROM order_items
LEFT JOIN products ON order_items.product_id = products.id
WHERE products.id IS NULL;Qualquer linha que essa consulta retornar é um item de pedido órfão: ele referencia um produto que não existe. Em um banco saudável, essa consulta retorna zero linhas.
-- Encontrar pedidos sem cliente (não deveria ser possível, mas vale verificar)
SELECT orders.id, orders.customer_id, orders.total_amount
FROM orders
LEFT JOIN users ON orders.customer_id = users.id
WHERE users.id IS NULL;
-- Verificar que todo pedido tem pelo menos um item
SELECT
o.id AS order_id,
o.status,
COUNT(oi.id) AS item_count
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, o.status
HAVING COUNT(oi.id) = 0;Execute essas consultas após cenários de teste que envolvem deleção, arquivamento, ou operações em massa. Regras de cascade delete são fáceis de configurar errado, e um registro órfão que deveria ter sido limpo pode causar erros no aplicativo dias depois, quando algum processo tenta referenciá-lo.
Consultas de agregação: verificando cálculos que a UI exibe
Quando seu aplicativo mostra um total, uma média, uma contagem, ou um resumo, você consegue verificá-lo diretamente no banco de dados. Se a UI mostra "Receita: R$4.827,50" para o mês passado, o banco de dados deve concordar.
-- Verificar receita total para um período
SELECT
SUM(total_amount) AS total_revenue,
COUNT(*) AS order_count,
AVG(total_amount) AS average_order_value
FROM orders
WHERE status = 'completed'
AND created_at >= '2026-05-01'
AND created_at < '2026-06-01';
-- Verificar divisão de receita por produto
SELECT
p.name AS product_name,
SUM(oi.quantity) AS units_sold,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN orders o ON oi.order_id = o.id
WHERE o.status = 'completed'
GROUP BY p.id, p.name
ORDER BY revenue DESC;Essas consultas se tornam poderosas quando o cálculo da UI e o cálculo do banco diferem. Uma discrepância significa que a UI está calculando algo incorretamente, a consulta que alimenta a UI tem um bug, ou os próprios dados de teste estão em estado inesperado. Os três valem encontrar.
-- Verificar se contagens de estoque correspondem ao que a página de produto exibe
SELECT
id,
name,
stock_quantity,
CASE WHEN stock_quantity = 0 THEN 'out_of_stock'
WHEN stock_quantity < 10 THEN 'low_stock'
ELSE 'in_stock'
END AS computed_status
FROM products
WHERE id IN (101, 102, 103);A coluna computed_status permite comparar o que o SQL calcula com o que a UI exibe. Se a UI mostra "Em estoque" mas a consulta retorna 'low_stock', você encontrou um bug de exibição ou uma inconsistência de regra de negócio.
Conectando a um banco de dados a partir de um teste
Para verificação manual durante debugging, um cliente SQL como TablePlus ou DBeaver é o caminho mais rápido. Conecte ao banco, rode a consulta, leia o resultado. Mas para suites de teste automatizadas, você quer que a verificação do banco aconteça como parte do próprio teste.
Pela linha de comando com psql:# Rodar uma consulta única e obter o resultado
psql $DATABASE_URL -c "SELECT COUNT(*) FROM orders WHERE status = 'pending';"
# Rodar um arquivo de consulta
psql $DATABASE_URL -f verify_order_state.sqlimport { test, expect } from '@playwright/test';
import { Client } from 'pg'; // npm install pg
test('order is saved to database after checkout', async ({ page }) => {
const testEmail = 'qa_checkout_test@test.com';
// Realizar a ação de UI
await page.goto('/checkout');
// ... preencher carrinho, endereço de entrega, enviar pedido ...
// Verificar o resultado no banco de dados
const db = new Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
try {
const result = await db.query(
`SELECT o.id, o.status, o.total_amount
FROM orders o
JOIN users u ON o.customer_id = u.id
WHERE u.email = $1
ORDER BY o.created_at DESC
LIMIT 1`,
[testEmail]
);
expect(result.rows).toHaveLength(1);
expect(result.rows[0].status).toBe('pending');
expect(parseFloat(result.rows[0].total_amount)).toBeCloseTo(89.99, 2);
} finally {
await db.end();
}
});O placeholder $1 é importante. Nunca concatene valores controlados pelo usuário em uma string SQL; use consultas parametrizadas para qualquer valor que vem de input de teste ou dados externos. Além de prevenir SQL injection em utilitários de teste, também lida com caracteres especiais em endereços de email ou nomes sem quebrar a consulta.
.env.test e nunca faça commit dele. Use process.env.DATABASE_URL no código de teste e adicione o .env.test bruto ao .gitignore. Seu pipeline de CI deve injetar o valor através de variáveis de ambiente, não através de arquivos commitados.Um padrão prático para suites de teste maiores é criar helpers de banco de dados junto com seus page objects:
// helpers/db.ts
import { Client } from 'pg';
export async function getLatestOrderForUser(email: string) {
const db = new Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
try {
const result = await db.query(
`SELECT o.* FROM orders o
JOIN users u ON o.customer_id = u.id
WHERE u.email = $1
ORDER BY o.created_at DESC LIMIT 1`,
[email]
);
return result.rows[0] ?? null;
} finally {
await db.end();
}
}
export async function cleanupTestUser(email: string) {
const db = new Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
try {
await db.query(
`DELETE FROM orders WHERE customer_id IN
(SELECT id FROM users WHERE email = $1)`,
[email]
);
await db.query('DELETE FROM users WHERE email = $1', [email]);
} finally {
await db.end();
}
}Depois no seu teste:
import { getLatestOrderForUser, cleanupTestUser } from '../helpers/db';
test.afterEach(async () => {
await cleanupTestUser('qa_checkout_test@test.com');
});
test('order status is pending after checkout', async ({ page }) => {
// ... ações de UI ...
const order = await getLatestOrderForUser('qa_checkout_test@test.com');
expect(order).not.toBeNull();
expect(order.status).toBe('pending');
});Isso mantém o SQL fora do corpo do teste e torna as assertions legíveis.
Regras de segurança para acesso a banco de dados em testes
Algumas regras que previnem o tipo de incidente que arruína um dia de trabalho.
Sempre use WHERE em UPDATE e DELETE. Vale repetir. O erro SQL mais perigoso é rodar uma consulta de modificação sem filtro. Antes de executar qualquer UPDATE ou DELETE, releia e confirme que a cláusula WHERE está lá e está correta. Rode SELECT antes de DELETE ao limpar. Se você vai deletar dados de teste, rode primeiro a versão SELECT da consulta para ver exatamente o que será removido. Se o resultado parecer certo, mude SELECT para DELETE e execute. Saiba em qual ambiente você está conectado. A maioria dos clientes SQL exibe a conexão atual no cabeçalho ou aba. Antes de rodar qualquer consulta de escrita, confirme que está em um banco de desenvolvimento ou staging, não em produção. Uma convenção clara de nomenclatura para bancos (ex:myapp_dev, myapp_staging, myapp_prod) facilita a verificação visual.
Use transações para configuração de dados de teste em múltiplas etapas. Quando seu setup insere em múltiplas tabelas, envolva em uma transação para que falhas parciais não deixem o banco em estado quebrado:
BEGIN;
INSERT INTO users (email, role, is_active)
VALUES ('qa_transaction_test@test.com', 'customer', true);
INSERT INTO orders (customer_id, status, total_amount)
VALUES (LASTVAL(), 'pending', 99.00);
-- Se qualquer coisa acima falhou, isso reverte ambas as inserções
COMMIT;FAQ
Preciso entender design de banco de dados para escrever SQL de QA?Não. Você precisa entender as tabelas que seu aplicativo usa: como se chamam, quais colunas têm, como se relacionam. Isso vem de ler o schema (a maioria dos clientes SQL o mostra em uma barra lateral), perguntar a um desenvolvedor, ou olhar os modelos ORM na codebase. Você não precisa saber por que o schema foi projetado dessa forma.
Qual a diferença entre INNER JOIN e LEFT JOIN?INNER JOIN (ou apenas JOIN) retorna linhas apenas onde ambas as tabelas têm um registro correspondente. LEFT JOIN retorna todas as linhas da tabela esquerda, com NULL nas colunas da tabela direita onde não há correspondência. Para trabalho de QA: use JOIN quando quiser ver os dados combinados, use LEFT JOIN quando quiser encontrar relacionamentos ausentes.
Devo testar contra um banco de dados de teste dedicado ou o banco compartilhado de desenvolvimento?Um banco dedicado de teste é fortemente preferível. Quando múltiplos desenvolvedores e pipelines de teste compartilham um banco, dados de teste de execuções diferentes se misturam e assertions sobre contagens ou estado ficam não confiáveis. Configure um banco separado com o mesmo schema, semeado com dados de baseline mínimos, que seus testes controlam completamente.
Minha consulta está retornando zero linhas mas sei que os dados estão lá. O que está errado?Quatro causas comuns. O casing do valor filtrado não bate com o banco: Test@example.com não corresponde a test@example.com em collation case-sensitive. Um valor de coluna tem espaços em branco no início ou fim. A conexão está no banco ou schema errado. Os dados foram inseridos numa transação não commitada que outra conexão ainda não enxerga.
No PostgreSQL: SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';. No MySQL: SHOW TABLES;. A maioria dos clientes GUI de SQL também exibe o schema em uma árvore no lado esquerdo.