Saltar al contenido principal

Prompts para Load Testing con k6 e IA: Probar Antes de Production

8 prompts para crear tests de carga con k6 usando IA: escenarios de tráfico, umbrales, ramp-up, estrés y análisis de resultados. Con código real.

Fran Cobos 27 min de lectura 5368 palabras

Tabla de contenidos

El aviso llega a las 19:40 de un viernes. Un post de Reddit y una newsletter bien colocada han mandado 4.000 usuarios a tu landing en veinte minutos. Tu servidor aguanta, pero la API de checkout tarda once segundos y algunos usuarios ya han pagado dos veces porque el botón no deshabilita el estado de carga.

Nadie lo probó antes porque “en local va bien” y porque escribir un test de carga desde cero da pereza. Hace falta un fichero, imports que te suenan a medias y unos umbrales que no sabes decidir. Eso son veinte minutos de bloqueo que se convierten en un_test de dos horas, o en ninguno.

La buena noticia es que los tests de carga son uno de los mejores clientes de IA que existen. La estructura es repetitiva, los umbrales tienen reglas, y el modelo ha visto miles de ejemplos de k6 en abierto. La mala noticia es que un test de carga mal generado es peor que no tener ninguno: te da confianza falsa y consume recursos de tu servidor.

Antes de escribir nada: elige la herramienta

No todas las herramientas de load testing hacen lo mismo, y elegir mal cuesta semanas.

HerramientaLenguajeCurvaCuándo elegirla
k6JavaScriptBajaRecomendación por defecto. Scripts versionables, CI nativo, buen soporte de APIs
ArtilleryJavaScript / YAMLMediaSi quieres Behaviour-Driven Testing con YAML y fixtures
LocustPythonMediaSi tu equipo es Python y los escenarios son muy dinámicos
JMeterJava / XMLAltaInterfaces muy complejas, necesitas el grabador, o protocol poco habitual
GatlingScala / JavaMedia-altaInformes muy ricos y comparativas entre versiones
VegetaGoMuy bajaCargas simples y masivas contra un único endpoint

Para un proyecto fullstack JavaScript, k6 gana. El script es un módulo ES, puedes importar tu cliente HTTP real, y el IDE te autocompleta. Escribo el resto asumiendo k6.

Reglas de oro antes de generar nada

  1. Los VUs salen de tus datos, no del aire. Un test con 50 VUs cuando tu pico real es 900 es un test que pasa siempre y no encuentra nada. Da al modelo tu p95 de requests concurrentes desde analytics.
  2. Los umbrales se escriben antes que los VUs. Los thresholds son el contrato del test: describen qué es aceptable. Si los escribes al final, los ajustas para que pase, y eso invalidó el test entero.
  3. Nunca apuntes a producción sin avisar. Un test de carga mal calibrado es un DDoS que tú te haces a ti mismo. Staging o un entorno con datos anonimizados.
  4. Un escenario, una variable. Si mezclas browse, checkout y login en un solo default, cuando falle no sabes qué parte de la app se rompió.
  5. Incluye siempre la parte de análisis. El 70% del valor de un test de carga está en qué preguntas le haces al modelo sobre los resultados, no en el script.

1. Traducir tu tráfico real a un escenario de k6

Este es el prompt que más valor da por segundo invertido. Sin él, todo lo demás es fiction.

Voy a testear con k6 una app con estas características:

App: [nombre y qué hace]
Infraestructura: [K8s con 3 réplicas / 1 VPS con Coolify y PM2 / Vercel functions / Railway]
Perfil de tráfico real (de analytics de los últimos 30 días):
- Requests concurrentes p95 en horario punta: [X]
- Picos registrados: [nº máximo, fecha]
- Rutas más usadas y su proporción:
  GET  /api/products          [n]% de requests
  GET  /api/products/:id      [n]%
  POST /api/checkout          [n]%
  POST /api/auth/login        [n]%
- Estacionalidad: [pico de lunes a las 10h / campaña puntual / crecimiento constante]

Objetivo del test: [saber si aguanto un pico de 5000 usuarios / validar un SLA de
  p95 < 400ms en /api/products / encontrar el punto de saturación]

Escribe un escenario k6 con el executor `ramping-arrival-rate` que replique ESTE tráfico.
Requisitos:
- El ratio entre rutas debe ser el que te he dado, no uniforme
- `preAllocatedVUs` calculado a partir de la latencia media que observamos ([X] ms),
  no un número inventado
- `maxVUs` con margen para picos sin provocar "VU limit reached"
- Comentarios explicando cada decisión del número

Antes del código, explícame tu razonamiento: por qué ese executor y no `ramping-vus`.

Por qué funciona: ramping-arrival-rate mide la tasa de llegada de usuarios, no el número de VUs, que es lo que importa cuando la latencia sube. Con ramping-vus el test se autoengaña: si el servidor va lento, llegan menos requests y el test pasa.

Ejemplo real de salida:

import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend } from 'k6/metrics';

const errorRate = new Rate('api_errors');
const checkoutDuration = new Trend('checkout_duration', true);

export const options = {
  scenarios: {
    browsing: {
      executor: 'ramping-arrival-rate',
      exec: 'browsingFlow',
      startRate: 20,
      timeUnit: '1s',
      preAllocatedVUs: 120,   // latencia media 380ms × 50 RPS × 1,5 margen
      maxVUs: 400,
      stages: [
        { target: 50, duration: '3m' },   // subida al pico de mañana
        { target: 50, duration: '10m' },  // steady state: lo que aguanto hoy
        { target: 200, duration: '45s' }, // pico por campaña: quiero saber si sobrevivo
        { target: 50, duration: '2m' },   // recuperación
      ],
    },
  },
};

const BASE = __ENV.BASE_URL || 'http://localhost:3000';

export function browsingFlow() {
  group('catálogo', () => {
    const res = http.get(`${BASE}/api/products`, {
      tags: { route: 'GET /api/products' },
      headers: { Accept: 'application/json' },
    });
    errorRate.add(res.status !== 200);
    check(res, {
      'catálogo 200': (r) => r.status === 200,
      'catálogo < 400ms': (r) => r.timings.duration < 400,
    });

    const detail = http.get(`${BASE}/api/products/${Math.floor(Math.random() * 500)}`, {
      tags: { route: 'GET /api/products/:id' },
    });
    check(detail, { 'detalle 200': (r) => r.status === 200 });
  });

  sleep(Math.random() * 2 + 1);
}

2. Escribir los thresholds: el contrato del test

Aquí es donde los equipos se lavikarian. Si el threshold está mal puesto, el test pasa siempre o falla siempre. Y las dos cosas son peores que no testear.

Estos son los SLA reales que tenemos pactados con el negocio para [nombre de la app]:

- GET /api/products:     p95 < 400ms, p99 < 900ms, error rate < 0,5%
- GET /api/products/:id: p95 < 250ms, p99 < 600ms, error rate < 0,5%
- POST /api/checkout:    p95 < 1200ms, p99 < 2500ms, error rate < 1%
- POST /api/auth/login:  p95 < 600ms, error rate < 0,1% (es crítico, login fallido = usuario perdido)

Datos del último mes que nos dio el APM:
- p95 real de /api/products: [380] ms
- p95 real de /api/checkout: [980] ms
- Tasa de error global: [0,3]%

Escribe el bloque `thresholds` de k6 para estos cuatro endpoints, con los valores del SLA
de negocio, NO los de APM (quiero que el test detecte cuando nos alejamos del SLA).

Además:
1. Métricas personalizadas para que el threshold se aplique SOLO a una ruta usando `tags`
2. Un threshold para `http_req_duration` global como red de seguridad
3. Un threshold de `checks` que ensure que al menos el 99% de los checks pasan
4. Un threshold para el tiempo de ejecución, para que un test que se cuelga no espere 2 horas
5. Un threshold para "dropo de usuarios": `vus` dropped > 0 debe fallar el test

Explica por qué cada threshold y no solo lo escribas.

Por qué funciona: separar el SLA pactado del dato observado es lo que convierte el test en un detector de regresiones de rendimiento. Si pones el p95 de APM como threshold, el test pasa hasta que degrades tanto como degradabas el día que lo mediste.

Ejemplo real:

import { Rate, Trend } from 'k6/metrics';

const errorRate = new Rate('api_errors');
const checkoutDuration = new Trend('checkout_duration', true);

export const options = {
  thresholds: {
    // Globales: red de seguridad
    http_req_duration: ['p(95)<600', 'p(99)<1500'],
    http_req_failed: ['rate<0.01'],
    checks: ['rate>0.99'],
    dropped_iterations: ['count==0'],

    // Por ruta, vía tags: el SLA de negocio manda sobre el dato de APM
    'http_req_duration{route:GET-products}': ['p(95)<400', 'p(99)<900'],
    'http_req_duration{route:GET-product-id}': ['p(95)<250', 'p(99)<600'],
    'http_req_duration{route:POST-checkout}': ['p(95)<1200', 'p(99)<2500'],
    'http_req_duration{route:POST-auth-login}': ['p(95)<600'],
    'api_errors': ['rate<0.005'],
    'checkout_duration': ['p(95)<1200'],
  },
};

3. Modelar los tres escenarios: ramp-up, steady state y spike

El error más común es hacer un solo escenario. Pero cada uno responde a una pregunta distinta.

Necesito tres escenarios k6 para [app], cada uno en su propio fichero, para responder
a tres preguntas distintas:

1. RAMP-UP: ¿cuántos usuarios concurrentes aguanto antes de que la latencia se degrade?
   → Executor `ramping-vus`, subida progresiva hasta que el threshold falle.
     Te lo quiero automatizado: usa `gracefulStop` y deja que k6_abort mediante
     `handleSummary` corte el test cuando el p95 supere el umbral.

2. STRESS: ¿cuál es mi punto de saturación real?
   → Subir hasta romper, y luego bajar. Quiero ver la curva completa, no solo el techo.
     Añade una fase de recuperación para comprobar que el sistema se recupera solo
     (pool de conexiones, memoria, locks).

3. SOAK: ¿haymemory leak o degradación a lo largo del tiempo?
   → RPS constante bajo durante 2 horas. Si la latencia sube en la segunda hora,
     tenemos un problema que ningún test corto va a encontrar.

Dame los tres ficheros completos, con sus options, su handleSummary para exportar
JSON, y el criterio de "este test ha sido un éxito" escrito como frase.

Por qué funciona: separar los tres y darle a cada uno su pregunta concreta evita el “un test que lo hace todo”, que no falla nunca porque los thresholds son demasiado laxos. El soak test en particular es el único que detecta fugas de memoria y conexiones que se agotan.

Ejemplo real: el ramp-up con abort automático.

import http from 'k6/http';
import { check, sleep } from 'k6';
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.1.0/index.js';

const BASE = __ENV.BASE_URL || 'http://localhost:3000';
const P95_LIMIT = 400;

export const options = {
  scenarios: {
    rampa: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '1m', target: 50 },
        { duration: '2m', target: 100 },
        { duration: '3m', target: 200 },
        { duration: '5m', target: 300 },
        { duration: '5m', target: 400 },
        { duration: '5m', target: 600 },
      ],
      gracefulRampDown: '30s',
    },
  },
  thresholds: {
    http_req_duration: [`p(95)<${P95_LIMIT}`],   // si falla, el test "pasa" el tiempo y sale rojo
    http_req_failed: ['rate<0.01'],
  },
};

export function default() {
  const res = http.get(`${BASE}/api/products`, { tags: { route: 'GET-products' } });
  check(res, { 'ok': (r) => r.status === 200 });
  sleep(1);
}

// Corta el test en cuanto el p95 revienta el umbral, para no esperar 26 minutos
export function handleSummary(data) {
  const p95 = data.metrics.http_req_duration
    ? data.metrics.http_req_duration.values['p(95)']
    : 0;

  const aborted = p95 > P95_LIMIT;

  return {
    stdout: textSummary(data, { indent: '  ', enableColors: true }) +
      (aborted ? `\n\nPUNTO DE SATURACIÓN ENCONTRADO: p95 = ${p95.toFixed(0)}ms\n` : '\n\nNo saturado.\n'),
    'results/saturation.json': JSON.stringify(data),
    'results/saturation-summary.json': JSON.stringify(
      { p95, aborted, iterations: data.metrics.iterations?.values?.count },
      null, 2,
    ),
  };
}

4. Stress testing contra soak testing: cuándo cada uno

No es lo mismo “que aguante un pico” que “que aguante una semana”. Escribe el prompt para que la IA te obligue a decidir.

Tengo un SaaS B2B con [nº] clientes, [nº] medio de usuarios concurrentes.
Modelo de uso: [lotes por la mañana entre 9 y 11 / uso constante 8h / turnos de lunes a viernes]
La Infraestructura escala horizontalmente, pero la base de datos es un Postgres único
de [especificaciones] detrás de PgBouncer.

Explícame la diferencia operativa entre un stress test y un soak test aplicado a MI caso,
no en abstracto:

1. ¿Cuánto tiempo debe durar cada uno y por qué ese número concreto para mi caso?
2. ¿Qué fallo Busco cada uno? Quiero los nombres reales de los fallos que aparecen en
   la hora 30 que no aparecen en el minuto 5 (fugas, locks, conexión exhaustion...)
3. ¿Qué monitoreo necesito para que el soak test sea útil? (CPU, memoria RSS del proceso,
   conexiones abiertas en PG, tamaño de la WAL...)
4. ¿Qué dato del soak test es el que delata una fuga? Dame el número concreto y el umbral
   a partir del cual hay que parar
5. ¿Cuándo se considera que la app HA FALLADO el soak test, más allá de los thresholds?

Por qué funciona: “qué fallo busco en la hora 30 que no aparece en el minuto 5” fuerza una respuesta específica sobre degradación a largo plazo. Y el punto 4 evita la respuesta vaga de “cuando los números empeoren”.

5. Testear una API autenticada con tokens

Todo test de carga contra una API con auth tiene un problema: si cada VU hace login en cada iteración, mides tu endpoint de autenticación, no el que quieres. Y si el token caduca a los 15 minutos, el test se rompe a mitad.

Quiero un test k6 de mi API que requiere autenticación JWT.

Detalle de mi setup:
- El login es POST /api/auth/login con {email, password} y devuelve
  {accessToken (caduca en 15min), refreshToken (caduca en 30días)}
- El accessToken va en la cabecera Authorization: Bearer
- Los endpoints a testar: GET /api/invoices, POST /api/invoices

Problemas que quiero resolver:
1. Cómo hacer login UNA VEZ en `setup()` y reutilizar el token en todos los VUs,
   en vez de 3000 logins que saturan el endpoint de auth
2. Cómo refrescar el token si el test dura más de 15 minutos, sin que cada VU lo haga
   a la vez (thundering herd sobre el endpoint de refresh)
3. Cómo generar datos de prueba realistas (n usuarios distintos, facturas distintas)
   en setup(), también UNA VEZ
4. Que el tenant_id del token no se mezcle con el de otro VU (posible fuga de datos)

Dame el fichero completo con: setup(), teardown(), función de test, y los thresholds.
En setup() te paso el array de N emails y contraseñas de test.

Por qué funciona: separar el login del test medido es lo que hace que los números signifiquen algo. Y el punto 4 (aislamiento de tenant) es un fallo real que aparece en los tests de carga multitenancy: el pool de tokens se cruza y un VU lee datos de otro.

Ejemplo real:

import http from 'k6/http';
import { check, sleep } from 'k6';
import { Counter } from 'k6/metrics';
import exec from 'k6/execution';

const tokenRefreshes = new Counter('token_refreshes');
const BASE = __ENV.BASE_URL || 'http://localhost:3000';

// Un token compartido, cacheado en el contexto deVU
let TOKEN = null;
let TOKEN_EXPIRES_AT = 0;

export function setup() {
  const logins = [];
  for (let i = 0; i < 100; i++) {
    const res = http.post(
      `${BASE}/api/auth/login`,
      JSON.stringify({ email: `load-user-${i}@test.com`, password: 'TestPassword123!' }),
      { headers: { 'Content-Type': 'application/json' } },
    );
    if (res.status === 200) {
      const body = res.json();
      logins.push({
        accessToken: body.accessToken,
        refreshToken: body.refreshToken,
        tenantId: body.user.tenantId,
        expiresAt: Date.now() + 15 * 60 * 1000,   // 15 min
      });
    }
  }
  console.log(`Setup: ${logins.length} tokens de usuario obtenidos`);
  return { logins };
}

export function teardown(data) {
  console.log(`Teardown: ${data.logins.length} sesiones creadas`);
}

export default function (data) {
  const idx = exec.scenario.iterationInTest % data.logins.length;
  let session = data.logins[idx];

  // Refresco de token solo si está a punto de caducar, y con jitter para no
  // martillear el endpoint de refresh a la vez
  if (Date.now() > session.expiresAt - 60000) {
    const res = http.post(
      `${BASE}/api/auth/refresh`,
      JSON.stringify({ refreshToken: session.refreshToken }),
      { headers: { 'Content-Type': 'application/json' } },
    );
    if (res.status === 200) {
      session.accessToken = res.json('accessToken');
      session.expiresAt = Date.now() + 15 * 60 * 1000;
      tokenRefreshes.add(1);
    }
    sleep(Math.random() * 5);   // dispersa los refrescos
  }

  const res = http.get(`${BASE}/api/invoices`, {
    headers: { Authorization: `Bearer ${session.accessToken}`, 'X-Tenant-Id': session.tenantId },
    tags: { route: 'GET-invoices' },
  });

  check(res, {
    'invoices 200': (r) => r.status === 200,
    'no devuelve invoices de otro tenant': (r) => {
      if (r.status !== 200) return true;
      const invoices = r.json('invoices') || [];
      return invoices.every((inv) => inv.tenantId === session.tenantId);
    },
  });
}

6. Usar k6 browser para testear la interfaz

El módulo k6/browser abre Chromium de verdad. Es caro, pero es el único modo de medir Core Web Vitals bajo carga.

Quiero medir el rendimiento real del frontend bajo carga con k6 browser, no solo la API.

App: [Next.js / Astro], ruta a testear: /catalogo
Objetivo: 200 usuarios concurrently abriendo la página y navigating a un producto

Dame un script k6 con k6/browser que:
1. Navegue a la URL con `page.goto` y mida el tiempo hasta que el elemento principal
   sea visible (LCP aproximado)
2. Mida INP disparando un click y esperando el siguiente frame
3. Mida CLS leyendo `PerformanceObserver` de layout-shift dentro de la página
4. Capture screenshots de un VU concreto para comparar visualmente con una ejecución previa
5. Verifique que los Core Web Vitals están dentro de umbral, con `check` y no solo con logs

Con las limitaciones reales: sé consciente de que k6 browser es mucho más pesado
que el test HTTP. Dime cuántos VU puedo levantar por máquina y qué presupuesto de
CPU necesito, y propón cómo partir el test en fases si no llega.

Incluye el handleSummary para exportar las métricas como JSON para comparar entre
ejecuciones y detectar regresiones de rendimiento.

Por qué funciona: el modelo conoce las APIs de Playwright que k6 reexporta, y el performance.now() dentro de page.evaluate es lo que te da las métricas sin depender de lighthouse en cada iteración.

Ejemplo real:

import { browser } from 'k6/browser';
import { check } from 'k6';
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.1.0/index.js';

const BASE = __ENV.BASE_URL || 'http://localhost:3000';

export const options = {
  scenarios: {
    browser: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '30s', target: 5 },
        { duration: '2m', target: 20 },
        { duration: '3m', target: 20 },
      ],
      options: { browser: { type: 'chromium' } },
    },
  },
  thresholds: {
    'checks': ['rate>0.95'],
  },
};

export default async function () {
  const page = await browser.newPage();

  const metrics = await page.evaluate(async () => {
    return await new Promise((resolve) => {
      let lcp = 0, cls = 0;
      new PerformanceObserver((list) => {
        for (const e of list.getEntries()) lcp = e.startTime;
      }).observe({ type: 'largest-contentful-paint', buffered: true });

      new PerformanceObserver((list) => {
        for (const e of list.getEntries()) if (!e.hadRecentInput) cls += e.value;
      }).observe({ type: 'layout-shift', buffered: true });

      setTimeout(() => resolve({ lcp, cls }), 500);
    });
  });

  await page.goto(`${BASE}/catalogo`, { waitUntil: 'networkidle' });

  check(page, {
    'LCP < 2500ms': () => metrics.lcp < 2500,
    'CLS < 0.1': () => metrics.cls < 0.1,
    'el listado aparece': async () => (await page.locator('[data-testid="producto-list"]').count()) > 0,
  });

  await page.close();
}

export function handleSummary(data) {
  return {
    stdout: textSummary(data, { indent: '  ', enableColors: true }),
    'results/browser.json': JSON.stringify(data),
  };
}

7. Analizar los resultados de k6 con IA

Ya has ejecutado el test. Tienes un JSON con 400 métricas. ¿Y ahora qué? Aquí es donde un buen prompt marca la diferencia entre “el test pasó” y “sé por qué pasó”.

Voy a pegarte el JSON de salida de k6 (`k6 run --out json=results.json`). Contexto de la prueba:

Escenario: [descripción], carga: [X RPS], duración: [Y min]
Thresholds definidos: [lista]
Entorno: [staging con datos de producción anonimizados, 3 réplicas, Postgres r6g.xlarge]
Monitoreo durante la prueba: [CPU al 60% de media, memoria 4GB de 8GB, conexiones PG 80 de 100]
Resultado: [los thresholds que fallaron y cuáles pasaron]

Analiza el JSON y respóndeme:

1. ¿Dónde está el cuello de botella? Distingue entre: saturación de la app, saturación
   de la base de datos, saturación de red, o insuficiente de VUs en el test
2. ¿La latencia viene de esperar en cola (muchas peticiones concurrentes esperando)
   o de que cada petición tarda más? Son problemas con solución completamente distinta
3. Correlaciona con el monitoreo: si CPU estaba al 60% y la latencia subió, el problema
   NO es CPU. ¿Qué explica entonces?
4. ¿Los errores son timeouts, errores 5xx, o rejections del balanceador? Cada uno apunta a
   una capa distinta
5. ¿El p99 se dispara mientras el p95 se mantiene? Eso suele ser GC, thundering herd,
   o una dependencia externa lenta
6. Si tuvieras que apostar por UN cambio que másseudopuede mejorar, ¿cuál sería?
   Con el porcentaje estimado de mejora.

Cita las métricas concretas del JSON que soportan cada conclusión.

Por qué funciona: la distinción entre “la petición tarda más” y “la petición espera en cola” es la pregunta que separa un diagnóstico útil de una lista deloquent. Un p95 estable con p99 disparado es casi siempre GC o una dependencia externa, y eso no se arregla escalando.

8. Convertir los resultados en un informe que un jefe entienda

El test no lo ejecutas tú solo. Y si el informe sale en jerga de percentiles, no sirve para decidir si invertimos en un clúster más grande.

Tengo estos resultados de un load test de k6 sobre [app]. Escribe un informe de una página
para enviar al CTO y al responsable de producto. Sin jerga técnica innecesaria, pero sin
mentir sobre los datos.

Datos:
- Carga: [X] usuarios concurrentes sostenidos durante [Y] minutos
- Resultado: [pasa / falla] los umbrales de [lista]
- Latencia p50/p95/p99 en la ruta crítica: [valores]
- Tasa de error: [valor]% ([tipo de error])
- Coste estimado de infraestructura durante la prueba: [X]€ en [Y] horas
- Coste mensual actual: [Z]€
- Pico real de tráfico de los últimos 3 meses: [nº]

Incluye:
1. Una frase de veredicto: ¿aguantamos, aguantamos conJusto margen, o no aguantamos?
2. Cuántos usuarios concurrentes reales podemos sostener con la infra actual
3. Qué pasaría con un pico de campaña de [X] usuarios: sí o no, y qué se rompería primero
4. El coste de la opción de escalar, comparada con el coste de optimisear
5. Qué decisión pido: presupuesto, tiempo de ingeniería, o ninguno
6. Los tres datos que NO tenemos y que me convendría medir antes de decidir

Nada de "se recomienda monitorizar" ni "hay mejoras de rendimiento posibles".
Una recomendación concreta con número.

Por qué funciona: el prompt prohíbe explícitamente las fórmulas vacías. “Se recomienda monitorizar” es exactamente lo que sale cuando le pides un informe a un modelo sin exigir datos. Y el punto 6 es brillante: te recuerda qué no sabes.

9. Load testing contra la UI con Playwright cuando k6 se queda corto

Automatizar esto con un prompt que compara herramientas.

Tengo una app [Next.js + PostgreSQL + Redis en Coolify] y necesito load testing. Tengo
considerado k6 pero me planteo otras opciones. Compara k6, Artillery, Locust y Playwright
para MI caso concreto:

- Quiero testear sobre todo APIs REST, con un 20% de flujo de UI al final
- Necesito simular entre 500 y 5000 usuarios concurrentes
- El servidor es un VPS de 16GB con Coolify; no tengo un clúster de pruebas
- CI en GitHub Actions, presupuesto de 10 minutos por pipeline
- Unaplicación con la que la gente ya sabe dehouse

Dime:
1. Cuál de las cuatro encaja y por qué, con los números de consumo de recursos por VU
2. Cuántas máquinas necesito para 2000 VUs concurrentes con la opción que elijas
3. Qué NO puedo hacer con la opción que elijas (sé honesto con las limitaciones)
4. El plan de migración: cómo paso de [mi situación actual] a tener un load test en CI
   en 3 pasos, con el fichero que escribo en cada uno
5. La alternativa: si Playwright no sirve para carga, ¿debería combinar Playwright para
   la parte de UI y otra cosa para la parte de API?

10. Detectar el punto de saturación con una rampa automática

Combinar los prompts 1 y 3 para conseguir el número que todo el mundo pregunta: “¿cuántos usuarios aguanto?”.

Necesito automatizar la respuesta a: "¿cuántos usuarios concurrentes aguanta [app]
antes de que el p95 de /api/checkout supere los 1200ms?"

Escribe un script k6 que:
1. Suba los VUs en escalones de 50 cada 60 segundos
2. En cada escalón, mida el p95 del tramo
3. Corte automáticamente en cuanto el p95 supere el umbral, sin esperar a terminar la rampa
4. Devuelva en el handleSummary una tabla legible con: VUs | p50 | p95 | p99 | err% | throughput

Quiero que el script sea unExperiment reproducible: mismo output, mismos números,
para poder comparar la infra antes y después de una optimización.

Y el prompt de análisis que usaré después con el JSON:

[pega aquí los resultados del test]

Dime el punto exacto de saturación, cuál fue el último escalón que pasó, y qué
métrica se rompió primero (latencia, errores, o throughput que deja de crecer).

Por qué funciona: la clave es el corte automático. Un ramp-up de 20 minutos donde el servidor lleva 15 caudado no te da información útil: te da una curva plana después del colapso, que es el comportamiento por defecto de cualquier sistema saturado.

Anti-patrones

1. Ejecutar contra producción diciendo “solo un par de usuarios”

❌ scenarios: { prod: { executor: 'constant-vus', vus: 200, duration: '5m' } }   // contra producción

Un constant-vus con la latencia alta puede disparar un consumo de conexiones y tirar el servicio. Tienes un staging con datos anonimizados, úsalo.

✅ BASE_URL se pasa por variable de entorno y el CI apunta SIEMPRE a staging:
   k6 run -e BASE_URL=https://staging.tudominio.com load-test.js
   # Para producción: requiere --tags production=true y una ventana de mantenimiento

2. Thresholds que pasan porque son laxos

❌ thresholds: { http_req_duration: ['p(95)<5000'] }   // "nunca falla, siempre verde"

Un threshold que nunca falla es peor que ninguno: te da un falso verde en el panel durante meses.

✅ El threshold se escribe con el SLA pactado, ANTES de ejecutar:
   'http_req_duration{route:POST-checkout}': ['p(95)<1200']
   Si falla, documentas por qué. Si lo subes, dejas escrito por qué.

3. Usar constant-vus cuando el tráfico es variable

❌ executor: 'constant-vus', vus: 100, duration: '10m'

constant-vus mantiene 100 VUs ocupados. Si cada VU tarda 3 segundos en responder, solo haces 33 RPS. Si baja a 200ms, haces 500 RPS. La carga que aplastas es distinta en cada tramo y no sabes cuál.

✅ executor: 'ramping-arrival-rate' o 'constant-arrival-rate'
   → 100 RPS constantes, llegue la latencia que llegue. Si el sistema se degrada,
     el número de VUs sube automáticamente para mantener la tasa. Ahí es donde ves el problema.

4. Medir la API sin medir la UI

❌ Script k6 HTTP contra /api/products → p95 de 180ms → "todo bien"
✅ Script k6 HTTP + script k6 browser para la ruta crítica

Una API puede responder en 180ms y una página renderizar en 6 segundos. Si solo mides la API, tu LCP sigue roto y tu test lo aprueba.

5. Mirar el p50 en lugar del p95

❌ "La latencia media es de 120ms, vamos bien"
✅ "p50 = 85ms, p95 = 390ms, p99 = 2400ms"  → el p99 es donde viven tus usuariosavensarios

El p50 esconde a los usuarios con mala conexión, que son los que ponen una reseña de una estrella y cancelan la suscripción.

6. Ejecutar el test una sola vez

❌ k6 run load-test.js   →  "ha ido bien"

Un único resultado no te dice nada. Las máquinas tienen ruido. Necesitas al menos tres ejecuciones para ver si los números se sostienen, y comparar contra la ejecución anterior para detectar degradación.

✅ Guardar el JSON de cada ejecución con fecha y commit hash, y comparar con el anterior.
   Es la base para un gate en CI: si el p95 sube más de un 15%, el pipeline falla.

Errores frecuentes al empezar con k6

**“VEO vus: [1] [12345/0: 00:00<00:00] 0.00v/u 1000Iterations/s" y no entiendo qué pasa."** Eso es un constant-vuscon 0 VUsEffectivos. Suele significar que elstartVUsestá a 0 y el primer stage no dura lo suficiente, o que el escenario anterior terminó y este no ha arrancado. Mira que losstages` tienen duración suficiente para que los VUs se asignen.

“El test pasa en local y falla en staging.” Casi siempre es la red: en local el RTT es 0, en staging son 2 ms, pero en un portátil haciendo k6 desde casa con VPN puede ser 40. Ejecuta el test desde una máquina lo más cerca posible del servidor, idealmente dentro de la misma red privada.

“Me sale VU limit reached.” Significa que has agotado maxVUs. No es un bug: es la señal de que la latencia subió tanto que necesitabas más VUs para mantener la tasa. O subes maxVUs (y aceptas que el servidor está ahogándose) o reduces la carga objetivo. Lo que no debes hacer es subir maxVUs a 9999 sin pensar.

“Los timeouts salen a los 30 segundos y no antes.” El valor por defecto de k6 es 60 segundos. Si tu SLA de p99 es 2 segundos, un timeout de 30 segundos te hace esperar mucho para descubrir un problema que ya sabes que va a pasar.

http.get(url, { timeout: '5s' });

“El handleSummary no se ejecuta.” Solo se llama en el proceso principal de k6, no en remote execution. Si usas --execution-segment, no hay summary. Y las métricas personalizadas que crees con new Trend() necesitan declarar isTime: true si miden tiempos, o los percentiles salen mal.

“La prueba parece lenta y creo que k6 es el cuello de botella.” Comprueba el uso de CPU del propio proceso k6. Si está al 90%, estás midiendo k6 en lugar de tu aplicación. Baja el número de VUs por máquina o reparte con k6-operator.

El flujo completo

┌───────────────────────────────────────────────────────────────┐
│  0. DATOS REALES                                             │
│     analytics: p95 de concurrencia, ratio de rutas, picos     │
│     SLA pactado con negocio: p95/p99/error por ruta           │
└───────────────────────────┬───────────────────────────────────┘
                            ▼
┌───────────────────────────────────────────────────────────────┐
│  1. PROMPT DE TRÁFICO  →  escenario k6 con números reales    │
│     (nada de "100 VUs porque suena bien")                    │
└───────────────────────────┬───────────────────────────────────┘
                            ▼
┌───────────────────────────────────────────────────────────────┐
│  2. PROMPT DE UMBRALES  →  thresholds escritos primero       │
│     desde el SLA, con tags por ruta                          │
└───────────────────────────┬───────────────────────────────────┘
                            ▼
┌───────────────────────────────────────────────────────────────┐
│  3. EJECUTAR EN STAGING                                      │
│     k6 run --out json=results.json -e BASE_URL=...           │
│     3 ejecuciones, no 1. Monitoreo abierto en paralelo.       │
└───────────────────────────┬───────────────────────────────────┘
                            ▼
┌───────────────────────────────────────────────────────────────┐
│  4. PROMPT DE ANÁLISIS  → diagnóstico por capas               │
│     app / BD / red / VUs insuficientes                        │
│     "¿cola o duración?" "¿p50 bien y p99 disparado?"          │
└───────────────────────────┬───────────────────────────────────┘
                            ▼
┌───────────────────────────────────────────────────────────────┐
│  5. UN CAMBIO DE ARQUITECTURA O DE CÓDIGO                     │
│     (no "subir VUs" como solución)                           │
└───────────────────────────┬───────────────────────────────────┘
                            ▼
┌───────────────────────────────────────────────────────────────┐
│  6. REPETIR  →  comparar  →  documentar el delta             │
│     Y convertirlo en gate de CI                              │
└───────────────────────────────────────────────────────────────┘

Ese paso 6 es el que convierte el load testing en una práctica y no en un ejercicio. Un gate de CI que falla cuando el p95 sube un 15% sobre la línea base es lo que evita la conversación incómoda de “la semana pasada iba bien”.

Conclusión

Un load test bien generado con IA no consiste en un default() que hace GETs. Consiste en que los números de carga salgan de tus analytics, los umbrales del SLA que firmaste, y el diagnóstico final distinga entre “tarda más” y “hace cola”. Esos tres ingredientes son los que separan un test útil de un adorno.

Lo que más me costó aprender: constant-arrival-rate casi siempre es la opción correcta, porque mantiene constante la llegada de usuarios aunque la latencia suba. Y la pregunta más valiosa del análisis es si el p95 se mantiene mientras el p99 se dispara, porque eso casi siempre apunta a GC o a una dependencia externa lenta, no a tu código.

Para el problema opuesto —que el código sigue funcionando después de un cambio— está prompts para detectar regresiones con IA. Y para el otro extremo de laQA, cuando lo que se degrada es el navegador y no el servidor, tienes prompts para analizar y testear rendimiento web.

Si el script que te genera se te alarga demasiado y ocupa media ventana de contexto, los atajos para ahorrar tokens con IA te Interestan. Para desplegar el entorno contra el que mides, mira lo de deploy con GitHub Actions y Netlify. Y si vas a construir el generador de prompts que produce esto, la herramienta interactiva es el sitio donde empezar.

Fran Cobos

Fran Cobos

Desarrollador Full Stack especializado en IA aplicada, automatización y desarrollo web. Escribo sobre herramientas, tutoriales y casos reales para programadores.

¿Necesitas desarrollo a medida?

Apps web, IA, módulos ERP — cuéntame tu proyecto.