A diferencia de las aserciones de Playwright, un check fallido en k6 no detiene el test: todos los usuarios virtuales siguen corriendo, y k6 cuenta las tasas de éxito y fallo en miles de requests para mostrar la señal de salud en el resumen. Lo que detiene la ejecución es un umbral fallido: si el tiempo de respuesta p95 supera el límite configurado, k6 termina con código 99 y el step de CI falla exactamente igual que un test fallido. Este tutorial recorre la instalación de k6, la escritura de un primer script con sleep() realista, la lectura de la tabla de salida, la configuración de stages de ramp-up y la definición de umbrales que hacen visibles las regresiones de performance en CI.
Qué es k6 y por qué los ingenieros QA deberían conocerlo
k6 es una herramienta de testing de performance de código abierto construida por Grafana Labs. Escribes scripts de test en JavaScript o TypeScript, los ejecutas desde la CLI y obtienes salida detallada sobre cómo se comportó tu sistema bajo carga. El repositorio de GitHub tiene más de 24.000 stars y se usa en organizaciones de todo tamaño.
Lo que hace que k6 sea el punto de entrada correcto para ingenieros QA es el modelo de scripting. Si ya escribes tests de Playwright o Jest, la sintaxis es familiar: importas módulos, escribes funciones, haces requests HTTP y agregas aserciones. No hay GUI pesada, ni formato de grabación propietario, ni archivo de configuración XML. Solo un script.
k6 también se integra limpiamente con GitHub Actions. Instalas el binario, ejecutas tu script, y si el test falla (más sobre qué significa "falla" después), el step de CI termina con código no cero. Ese es el mismo contrato que usa cada otra herramienta de testing.
La herramienta no reemplaza a Playwright. Playwright verifica el comportamiento; k6 mide el performance bajo carga. Responden preguntas diferentes y los dos pertenecen a una estrategia QA madura.
Instalar k6 y ejecutar tu primer test
k6 se distribuye como un binario independiente. En macOS:
brew install k6En Linux (Debian/Ubuntu):
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install k6En Windows, usa el instalador oficial desde k6.io/docs/get-started/installation o instala vía Chocolatey:
choco install k6Una vez instalado, verifica que funciona:
k6 versionAhora crea un archivo llamado load-test.js y agrega este script mínimo:
import http from 'k6/http';
export default function () {
http.get('https://test-api.k6.io/public/crocodiles/');
}Ejecutalo:
k6 run load-test.jsk6 usa test-api.k6.io como sandbox público. Puedes ejecutarlo contra ese endpoint sin configurar nada. Vas a ver en tu terminal el conteo de requests, tiempos de respuesta y una tabla de resumen. Ese es tu primer load test corriendo.
La anatomía básica de un test
Un script real de k6 tiene tres partes: el export options que controla cómo corre el test, la función default que contiene la lógica del test, y cualquier configuración que necesiten tus requests. La estructura:
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
vus: 10, // usuarios virtuales corriendo concurrentemente
duration: '30s', // cuánto tiempo corre el test
};
export default function () {
http.get('https://tu-api.example.com/api/products');
sleep(1); // tiempo de espera entre requests
}vus significa virtual users. Cada VU ejecuta la función default en un loop durante toda la duración, de forma independiente de los demás. Configurar vus: 10 y duration: '30s' significa 10 usuarios simulados golpeando tu endpoint continuamente durante 30 segundos.
La llamada sleep(1) no es opcional: modela el tiempo de espera que un usuario real pasaría entre acciones. Sin ella, cada VU dispara requests tan rápido como el servidor puede responder, lo cual es poco realista y produce números de throughput engañosamente altos. Un sleep de un segundo es un valor por defecto razonable; ajústalo para que coincida con el comportamiento real de tus usuarios.
Para requests POST con cuerpo JSON, que vas a necesitar para la mayoría del testing de API:
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
vus: 20,
duration: '1m',
};
export default function () {
const url = 'https://tu-api.example.com/api/login';
const payload = JSON.stringify({
username: 'usuario@example.com',
password: 'contraseña',
});
const params = {
headers: { 'Content-Type': 'application/json' },
};
http.post(url, payload, params);
sleep(1);
}Leer la salida de k6
Después de una ejecución, k6 imprime una tabla de resumen en el terminal. Aprender a leerla rápido es la habilidad que convierte números crudos en información accionable.
Una salida típica se ve así:
✓ checks.........................: 100.00% ✓ 1800 ✗ 0
data_received..................: 4.3 MB 143 kB/s
data_sent......................: 523 kB 17 kB/s
http_req_blocked...............: avg=1.2ms min=1µs med=4µs max=312ms p(90)=7µs p(95)=11µs
http_req_connecting............: avg=743µs min=0s med=0s max=181ms p(90)=0s p(95)=0s
http_req_duration..............: avg=312ms min=98ms med=280ms max=2.1s p(90)=520ms p(95)=640ms
{ expected_response:true }...: avg=312ms min=98ms med=280ms max=2.1s p(90)=520ms p(95)=640ms
http_req_failed................: 0.00% ✓ 0 ✗ 1800
http_req_receiving.............: avg=2.1ms min=45µs med=1.1ms max=74ms p(90)=4.2ms p(95)=5.8ms
http_req_sending...............: avg=178µs min=27µs med=136µs max=3.2ms p(90)=312µs p(95)=389µs
http_req_tls_handshaking.......: avg=412µs min=0s med=0s max=168ms p(90)=0s p(95)=0s
http_req_waiting...............: avg=310ms min=97ms med=278ms max=2.1s p(90)=518ms p(95)=637ms
http_reqs......................: 1800 60.0/s
iteration_duration.............: avg=1.31s min=1.1s med=1.28s max=3.1s p(90)=1.52s p(95)=1.64s
iterations.....................: 1800 60.0/s
vus............................: 10 min=10 max=10
vus_max........................: 10 min=10 max=10Las tres métricas que mirás primero son:
http_req_duration es el tiempo total de ida y vuelta para cada request. La columna p(95) te dice el percentil 95: el 95% de tus requests completaron dentro de ese tiempo. Este es el número que importa para los SLAs, no el promedio. Los promedios ocultan los outliers; el p95 no.
http_reqs muestra el total de requests hechos y la tasa por segundo. Esto te dice cuánta carga generaste realmente.
http_req_failed muestra el porcentaje de requests que recibieron una respuesta de error (4xx o 5xx). Cero por ciento es lo esperado; cualquier valor por encima requiere investigación.
Agregar checks
La corrección funcional bajo carga importa tanto como la velocidad. k6 tiene un mecanismo de aserciones integrado llamado checks que funciona de forma similar a las sentencias expect en otros frameworks de testing:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 10,
duration: '30s',
};
export default function () {
const res = http.get('https://tu-api.example.com/api/products');
check(res, {
'status es 200': (r) => r.status === 200,
'tiempo de respuesta < 500ms': (r) => r.timings.duration < 500,
'body contiene productos': (r) => r.body.includes('"id"'),
});
sleep(1);
}La diferencia importante respecto a las aserciones tradicionales: un check fallido no detiene el test. Todos los VUs siguen corriendo. k6 cuenta cuántos checks pasaron y fallaron en todas las iteraciones y muestra el porcentaje en el resumen. Esto es intencional. Quieres saber qué porcentaje de requests bajo carga produjo una respuesta correcta, no solo si un request falló.
Para un endpoint de login que devuelve un token, escribirías:
import http from 'k6/http';
import { check, sleep } from 'k6';
export default function () {
const payload = JSON.stringify({
username: 'usuario@example.com',
password: 'password123',
});
const res = http.post('https://tu-api.example.com/auth/login', payload, {
headers: { 'Content-Type': 'application/json' },
});
check(res, {
'login exitoso': (r) => r.status === 200,
'token presente': (r) => JSON.parse(r.body).token !== undefined,
'login suficientemente rápido': (r) => r.timings.duration < 1000,
});
sleep(1);
}Stages: ramp up, sostenimiento, ramp down
Una carga constante plana casi nunca es lo que quieres. El tráfico real sube a medida que llegan los usuarios, se mantiene en el pico y luego baja. k6 modela esto con stages, que te permiten definir cómo cambia el conteo de VUs a lo largo del tiempo:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 50 }, // subir a 50 VUs en 2 minutos
{ duration: '5m', target: 50 }, // mantener 50 VUs durante 5 minutos
{ duration: '2m', target: 100 }, // subir a 100 VUs en 2 minutos
{ duration: '5m', target: 100 }, // mantener 100 VUs durante 5 minutos
{ duration: '2m', target: 0 }, // bajar a 0
],
};
export default function () {
const res = http.get('https://tu-api.example.com/api/products');
check(res, {
'status es 200': (r) => r.status === 200,
'tiempo de respuesta OK': (r) => r.timings.duration < 1000,
});
sleep(1);
}Este patrón (subir, mantener, subir más, mantener, bajar) se llama perfil de stress test. Revela en qué conteo de usuarios tu sistema comienza a degradarse. Vas a ver el tiempo de respuesta p95 en tus resultados subir de forma constante a medida que aumentan los VUs. El punto de inflexión donde salta abruptamente es el punto de quiebre de tu sistema.
Para la mayoría del testing de API vas a ejecutar un test más simple de dos fases: ramp up y sostenimiento.
export const options = {
stages: [
{ duration: '1m', target: 50 }, // calentamiento
{ duration: '5m', target: 50 }, // carga sostenida
{ duration: '30s', target: 0 }, // ramp down
],
};La fase de ramp-down importa. Cortar la carga abruptamente puede enmascarar problemas con el connection pool y te da métricas más limpias al final del test.
Umbrales: hacer que el test pase o falle
Los checks te dicen qué pasó. Los umbrales determinan si la ejecución del test tiene éxito o falla. Un umbral es una condición de aprobación/rechazo en cualquier métrica. Si la condición se viola, k6 termina con código no cero. Ese es el gancho que hace que los load tests sean útiles en CI.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '1m', target: 50 },
{ duration: '5m', target: 50 },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<2000'], // el percentil 95 debe estar bajo 2 segundos
http_req_failed: ['rate<0.01'], // menos del 1% de requests puede fallar
checks: ['rate>0.99'], // más del 99% de los checks deben pasar
},
};
export default function () {
const res = http.get('https://tu-api.example.com/api/products');
check(res, {
'status es 200': (r) => r.status === 200,
'tiempo de respuesta < 2s': (r) => r.timings.duration < 2000,
});
sleep(1);
}Cuando k6 ejecuta este script y el tiempo de respuesta p95 supera 2000ms, la ejecución termina con código 99. Tu step de CI falla. Ese es el comportamiento correcto. Un build que despacha código que hace tu API un 40% más lenta debería fallar.
Puedes definir umbrales por endpoint usando tags:
export default function () {
const loginRes = http.post(
'https://tu-api.example.com/auth/login',
JSON.stringify({ username: 'usuario@example.com', password: 'pass' }),
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
);
const productsRes = http.get(
'https://tu-api.example.com/api/products',
{ tags: { name: 'products' } }
);
}export const options = {
thresholds: {
'http_req_duration{name:login}': ['p(95)<1000'],
'http_req_duration{name:products}': ['p(95)<500'],
},
};El login se espera que sea más lento que una lista de productos cacheada, así que le das a cada endpoint su propio SLA.
Ejecutar k6 en CI con GitHub Actions
k6 corre limpiamente en GitHub Actions. El equipo de Grafana publica una action oficial que instala el binario y maneja el pinning de versión:
# .github/workflows/load-test.yml
name: Load Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
load-test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Instalar k6
uses: grafana/setup-k6-action@v1
with:
k6-version: '0.55.0'
- name: Ejecutar load test
run: k6 run load-test.js
env:
BASE_URL: ${{ vars.BASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
- name: Subir resultados de k6
uses: actions/upload-artifact@v4
if: always()
with:
name: k6-results
path: k6-results.json
retention-days: 14Para usar variables de entorno dentro de tu script de k6, accédelas a través de __ENV:
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export const options = {
stages: [
{ duration: '1m', target: 20 },
{ duration: '3m', target: 20 },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<2000'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const res = http.get(`${BASE_URL}/api/products`);
check(res, {
'status es 200': (r) => r.status === 200,
'tiempo de respuesta aceptable': (r) => r.timings.duration < 2000,
});
sleep(1);
}Para generar un archivo JSON de resultados para el upload del artefacto, pasá el flag --out:
k6 run --out json=k6-results.json load-test.jsUna recomendación práctica: no ejecutes load tests en cada pull request contra tu entorno de producción o staging compartido. El job de load test debería correr en merges a main, o dispararse manualmente vía workflow_dispatch. Ejecutar load tests pesados en cada PR genera interferencia entre ejecuciones concurrentes. Un test a nivel smoke (5 VUs, 30 segundos) en PRs es aceptable; un load test completo no.
Qué endpoints testear
Elegir qué testear con k6 es tan importante como el test en sí. No todos los endpoints necesitan un load test. Enfócate en los que importan en términos de performance.
El endpoint de login es el primer test que escribir. Cada sesión de usuario empieza acá. Un login lento multiplicado por 500 usuarios concurrentes destruye la experiencia antes de que alguien llegue a las funcionalidades reales. Testea el flujo completo de login: POST de credenciales, recibir un token, hacer un request autenticado.import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export const options = {
stages: [
{ duration: '1m', target: 50 },
{ duration: '5m', target: 50 },
{ duration: '1m', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<1500'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
// Paso 1: Login
const loginRes = http.post(
`${BASE_URL}/auth/login`,
JSON.stringify({ username: 'loadtest@example.com', password: 'passloadtest' }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(loginRes, {
'login status 200': (r) => r.status === 200,
'token devuelto': (r) => JSON.parse(r.body).token !== undefined,
});
const token = JSON.parse(loginRes.body).token;
// Paso 2: Request autenticado
const profileRes = http.get(`${BASE_URL}/api/me`, {
headers: { Authorization: `Bearer ${token}` },
});
check(profileRes, {
'perfil status 200': (r) => r.status === 200,
});
sleep(2);
}Un punto de partida razonable para la mayoría de las aplicaciones es tres scripts de load test: uno para autenticación, uno para los tres principales endpoints de lectura en un solo script, y uno para la operación de escritura crítica para la conversión. Con eso es suficiente cobertura para detectar las regresiones de performance que importan sin construir una suite completa de performance testing antes de tener una línea base.
FAQ
¿Necesito un entorno de test separado para ejecutar k6?Necesitás un entorno que pueda absorber la carga sin afectar a usuarios reales ni corromper datos de producción. Un entorno dedicado para load testing es lo ideal. Si solo tenés staging, ejecutá los load tests fuera del horario laboral y asegurate de que tus datos de prueba estén aislados. Nunca apuntes k6 a producción con conteos altos de VUs.
¿Cuántos VUs debo usar?Empieza con un número que represente el tráfico pico realista, no tu máximo teórico. Si tu aplicación tiene 50 usuarios concurrentes en un día ocupado, testea con 50 a 100. Encontrar qué pasa con 1.000 VUs importa menos que saber que tu sistema maneja la carga pico normal con comodidad.
Mi test de k6 pasa pero la aplicación se siente lenta. ¿Por qué?k6 mide el tiempo de respuesta HTTP, que incluye el procesamiento del servidor pero excluye el renderizado del cliente. Un endpoint de API que responde en 200ms puede seguir sintiéndose lento si el frontend hace 20 llamadas secuenciales para poblar una página. Usá k6 para performance a nivel de API; usá herramientas de profiling del navegador para el performance percibido de la página.
¿Puede k6 testear endpoints WebSocket o gRPC?Sí. k6 tiene soporte integrado para WebSockets a través del módulo k6/ws y para gRPC a través del módulo k6/net/grpc. El modelo de scripting es el mismo; solo difiere la API específica del protocolo.
El archivo JSON de salida de --out json se puede importar a Grafana para visualización. Si tu equipo usa Grafana Cloud, k6 tiene integración nativa que transmite resultados en tiempo real y los almacena para comparación entre ejecuciones. Para equipos sin Grafana, la tabla de resumen del terminal copiada en un comentario de PR o en Slack es suficiente para comunicar el pase/fallo y los percentiles clave.
El load testing verifica que tu sistema cumple los requisitos de performance en los niveles de tráfico esperados. El stress testing empuja más allá de los niveles esperados para encontrar el punto de quiebre. La configuración de stages maneja los dos: un load test se mantiene en un conteo de VUs objetivo, un stress test sigue subiendo hasta que los checks fallan.
→ See also: Fundamentos del Testing de Rendimiento: Load, Stress y Spike Testing | API Testing 101: Todo lo que Todo QA Engineer Necesita Saber en 2026 | CI/CD para QA: GitHub Actions, Jenkins y GitLab Comparados