Módulo 6: Checks, groups y escenarios realistas

4. `group()` para organizar los pasos (contenido)

Descripción

Un escenario realista tiene varios pasos: cotizar, reservar, confirmar. Cuando los metes todos en la función del VU, uno detrás de otro, el script funciona —pero el resumen se vuelve un amasijo. Todas las peticiones se mezclan en una sola métrica http_req_duration, y cuando ves un p95 alto no sabes de qué paso: ¿fue la cotización la que se puso lenta, o la reserva? ¿El cuello de botella está en calcular el precio o en escribir la reserva? Con los pasos revueltos, el número agregado esconde justo lo que necesitas saber. Aquí entra group(): una forma de envolver cada paso en un bloque con nombre para que el resumen te dé métricas por paso y los checks queden etiquetados por grupo.

group() es organización, no comportamiento. No cambia lo que el VU hace —las mismas peticiones, en el mismo orden—; cambia cómo se reportan. Al envolver el paso de cotizar en group('quote', () => { ... }), k6 mide cuánto tardó ese bloque (una métrica group_duration etiquetada con group: quote) y etiqueta los checks de dentro con ese grupo. Haces lo mismo con book y confirm, y de pronto el resumen deja de decir "el escenario tardó X" y empieza a decir "cotizar tardó X, reservar tardó Y, confirmar tardó Z". Esa desagregación es oro cuando buscas el cuello de botella: te dice qué paso es el lento, no solo que algo lo es.

Conexión con el módulo: group() es la segunda pieza del escenario realista. Es un constructo de k6 que se muestra como contenido (k6 no está instalado). Pero la idea —medir por paso, no solo en agregado— la ejecutamos de verdad: el generador de Python del escenario cotizar → reservar → confirmar ya mide y reporta la latencia media por grupo, que es el equivalente del group_duration de k6. Verás los tres pasos con su latencia por separado, medida en este entorno con Python 3.14.0. La frontera: group() organiza los pasos; encadenarlos con datos que fluyen de uno a otro es la correlación de la lección 6.

Los capítulos de un recibo detallado

Piénsalo con una factura. Imagina que vas a un taller mecánico y te entregan una factura que dice, al final, una sola línea: "Reparación: 8.000 pesos". Es un número correcto, pero inútil para entender nada. ¿Cuánto fue la mano de obra? ¿Cuánto los repuestos? ¿Cuánto la revisión? Si la próxima vez te cobran 12.000, no tienes forma de saber qué subió. Ahora imagina la misma factura detallada por conceptos: "Diagnóstico: 1.000. Repuestos: 5.000. Mano de obra: 2.000." Mismo total, pero ahora ves de qué se compone, y si algo cambia, sabes exactamente qué línea se movió.

group() convierte el resumen de tu prueba de una factura de una línea en una factura detallada. Sin grupos, el resumen dice "el escenario tardó, en p95, 40 ms" —el total, sin desglose—. Con grupos, dice "cotizar: 15 ms, reservar: 18 ms, confirmar: 7 ms" —el mismo total, pero por conceptos—. Y cuando la próxima corrida muestre un p95 más alto, no te preguntarás "¿qué se puso lento?": lo leerás directo en la línea del grupo que subió. Agrupar es ponerle conceptos a la factura de tu escenario.

group() no cambia lo que el VU hace; cambia cómo se reporta. Envuelve cada paso del escenario en un bloque con nombre y k6 mide ese bloque por separado (group_duration) y etiqueta sus checks con el grupo. Es la diferencia entre una factura de una línea ("el escenario tardó X") y una factura detallada por conceptos ("cotizar X, reservar Y, confirmar Z") —que es la que te deja encontrar el cuello de botella.

group() en k6 (contenido)

Así se ve el escenario de tres pasos con group(). Cada paso —cotizar, reservar, confirmar— va envuelto en su bloque con nombre, y los checks de cada paso viven dentro de su grupo:

// scenario_grouped.js - el escenario de 3 pasos, organizado con group().
// MOSTRADO COMO CONTENIDO: k6 no esta instalado en este entorno.
import http from 'k6/http';
import { check, group, sleep } from 'k6';

const BASE_URL = 'http://localhost:8000';
const params = { headers: { 'Content-Type': 'application/json' } };

export default function () {
  let price, bookingId;

  // --- Paso 1: cotizar ---
  group('quote', function () {
    const body = JSON.stringify({ room: 'Studio', tier: 'pro', hours: 4 });
    const res = http.post(`${BASE_URL}/quote`, body, params);
    check(res, {
      'status is 200': (r) => r.status === 200,
      'price is correct': (r) => r.json('price_cents') === 12800,  // Studio/pro/4h
    });
    price = res.json('price_cents');       // se usa en el paso 2 (correlacion, leccion 6)
  });

  sleep(1);   // think time entre pasos

  // --- Paso 2: reservar ---
  group('book', function () {
    const body = JSON.stringify({ room: 'Studio', tier: 'pro', hours: 4, price_cents: price });
    const res = http.post(`${BASE_URL}/book`, body, params);
    check(res, {
      'status is 200': (r) => r.status === 200,
      'booking is confirmed': (r) => r.json('confirmed') === true,
    });
    bookingId = res.json('booking_id');    // se usa en el paso 3 (correlacion, leccion 6)
  });

  sleep(1);

  // --- Paso 3: confirmar ---
  group('confirm', function () {
    const res = http.get(`${BASE_URL}/booking/${bookingId}`);
    check(res, {
      'status is 200': (r) => r.status === 200,
      'id matches': (r) => r.json('booking_id') === bookingId,
    });
  });
}

Desármalo:

  • group('quote', function () { ... }). El primer argumento es el nombre del grupo —el que verás en el resumen—; el segundo, una función con el código de ese paso. Todo lo que ocurre dentro (la petición, los checks) queda etiquetado con group: quote.
  • Los checks dentro del grupo heredan la etiqueta. En el resumen, status is 200 del grupo quote y status is 200 del grupo book aparecen separados, cada uno bajo su grupo. Así, dos pasos con un criterio del mismo nombre no se confunden.
  • group_duration. k6 mide cuánto tardó cada bloque group() y lo reporta como una métrica group_duration etiquetada con el nombre del grupo. Es lo que te deja comparar cuánto tardó quote vs book vs confirm.
  • sleep(1) entre grupos, no dentro. El think time va entre pasos (un usuario piensa entre cotizar y reservar), así que lo pones fuera de los group(), para que la pausa no infle el group_duration de ningún paso.

Una nota honesta: group() no hace que los pasos sean atómicos ni transaccionales. Si el paso 2 falla, el 3 se ejecuta igual (a menos que tú lo evites con lógica). group() solo organiza el reporte; la lógica del flujo la escribes tú.

Cómo se ve en el resumen de k6 (contenido)

Con grupos, el resumen de k6 muestra el group_duration desglosado por grupo. Así se vería el bloque relevante (contenido de referencia, fiel al formato de k6, no ejecutado aquí):

     █ quote
       ✓ status is 200
       ✓ price is correct

     █ book
       ✓ status is 200
       ✓ booking is confirmed

     █ confirm
       ✓ status is 200
       ✓ id matches

     checks.........................: 100.00% ✓ 600      ✗ 0
     group_duration.................: avg=8.1ms  min=1.2ms med=6.4ms max=41ms p(90)=15ms p(95)=19ms
       { group:::quote }............: avg=9.7ms  ...
       { group:::book }.............: avg=8.9ms  ...
       { group:::confirm }..........: avg=5.6ms  ...
     http_req_duration..............: avg=2.7ms  ...

Lo que este resumen te da y el sin-grupos no:

  • Los checks agrupados por paso (█ quote, █ book, █ confirm), cada uno con sus criterios. De un vistazo sabes qué paso tiene checks rojos, no solo que "algún check" falló.
  • group_duration por grupo ({ group:::quote }, { group:::book }, { group:::confirm }). Aquí está la factura detallada: cuánto tardó cada paso por separado. Si book fuera el lento, lo verías en su línea.

La misma idea, ejecutada en Python

El generador de Python del escenario cotizar → reservar → confirmar ya mide por grupo. Cada paso cronometra su petición y guarda la latencia en una lista por grupo; al final reporta la media de cada uno —el equivalente del group_duration de k6—:

# Dentro de cada paso del escenario: cronometrar y guardar por grupo.
t0 = time.perf_counter()
status, body = post_json(f"{BASE_URL}/quote", {...})
dt = (time.perf_counter() - t0) * 1000          # latencia del paso en ms
with lock:
    group_calls.setdefault("quote", []).append(dt)   # una lista de latencias por grupo
# ... idem para "book" y "confirm"

# Al final, la media por grupo (el group_duration de k6):
for g in ("quote", "book", "confirm"):
    lat = group_calls[g]
    print(f"    group '{g}': n={len(lat)}  avg={sum(lat)/len(lat):.2f}ms")

Corramos el escenario sano, 4 VUs / 3 s / think 100 ms, y miremos el desglose por grupo.

Qué esperar. Los tres grupos deben tener la misma cantidad de llamadas (una por iteración) y una latencia media por paso —probablemente la cotización un poco más alta que confirmar, porque calcula el precio—. Salida real en este entorno (el bloque por grupo de la corrida completa):

  iterations.....: 109   (35.2/s)
  checks.........: 100.00%   (872 de 872)
  por grupo (latencia media, n llamadas):
    group 'quote  '.....: n=109  avg=1.06ms
    group 'book   '.....: n=109  avg=0.54ms
    group 'confirm'.....: n=109  avg=0.47ms

Léela como la factura detallada:

  • n=109 en los tres grupos — cada iteración ejecutó los tres pasos una vez, así que los tres grupos tienen 109 llamadas. Coincide con las 109 iteraciones.
  • quote: avg=1.06ms, book: avg=0.54ms, confirm: avg=0.47ms — aquí está el desglose por paso. La cotización es la más lenta de las tres (1.06 ms), lo cual tiene sentido: es la que recibe un body, lo parsea, valida y calcula el precio. La reserva (0.54 ms) y la confirmación (0.47 ms) son más ligeras. Sin grupos, verías solo un promedio agregado y no sabrías que el paso de cotizar es el que más pesa.
  • La lección de la desagregación — la diferencia entre 1.06 ms y 0.47 ms es pequeña aquí (Reservo es rapidísima en localhost), pero el método es el que importa: en una API real, si un paso tardara 10× más que los otros, esta tabla te lo diría de inmediato. Medir por grupo es lo que convierte "el escenario está lento" en "el paso de reservar está lento", que es una pista accionable.

Fíjate en que el número que importa no es la magnitud (milisegundos contra un servidor local), sino la capacidad de comparar pasos. Eso es exactamente lo que group() te da en k6 y lo que este desglose te da ejecutado: la factura por conceptos en vez de la de una línea.

Cuándo agrupar y cuándo no

group() es útil, pero no gratis en atención: demasiados grupos anidados vuelven el resumen ruidoso. La regla práctica:

  • Agrupa por paso lógico del escenario —cotizar, reservar, confirmar—, no por cada línea de código. Un grupo debe corresponder a una acción del usuario que tenga sentido medir por separado.
  • No agrupes lo que no vas a comparar. Si un escenario tiene un solo paso, group() no aporta nada; el resumen agregado ya es ese paso.
  • No metas el think time dentro del grupo. El sleep va entre grupos, no dentro, para que la pausa no infle el group_duration y desvirtúe la medición del paso.
  • No confundas group() con transacción. Agrupar no hace que los pasos fallen o tengan éxito juntos; solo los reporta juntos. La lógica de "si el paso 2 falla, no hagas el 3" la escribes tú.

Errores comunes

Meter el sleep (think time) dentro del group(). Qué pasa: alguien pone la pausa de think time dentro del bloque group('quote', ...), y el group_duration de cotizar sale inflado por el segundo de sueño. Por qué pasa: parece natural poner todo el "paso" junto. Cómo detectarlo: un grupo con una latencia sospechosamente alta y redonda (≈ el valor del sleep). Cómo corregirlo: el think time va entre grupos, fuera de ellos. El group_duration debe medir solo el trabajo del paso (la petición), no la pausa que le sigue.

Esperar que group() haga los pasos atómicos. Qué pasa: alguien cree que si un paso falla dentro de un grupo, los siguientes no se ejecutan. Por qué pasa: la palabra "grupo" suena a "transacción". Cómo detectarlo: el paso 3 se ejecuta aunque el 2 haya fallado, produciendo errores en cascada (por ejemplo, un GET /booking/undefined). Cómo corregirlo: entiende que group() solo organiza el reporte. Si necesitas que un fallo detenga el flujo, escríbelo tú (un if que salte los pasos siguientes cuando el anterior no dio lo esperado).

Anidar grupos hasta hacer el resumen ilegible. Qué pasa: alguien envuelve cada http.get y cada check en su propio grupo, y el resumen se llena de decenas de líneas { group:::... }. Por qué pasa: se confunde "organizar" con "etiquetar todo". Cómo detectarlo: el resumen tiene más grupos que pasos reales tiene el escenario. Cómo corregirlo: un grupo por paso lógico (una acción del usuario), no por línea. Menos grupos, bien elegidos, hacen la factura legible; demasiados la vuelven otra vez ruido.

Ejercicios

Ejercicio 1 — Agrupa un escenario de dos pasos. Escribe (en k6, como contenido) un escenario que primero liste las salas (GET /rooms) y luego cotice una (POST /quote), cada paso en su propio group() con un check de status 200. Pon el think time en el lugar correcto.

Ver solución
export default function () {
  group('list rooms', function () {
    const res = http.get(`${BASE_URL}/rooms`);
    check(res, { 'status is 200': (r) => r.status === 200 });
  });

  sleep(1);   // think time ENTRE pasos, fuera de los grupos

  group('quote', function () {
    const body = JSON.stringify({ room: 'Focus', tier: 'basic', hours: 3 });
    const res = http.post(`${BASE_URL}/quote`, body, params);
    check(res, { 'status is 200': (r) => r.status === 200 });
  });
}

Cada paso en su grupo; el sleep(1) entre ambos, no dentro de ninguno, para que no infle el group_duration.

Ejercicio 2 — Lee la factura por grupo. En la corrida real, el desglose fue quote: avg=1.06ms, book: avg=0.54ms, confirm: avg=0.47ms, con n=109 en los tres. (a) ¿Por qué los tres tienen n=109? (b) ¿Cuál paso es el más pesado y por qué tiene sentido? (c) Si en una API real book mostrara avg=95ms y los otros dos avg=3ms, ¿qué te diría eso?

Ver solución
  • (a) Porque cada iteración ejecuta los tres pasos exactamente una vez, así que las 109 iteraciones producen 109 llamadas en cada grupo.
  • (b) La cotización (quote, 1.06 ms) es la más pesada. Tiene sentido: es la que recibe y parsea un body JSON, valida sala/tier/horas y calcula el precio. Reservar y confirmar son operaciones más ligeras contra el store en memoria.
  • (c) Que el cuello de botella está en el paso de reservar: book tarda ~30× más que los otros. En una API real eso apuntaría a la escritura de la reserva (una base de datos lenta, un índice faltante, un bloqueo). La factura por grupo convierte "el escenario está lento" en "el paso de reservar está lento", que es una pista accionable —eso es para lo que sirve group()—.

Ejercicio 3 — ¿Grupo o no grupo? Para cada caso, di si group() aporta o no, y por qué. (a) Un escenario con un solo paso: cotizar. (b) Un escenario de cuatro pasos: login, buscar sala, cotizar, reservar. (c) Envolver cada uno de los cinco checks de un mismo paso en su propio grupo.

Ver solución
  • (a) No aporta. Con un solo paso, el resumen agregado ya es ese paso. Agruparlo solo añade una línea redundante.
  • (b) Sí aporta. Cuatro pasos lógicos distintos: agrupar cada uno da un group_duration por paso y te deja ver cuál de los cuatro es el cuello de botella. Es el caso ideal para group().
  • (c) No aporta (y estorba). Cinco checks del mismo paso no son cinco pasos; son cinco criterios de una acción. Meterlos en cinco grupos infla el resumen sin ganar información. Los cinco checks van dentro de un solo group() (el del paso), no cada uno en el suyo.

Resumen y siguiente paso

En esta lección organizaste el escenario con group(). No cambia lo que el VU hace —las mismas peticiones, en el mismo orden—; cambia cómo se reporta: envuelve cada paso (cotizar, reservar, confirmar) en un bloque con nombre, y k6 mide ese bloque por separado (group_duration) y etiqueta sus checks por grupo. Es la diferencia entre una factura de una línea ("el escenario tardó X") y una detallada por conceptos ("cotizar X, reservar Y, confirmar Z") —la que te deja encontrar el cuello de botella—. Lo mediste ejecutado: el desglose real por grupo (quote 1.06 ms, book 0.54 ms, confirm 0.47 ms, con 109 llamadas cada uno) mostró que la cotización es el paso más pesado, algo que un promedio agregado habría escondido.

Antes de avanzar deberías poder: envolver los pasos de un escenario en group() con nombres claros; leer un group_duration por grupo y usarlo para ubicar el paso lento; poner el think time entre grupos y no dentro; y decir cuándo agrupar aporta y cuándo estorba.

La lección 5 ataca otra falta de realismo. Hasta ahora, aunque el escenario tiene varios pasos, todos piden el mismo dato (Studio/pro/4h en el ejemplo). Un usuario real pide salas distintas. Aprenderás a parametrizar los datos —variar sala/tier/horas con una lista, un SharedArray en k6— para no golpear una sola ruta caliente, y verás medida la diferencia entre pedir siempre lo mismo (una fila, un precio) y pedir datos variados (seis filas, cinco precios).

Recursos

  • Groups y tags en k6 — la referencia de group(): cómo etiqueta métricas y checks por paso y produce group_duration. La fuente exacta de esta lección.
  • Métricas integradas de k6 — group_duration — el catálogo donde vive group_duration, la métrica por grupo que desglosa cuánto tardó cada paso. Lo que el reporte por grupo mide.
  • time.perf_counter — documentación de Python — el reloj de alta resolución con el que el generador cronometra cada paso para calcular la latencia por grupo. Cómo se mide el tiempo de un bloque en Python.