k6 Load Testing: Stress-Test Your APIs Before Launch

Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.

E
ECOSIRE Research and Development Team
|19 de marzo de 202610 min de lectura2.2k Palabras|

Parte de nuestra serie Performance & Scalability

Leer la guía completa

Prueba de carga k6: pruebe sus API antes del lanzamiento

Enviar un producto sin pruebas de carga es una apuesta. No conoce su punto de ruptura hasta que los usuarios lo encuentran por usted, generalmente durante el lanzamiento de un producto, un momento viral o un pico de ventas. k6 es la herramienta de prueba de carga moderna que le permite escribir pruebas en JavaScript, ejecutarlas desde CI y descubrir su límite de rendimiento antes de que lo hagan los usuarios. Es fácil de desarrollar, eficiente en recursos (k6 usa rutinas, no subprocesos) y se integra limpiamente con Grafana y Prometheus para obtener métricas en tiempo real.

Esta guía cubre k6 desde el primer script hasta pruebas de carga complejas de múltiples escenarios, métricas personalizadas, umbrales, integración de CI y patrones de prueba seguros para producción para las API de Node.js/NestJS.

Conclusiones clave

  • Los scripts de k6 son JavaScript pero se ejecutan en un tiempo de ejecución de Go, sin API de Node.js (sin require, sin fs, sin setTimeout)
  • Los Usuarios Virtuales (VU) son usuarios simulados concurrentes; Las iteraciones son ejecuciones de scripts individuales.
  • Establezca siempre umbrales antes de ejecutar: los umbrales fallidos detienen la prueba y fallan el CI
  • Utilice escenarios (vus constante, vus en rampa, tasa de llegada constante) para modelar patrones de tráfico reales
  • Nunca cargue la producción de prueba sin coordinarse con operaciones: pruebe contra la puesta en escena o un clon de producción
  • La métrica http_req_duration es su métrica principal de SLA: p95 y p99 importan más que los promedios
  • Limite la velocidad de sus corredores k6: el tráfico de prueba de carga no debe agotar su presupuesto de límite de velocidad
  • Utilice k6 Cloud o Grafana k6 para pruebas de carga distribuida en regiones geográficas

Instalación y primer script

# macOS
brew install k6

# Ubuntu/Debian
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

# Windows (via Chocolatey)
choco install k6

Tu primer script k6:

// k6/homepage-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';

// Custom metric: error rate
const errorRate = new Rate('error_rate');

export const options = {
  // Ramp up to 50 VUs over 1 minute, hold 2 minutes, ramp down
  stages: [
    { duration: '1m', target: 50 },
    { duration: '2m', target: 50 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95th pct < 500ms
    http_req_failed:   ['rate<0.01'],                // Less than 1% errors
    error_rate:        ['rate<0.01'],
  },
};

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

export default function () {
  const res = http.get(`${BASE_URL}/`);

  const ok = check(res, {
    'status is 200':              (r) => r.status === 200,
    'response time < 500ms':     (r) => r.timings.duration < 500,
    'body contains ECOSIRE':     (r) => r.body.includes('ECOSIRE'),
  });

  errorRate.add(!ok);
  sleep(1); // 1 second think time between iterations
}

Ejecútelo:

k6 run k6/homepage-load.js
k6 run --env BASE_URL=https://staging.ecosire.com k6/homepage-load.js
k6 run --vus 100 --duration 30s k6/homepage-load.js  # Quick smoke test

Prueba de carga de API con autenticación

// k6/api-load.js
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Counter, Trend } from 'k6/metrics';

const apiCalls = new Counter('api_calls');
const authDuration = new Trend('auth_duration');

export const options = {
  scenarios: {
    // Constant arrival rate: 100 requests/second regardless of VU count
    constant_load: {
      executor: 'constant-arrival-rate',
      rate: 100,
      timeUnit: '1s',
      duration: '3m',
      preAllocatedVUs: 50,
      maxVUs: 200,
    },
  },
  thresholds: {
    http_req_duration:            ['p(95)<300', 'p(99)<500'],
    'http_req_duration{type:api}': ['p(95)<200'],
    http_req_failed:              ['rate<0.005'], // 0.5% error budget
  },
};

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

// setup() runs once before VUs start
export function setup() {
  // Authenticate and return tokens for VUs to use
  const loginRes = http.post(`${BASE_URL}/auth/login`, JSON.stringify({
    email: __ENV.TEST_EMAIL,
    password: __ENV.TEST_PASSWORD,
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  check(loginRes, { 'login successful': (r) => r.status === 200 });

  const { accessToken } = loginRes.json();
  return { accessToken };
}

export default function (data) {
  const { accessToken } = data;
  const headers = {
    Authorization: `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  };

  group('Contacts API', () => {
    // List contacts
    const listRes = http.get(`${BASE_URL}/contacts?page=1&limit=20`, {
      headers,
      tags: { type: 'api', endpoint: 'contacts-list' },
    });

    check(listRes, {
      'list contacts: 200': (r) => r.status === 200,
      'list contacts: has items': (r) => r.json('data') !== null,
    });

    apiCalls.add(1);
    sleep(0.5);

    // Create a contact
    const createRes = http.post(`${BASE_URL}/contacts`, JSON.stringify({
      name: `Load Test User ${Date.now()}`,
      email: `load-test-${Date.now()}@example.com`,
    }), {
      headers,
      tags: { type: 'api', endpoint: 'contacts-create' },
    });

    check(createRes, {
      'create contact: 201': (r) => r.status === 201,
    });

    apiCalls.add(1);
  });

  sleep(1);
}

// teardown() runs once after all VUs finish
export function teardown(data) {
  // Clean up test data if needed
  console.log(`Test complete. Data: ${JSON.stringify(data)}`);
}

Ampliación de escenarios de VU

Modele patrones de tráfico reales con múltiples etapas y escenarios:

// k6/stress-test.js
export const options = {
  scenarios: {
    // Scenario 1: Normal traffic baseline
    normal_traffic: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 20 },
        { duration: '5m', target: 20 },
      ],
      gracefulRampDown: '30s',
    },

    // Scenario 2: Sudden spike (marketing campaign goes live)
    traffic_spike: {
      executor: 'ramping-vus',
      startVUs: 0,
      startTime: '7m', // Starts after baseline is stable
      stages: [
        { duration: '30s', target: 200 }, // Rapid spike
        { duration: '2m',  target: 200 }, // Hold spike
        { duration: '30s', target: 20  }, // Fall back
      ],
    },

    // Scenario 3: Soak test for memory leaks
    soak_test: {
      executor: 'constant-vus',
      vus: 10,
      duration: '30m', // Run for 30 minutes
      startTime: '10m',
    },
  },
  thresholds: {
    'http_req_duration{scenario:normal_traffic}': ['p(95)<300'],
    'http_req_duration{scenario:traffic_spike}':  ['p(95)<800'],
    http_req_failed:                              ['rate<0.02'],
  },
};

Prueba de rastreo de blog/mapa del sitio

// k6/sitemap-crawl.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';

// Load URLs from sitemap (pre-fetched and saved as JSON)
const urls = new SharedArray('sitemap urls', function () {
  // In real usage, fetch sitemap XML and parse URLs beforehand
  // SharedArray is loaded once and shared across all VUs (memory efficient)
  return JSON.parse(open('./sitemap-urls.json'));
});

export const options = {
  vus: 20,
  duration: '5m',
  thresholds: {
    http_req_duration: ['p(95)<2000'], // Public pages can be slower
    http_req_failed:   ['rate<0.01'],
  },
};

export default function () {
  // Each VU picks a random URL from the sitemap
  const url = urls[Math.floor(Math.random() * urls.length)];

  const res = http.get(url, {
    headers: {
      // Identify as load tester in logs
      'User-Agent': 'k6-load-tester/1.0',
    },
    timeout: '10s',
  });

  check(res, {
    'status is 200':           (r) => r.status === 200,
    'no error page':           (r) => !r.body.includes('Error'),
    'has canonical tag':       (r) => r.body.includes('rel="canonical"'),
    'response time < 2000ms': (r) => r.timings.duration < 2000,
  });

  sleep(Math.random() * 2 + 1); // Random 1-3 second think time
}

Métricas y umbrales personalizados

// k6/checkout-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Counter, Rate, Trend, Gauge } from 'k6/metrics';

// Custom metrics for business-specific tracking
const checkoutSuccesses = new Counter('checkout_successes');
const checkoutFailures  = new Counter('checkout_failures');
const checkoutDuration  = new Trend('checkout_duration');
const activeCheckouts   = new Gauge('active_checkouts');

export const options = {
  stages: [
    { duration: '1m', target: 10 },
    { duration: '3m', target: 30 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    checkout_duration:   ['p(95)<3000'],   // Checkout < 3s at p95
    checkout_failures:   ['count<10'],     // Max 10 absolute failures
    http_req_duration:   ['p(99)<2000'],
  },
};

export default function (data) {
  activeCheckouts.add(1);
  const start = Date.now();

  // Step 1: Add to cart
  const cartRes = http.post('/api/cart/add', JSON.stringify({
    productId: 'prod_odoo_customization',
    quantity: 1,
  }), { headers: { 'Content-Type': 'application/json' } });

  if (!check(cartRes, { 'add to cart: 200': (r) => r.status === 200 })) {
    checkoutFailures.add(1);
    activeCheckouts.add(-1);
    return;
  }

  sleep(2);

  // Step 2: Create checkout session
  const checkoutRes = http.post('/api/billing/checkout', JSON.stringify({
    cartId: cartRes.json('cartId'),
  }), { headers: { 'Content-Type': 'application/json' } });

  const success = check(checkoutRes, {
    'checkout session created': (r) => r.status === 200,
    'has session URL':          (r) => r.json('url') !== null,
  });

  if (success) {
    checkoutSuccesses.add(1);
    checkoutDuration.add(Date.now() - start);
  } else {
    checkoutFailures.add(1);
  }

  activeCheckouts.add(-1);
  sleep(1);
}

Integración de salida de Grafana

Transmita métricas de k6 a InfluxDB y visualícelas en Grafana durante la prueba:

# Run with InfluxDB output
k6 run --out influxdb=http://localhost:8086/k6 k6/api-load.js

# Or use the k6 Grafana integration (k6 v0.45+)
k6 run --out experimental-prometheus-rw k6/api-load.js
# docker-compose.monitoring.yml
services:
  influxdb:
    image: influxdb:2.7
    ports: ["8086:8086"]
    environment:
      INFLUXDB_DB: k6
      INFLUXDB_ADMIN_USER: admin
      INFLUXDB_ADMIN_PASSWORD: password

  grafana:
    image: grafana/grafana:latest
    ports: ["3030:3000"]
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: "true"
    volumes:
      - ./grafana/dashboards:/etc/grafana/provisioning/dashboards
      - ./grafana/datasources:/etc/grafana/provisioning/datasources

Integración de CI

# .github/workflows/ci.yml (load test section)
load-tests:
  runs-on: ubuntu-latest
  if: github.ref == 'refs/heads/main' # Only on main branch
  needs: [deploy-staging]
  steps:
    - uses: actions/checkout@v4

    - name: Install k6
      run: |
        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

    - name: Run smoke test (30 VUs, 60s)
      run: |
        k6 run --vus 30 --duration 60s \
          --env BASE_URL=${{ secrets.STAGING_URL }} \
          --env TEST_EMAIL=${{ secrets.TEST_EMAIL }} \
          --env TEST_PASSWORD=${{ secrets.TEST_PASSWORD }} \
          apps/api/k6/api-load.js

    - name: Upload k6 results
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: k6-results
        path: k6-results/

Líneas base de desempeño

Establecer líneas de base y alertar sobre regresiones:

Punto finalp50p95p99Presupuesto erróneo
GET / (página de inicio)120 ms400 ms800 ms0,1%
CÓDIGO050 ms150 ms300 ms0,5%
CÓDIGO080 ms200 ms400 ms0,5%
CÓDIGO0200 ms500 ms1000 ms1%
CÓDIGO0500 ms1500 ms3000 ms1%
CÓDIGO0100 ms300 ms600 ms0,1%

Preguntas frecuentes

¿Cuál es la diferencia entre VU y solicitudes por segundo?

Los VU (usuarios virtuales) son usuarios simulados concurrentes. Cada VU ejecuta su función predeterminada en un bucle. Las solicitudes por segundo dependen de la velocidad con la que se ejecuta cada iteración. Si cada iteración de VU dura 2 segundos (incluido el tiempo de suspensión) y tienes 100 VU, obtienes aproximadamente 50 RPS. Utilice el ejecutor constant-arrival-rate cuando necesite apuntar a un RPS específico independientemente de la duración de la iteración.

¿Debo cargar la prueba en producción o en preparación?

Prefiere siempre la puesta en escena. Las pruebas de carga de producción corren el riesgo de afectar a usuarios reales, agotar los límites de velocidad, crear pedidos reales a partir de datos de prueba y desencadenar eventos de webhook reales. Si debe probar la producción, hágalo durante períodos de poco tráfico, utilice tokens de pago de prueba y tenga un plan de reversión. k6 Cloud admite la generación de carga distribuida geográficamente para que pueda probar el rendimiento del borde de su CDN sin tocar el origen.

¿Cómo manejo la caducidad del token de autenticación durante pruebas largas?

Para pruebas cortas (menos de 15 minutos), obtenga un token en setup() y páselo a todas las VU a través de data. Para pruebas largas (más de 15 minutos), implemente la actualización del token en el script de prueba: verifique la antigüedad del token en la función predeterminada y actualice cuando se acerque a su vencimiento. Almacene el token en una variable JavaScript local para cada VU.

¿Cuál es la diferencia entre stages y scenarios?

stages es una abreviatura de un único escenario ramping-vus, bueno para patrones simples de aceleración/mantenimiento/desaceleración. scenarios le brinda control total: múltiples patrones de tráfico simultáneos, diferentes ejecutores por escenario, umbrales por escenario y etiquetado de escenarios en métricas. Utilice scenarios para pruebas realistas de múltiples patrones (línea de base + pico + remojo simultáneamente).

¿Cuántas VU puede manejar un solo proceso k6?

Un solo proceso k6 en hardware moderno puede soportar entre 5000 y 10 000 VU y generar entre 50 000 y 100 000 RPS, dependiendo de la complejidad del script y el tamaño de la respuesta. Para cargas más altas, use k6 cloud o ejecute varias instancias k6 detrás de un distribuidor de carga. Cada VU es extremadamente liviana (una gorutina): k6 usa muchos menos recursos que JMeter o Gatling para recuentos de VU equivalentes.


Próximos pasos

Las pruebas de carga revelan el verdadero perfil de rendimiento de su aplicación antes de que los usuarios la descubran bajo estrés. Los patrones de esta guía (escenarios de aceleración, umbrales personalizados, integración de CI y paneles de Grafana) le brindan la infraestructura para encontrar y solucionar cuellos de botella de rendimiento de forma continua.

ECOSIRE crea API NestJS con rendimiento validado con pruebas de carga k6 que cubren la página de inicio, puntos finales de API, flujos de pago y rastreos completos de mapas del sitio. Explore nuestros servicios de ingeniería backend para saber cómo construimos para lograr rendimiento desde el primer día.

E

Escrito por

ECOSIRE Research and Development Team

Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.

Chatea en whatsapp