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 k6

En 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 k6

En Windows, usa el instalador oficial desde k6.io/docs/get-started/installation o instala vía Chocolatey:

choco install k6

Una vez instalado, verifica que funciona:

k6 version

Ahora 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.js

k6 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.

Los usuarios virtuales de k6 no son threads ni procesos: son goroutines livianas. Una sola instancia de k6 puede ejecutar miles de VUs de forma realista sin consumir una cantidad proporcional de memoria o CPU. No necesitás un cluster para ejecutar un load test útil.

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=10

Las 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ó.

Usa checks para todo lo que normalmente asercionarías en un test funcional: código de estado, tiempo de respuesta, presencia de campos esperados en el cuerpo de la respuesta. La tasa de éxito de los checks en el resumen te da una señal de salud combinada a través de miles de requests.

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.

Los umbrales se evalúan de forma continua durante el test, no solo al final. Si tu p95 supera el límite al inicio de la ejecución y se recupera después, el umbral igual se marca como fallido. Esto es intencional. Un sistema que se degrada y se recupera puede estar ocultando un problema de capacidad que necesitás investigar.

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: 14

Para 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.js

Una 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);
}

Los endpoints de lectura críticos son la segunda prioridad. Listado de productos, resultados de búsqueda, datos del dashboard: todo lo que los usuarios acceden repetidamente y que llega a la base de datos. Estos son los endpoints más propensos a mostrar degradación a escala porque agregan datos de múltiples filas o tablas. El flujo de checkout o envío de órdenes es el tercero. Este es el endpoint donde el performance lento tiene impacto directo en los ingresos. Testéalo con un conteo de VUs más bajo que los endpoints de lectura (la concurrencia real de checkout es menor que la de navegación) pero mantenele un umbral de tiempo de respuesta más estricto.

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.

¿Cómo comparto los resultados de k6 con mi equipo?

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.

¿Cuál es la diferencia entre un load test y un stress test?

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