Un formulario que devuelve HTTP 200 y muestra un mensaje de éxito puede no haber escrito nada en la base de datos si el manejador de errores silencia una violación de restricción. Las consultas SQL directas detectan esto: verifican que la fila esperada existe en la tabla esperada con los valores esperados, sin pasar por la UI ni por la API. Este artículo cubre los cinco patrones de consulta que manejan la mayoría de los escenarios de QA, cómo configurar y limpiar datos de test en segundos, y cómo ejecutar aserciones de base de datos desde dentro de los tests de Playwright.
Por qué SQL pertenece al toolkit de un ingeniero QA
Toda aplicación web tiene la misma estructura: un frontend que muestra datos, una API que procesa solicitudes y una base de datos que almacena el estado. Los tests automatizados a través de la UI verifican la primera capa. Los tests de API verifican la segunda. Pero la tercera capa (si los datos correctos realmente llegaron a la tabla correcta con los valores correctos) permanece invisible a menos que mires directamente.
La brecha aparece constantemente en el trabajo real de testing. Un envío de formulario devuelve HTTP 200, la UI muestra un mensaje de éxito, y la escritura en la base de datos falló silenciosamente por una violación de restricción que el manejador de errores descartó. Una actualización de estado se refleja visualmente en la UI porque el frontend actualizó su estado local, pero la llamada a la API que debía escribir en el backend nunca se ejecutó. Un script de seed corre, el test se ejecuta, y descubres que los datos de test se insertaron en la base de datos del entorno equivocado.
SQL te da tres capacidades concretas: verificar que las acciones tuvieron el efecto esperado sobre los datos, configurar datos de test más rápido que cualquier flujo de UI, y depurar fallos que la propia interfaz de la aplicación te oculta.
Nada de esto requiere conocimientos avanzados de bases de datos. Cinco patrones de consulta cubren la gran mayoría de lo que un ingeniero QA hace en la práctica.
Las cinco consultas que cubren el 80% del trabajo de QA
SELECT con WHERE recupera filas que coinciden con tus criterios:-- Obtener un usuario específico por email
SELECT id, email, role, is_active
FROM users
WHERE email = 'testuser@example.com';
-- Obtener todos los pedidos de un cliente
SELECT id, status, total_amount, created_at
FROM orders
WHERE customer_id = 42;
-- Obtener pedidos realizados en las últimas 24 horas
SELECT id, customer_id, status, total_amount
FROM orders
WHERE created_at > NOW() - INTERVAL '24 hours';-- Pedidos con email del 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;-- Cuántos pedidos hay por estado
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' sin filtro cancela todos los pedidos de la tabla. Siempre escribe el WHERE primero, luego agrega la acción.Estos cinco patrones forman la base. Todo lo demás en este artículo se construye sobre ellos.
Verificar resultados de tests: asertando lo que la UI no puede mostrar
Después de una acción de test, la verificación más directa es una consulta que revisa el estado esperado en la base de datos. Si la consulta devuelve la fila esperada, la operación fue exitosa de extremo a extremo. Si no devuelve nada, algo falló entre la acción del usuario y el estado persistente, aunque la UI pareciera haber tenido éxito.
Después del registro de un usuario:
-- ¿El usuario se creó con los valores por defecto correctos?
SELECT id, email, role, is_active, email_verified
FROM users
WHERE email = 'newuser@test.com';Esperas una fila, con role = 'customer', is_active = true y email_verified = false. Cualquier diferencia es un bug que vale investigar antes de que llegue a producción.
Después de crear un pedido:
-- ¿El pedido llegó con el estado y el monto correctos?
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;Esperas status = 'pending' y total_amount que coincida con lo que mostró la UI. Si el total difiere por un centavo, encontraste un bug de redondeo. Si el estado es cualquier cosa distinta de 'pending', encontraste un bug de transición de estado.
Para aserciones de conteo de filas, verificar que exactamente N registros fueron creados, actualizados o eliminados:
-- Verificar que se crearon exactamente tres ítems para este pedido
SELECT COUNT(*) AS item_count
FROM order_items
WHERE order_id = 1001;
-- Verificar que no existen registros duplicados de usuario para este email
SELECT COUNT(*) AS duplicate_count
FROM users
WHERE email = 'testuser@test.com';Estas consultas están listas para usarse como aserciones. En un test automatizado, comparás el conteo devuelto con el valor esperado. En testing manual, las ejecutas en tu cliente SQL y verificas el resultado.
Configurar datos de test con INSERT, y limpiarlos
Pasar por la UI para crear datos de test es lento. Un test que necesita un usuario con cinco pedidos completados, dos pedidos pendientes y un pedido cancelado puede tardar diez minutos en configurarse manualmente. Con SQL, lleva segundos.
-- Crear un usuario de test
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()
);
-- Obtener el ID del nuevo usuario para inserts posteriores
SELECT id FROM users WHERE email = 'qa_load_test_user@test.com';
-- Crear pedidos para ese usuario (asumiendo 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');Esto pone la base de datos exactamente en el estado que el test requiere, sin hacer clic en ninguna UI. El test puede verificar comportamientos que dependen del historial de pedidos: descuentos por fidelidad, límites de pedidos, o estadísticas del dashboard.
La limpieza es igual de importante. Los datos de test que quedan en bases de datos compartidas causan contaminación entre tests: un test posterior encuentra registros inesperados, los conteos devuelven valores incorrectos, y perdés una hora depurando algo que nunca estuvo roto.
-- Limpiar después de los tests usando una convención de nombres 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';La convención importa: usa un dominio consistente como @test.com o un prefijo como qa_ para todos los datos generados por tests. Esto hace que las consultas de limpieza sean seguras y confiables. Puedes ejecutarlas al final de cada ejecución de tests sin preocuparte por tocar datos reales.
SELECT * FROM users WHERE email LIKE '%@test.com' antes de DELETE FROM users WHERE email LIKE '%@test.com' tarda cinco segundos y puede evitar un día muy malo.JOINs para QA: encontrar registros huérfanos y verificar relaciones
Las bases de datos relacionales hacen cumplir las relaciones mediante claves foráneas, pero los bugs ocurren en el límite entre tablas. Un ítem de pedido podría referenciar un producto que fue eliminado. Un registro de sesión podría apuntar a un usuario que ya no existe. Estos registros huérfanos causan fallos silenciosos difíciles de rastrear sin mirar los datos directamente.
LEFT JOIN expone estas brechas. A diferencia de un JOIN regular que solo devuelve filas donde ambos lados tienen una coincidencia, LEFT JOIN devuelve todas las filas de la tabla izquierda y rellena con NULL las columnas de la tabla derecha donde no hay coincidencia:
-- Encontrar ítems de pedido sin producto correspondiente (registros huérfanos)
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;Cualquier fila que devuelva esta consulta es un ítem de pedido huérfano: referencia un producto que no existe. En una base de datos sana, esta consulta devuelve cero filas.
-- Encontrar pedidos sin cliente (no debería ser posible, pero 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 cada pedido tiene al menos un ítem
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;Ejecuta estas consultas después de escenarios de test que involucren eliminación, archivado u operaciones masivas. Las reglas de delete en cascada son fáciles de configurar mal, y un registro huérfano que debería haberse limpiado puede causar errores en la aplicación días después cuando algún proceso intente referenciarlo.
Consultas de agregación: verificar los cálculos que muestra la UI
Cuando tu aplicación muestra un total, un promedio, un conteo o un resumen, podés verificarlo directamente contra la base de datos. Si la UI muestra "Ingresos: $4.827,50" para el mes pasado, la base de datos debería concordar.
-- Verificar los ingresos totales para un 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 el desglose de ingresos por producto
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;Estas consultas se vuelven poderosas cuando el cálculo de la UI y el cálculo de la base de datos no coinciden. Una discrepancia significa que la UI está calculando algo incorrectamente, la consulta que alimenta la UI tiene un bug, o los propios datos de test están en un estado inesperado. Los tres casos valen la pena encontrar.
-- Verificar que los conteos de inventario coinciden con lo que muestra la página del producto
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);La columna computed_status te permite comparar lo que calcula SQL con lo que muestra la UI. Si la UI muestra "En stock" pero la consulta devuelve 'low_stock', encontraste un bug de visualización o una inconsistencia en la lógica de negocio.
Conectarse a una base de datos desde un test
Para verificación manual durante la depuración, un cliente SQL como TablePlus o DBeaver es el camino más rápido. Conéctate a la base de datos, ejecuta la consulta, lee el resultado. Pero para suites de tests automatizados, quieres que la verificación de base de datos ocurra como parte del test mismo.
Desde la línea de comandos con psql
# Ejecutar una consulta y obtener el resultado
psql $DATABASE_URL -c "SELECT COUNT(*) FROM orders WHERE status = 'pending';"
# Ejecutar un archivo de consulta
psql $DATABASE_URL -f verify_order_state.sqlDesde un test de Playwright con node-postgres
import { test, expect } from '@playwright/test';
import { Client } from 'pg'; // npm install pg
test('el pedido se guarda en la base de datos después del checkout', async ({ page }) => {
const testEmail = 'qa_checkout_test@test.com';
// Ejecutar la acción en la UI
await page.goto('/checkout');
// ... llenar carrito, ingresar envío, enviar pedido ...
// Verificar el resultado en la base de datos
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();
}
});El marcador $1 es importante. Nunca concatenes valores controlados por el usuario en una cadena SQL; usá consultas parametrizadas para cualquier valor que provenga de input de test o datos externos. Además de prevenir inyección SQL en utilidades de test, maneja caracteres especiales en emails o nombres sin romper la consulta.
.env.test y nunca la subas al repositorio. Usá process.env.DATABASE_URL en el código de tests y agregá el .env.test en bruto al .gitignore. Tu pipeline de CI debe inyectar el valor mediante variables de entorno, no mediante archivos commiteados.Un patrón práctico para suites de tests más grandes es crear helpers de base de datos junto a tus 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();
}
}Luego en el test:
import { getLatestOrderForUser, cleanupTestUser } from '../helpers/db';
test.afterEach(async () => {
await cleanupTestUser('qa_checkout_test@test.com');
});
test('el estado del pedido es pending después del checkout', async ({ page }) => {
// ... acciones en la UI ...
const order = await getLatestOrderForUser('qa_checkout_test@test.com');
expect(order).not.toBeNull();
expect(order.status).toBe('pending');
});Esto mantiene el SQL fuera del cuerpo del test y hace que las aserciones sean fáciles de leer.
Reglas de seguridad para el acceso a la base de datos en testing
Algunas reglas que evitan el tipo de incidentes que arruinan un día de trabajo.
Siempre usa WHERE en UPDATE y DELETE. Vale repetirlo. El error SQL más peligroso es ejecutar una consulta de modificación sin filtro. Antes de ejecutar cualquier UPDATE o DELETE, léelo en voz alta y confirma que el WHERE está presente y es correcto. Ejecuta SELECT antes de DELETE al limpiar datos. Si vas a eliminar datos de test, ejecuta la versión SELECT de la consulta primero para ver exactamente qué se va a eliminar. Si el resultado parece correcto, cambia SELECT por DELETE y ejecútala. Sabe a qué entorno estás conectado. La mayoría de los clientes SQL muestra la conexión actual en el encabezado o en la pestaña. Antes de ejecutar cualquier consulta de escritura, confirma que estás en una base de datos de desarrollo o staging, no en producción. Una convención clara de nombres para las bases de datos (por ejemplo,myapp_dev, myapp_staging, myapp_prod) facilita verificarlo de un vistazo.
Usa transacciones para la configuración de datos de test en múltiples pasos. Cuando tu setup inserta en múltiples tablas, envolvelo en una transacción para que los fallos parciales no dejen la base de datos en un estado roto:
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);
-- Si algo de lo anterior falló, esto revierte ambos inserts
COMMIT;FAQ
¿Necesito entender el diseño de bases de datos para escribir SQL de QA?No. Necesitas entender las tablas que usa tu aplicación: cómo se llaman, qué columnas tienen, cómo se relacionan entre sí. Esto viene de leer el esquema (la mayoría de los clientes SQL lo muestran en una barra lateral), preguntar a un desarrollador, o mirar los modelos ORM en el codebase. No necesitas saber por qué el esquema fue diseñado de esa manera.
¿Cuál es la diferencia entre INNER JOIN y LEFT JOIN?INNER JOIN (o simplemente JOIN) devuelve filas solo cuando ambas tablas tienen un registro coincidente. LEFT JOIN devuelve todas las filas de la tabla izquierda, con NULL en las columnas de la tabla derecha donde no hay coincidencia. Para el trabajo de QA: usa JOIN cuando quieres ver los datos combinados, usa LEFT JOIN cuando quieres encontrar relaciones faltantes.
¿Debería testear contra una base de datos de test dedicada o la base de datos de desarrollo compartida?Una base de datos de test dedicada es lo que hay que usar. Cuando múltiples desarrolladores y pipelines de CI comparten una base de datos, los datos de test de diferentes ejecuciones se mezclan y las aserciones sobre conteos o estado se vuelven poco confiables. Configura una base de datos separada con el mismo esquema, precargada con datos base mínimos, que tus tests controlen completamente.
Mi consulta no devuelve filas pero sé que los datos están ahí. ¿Qué está mal?Cuatro causas comunes: el valor que estás filtrando tiene una capitalización diferente a la que está en la base de datos (WHERE email = 'Test@example.com' no coincidirá con test@example.com en una colación sensible a mayúsculas); hay espacios al inicio o al final en el valor de una columna; estás conectado a la base de datos o esquema equivocado; o los datos fueron insertados dentro de una transacción sin confirmar que otra conexión todavía no puede ver.
En PostgreSQL: SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';. En MySQL: SHOW TABLES;. La mayoría de los clientes GUI de SQL también muestran el esquema en una vista de árbol a la izquierda.