Módulo 8: Proyecto — Prueba de carga de la API de Reservo
2. El escenario cotizar→reservar con checks
Descripción
El corazón de cualquier script de carga es la función default: el guion que cada usuario virtual repite en bucle. En esta lección construimos ese guion para Reservo, y no una petición suelta sino un flujo realista de dos pasos: cotizar (POST /quote) y luego reservar (POST /book) —lo que un cliente real hace: primero pregunta el precio, después confirma—. Cada paso lleva sus check() de corrección (status 200, price_cents correcto, confirmed: true, booking_id presente), los datos se parametrizan (distintas salas, planes y horas en cada iteración), y entre iteraciones va un sleep() con jitter para que el tráfico no llegue en lockstep. Escribimos el escenario en k6 (contenido) y lo corremos de verdad en Python, viendo sus checks pasar al 100%.
Conexión con el módulo: esta es la primera de las cuatro piezas del capstone —el escenario que las lecciones siguientes envolverán en un perfil (M4, lección 3), unos thresholds (M5, lección 4) y una corrida por etapas (lección 5)—. Reúne dos módulos: la anatomía del script y http.post (M2) y los check(), la correlación, los datos parametrizados y el think time realista (M6). Aquí no re-explicamos qué es un check ni cómo funciona http.post; los usamos para armar el flujo completo cotizar→reservar. Si necesitas repasar la mecánica, M2 y M6 la enseñan; esta lección la integra.
El cliente que pregunta el precio antes de reservar
Piensa en cómo reservas de verdad una sala en Reservo. No aterrizas en un botón "reservar" y pagas a ciegas. Primero preguntas el precio: eliges la sala, el plan y las horas, y el sistema te dice "son $75.00". Solo entonces, si el precio te convence, confirmas la reserva y el sistema te devuelve un número de confirmación. Son dos pasos, en orden, y el segundo depende del primero: reservas lo que cotizaste.
Una prueba de carga honesta imita ese flujo, no un pedazo suelto de él. Golpear solo /quote mil veces mide el motor de precios, pero no mide lo que el usuario vive —que son dos llamadas encadenadas—. El escenario cotizar→reservar sube VUs que hacen las dos cosas en orden, como clientes de verdad: cotizan, y con lo que cotizaron, reservan. Esa es la diferencia entre medir una API y medir el camino que el usuario recorre por ella. Y como el usuario espera unos segundos entre ver el precio y decidir, el escenario también incluye ese think time —con un poco de aleatoriedad, porque no todos los clientes tardan lo mismo—.
El escenario en k6 (contenido)
Aquí está la función default del capstone: el escenario cotizar→reservar completo, con sus checks y su think time. Recuerda: contenido rotulado, correcto y fiel a la documentación de k6, no ejecutado aquí (k6 no está instalado).
// CONTENIDO (no ejecutado aquí): k6 no está instalado.
// Referencia: grafana.com/docs/k6 (http, check, sleep).
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = 'http://127.0.0.1:8000';
// Datos parametrizados: cada iteración elige una sala, un plan y unas horas.
const ROOMS = ['Focus', 'Studio', 'Boardroom'];
const RATES = { Focus: 2500, Studio: 4000, Boardroom: 8000 }; // centavos/hora
const TIERS = ['basic', 'pro'];
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
// El precio esperado, para el check de corrección (descuento pro entero).
function expectedPrice(room, tier, hours) {
let total = RATES[room] * hours;
if (tier === 'pro') total = Math.floor((total * 80) / 100);
return total;
}
export default function () {
const room = pick(ROOMS);
const tier = pick(TIERS);
const hours = Math.floor(Math.random() * 8) + 1; // 1..8
const want = expectedPrice(room, tier, hours);
const params = { headers: { 'Content-Type': 'application/json' } };
// Paso 1 — cotizar.
const quoteBody = JSON.stringify({ room, tier, hours });
const quote = http.post(`${BASE_URL}/quote`, quoteBody, params);
check(quote, {
'quote status is 200': (r) => r.status === 200,
'quote price is correct': (r) => r.json('price_cents') === want,
});
// Paso 2 — reservar el mismo pedido (correlación: reservas lo que cotizaste).
const bookBody = JSON.stringify({ room, tier, hours });
const book = http.post(`${BASE_URL}/book`, bookBody, params);
check(book, {
'book status is 200': (r) => r.status === 200,
'book is confirmed': (r) => r.json('confirmed') === true,
'book has booking_id': (r) => typeof r.json('booking_id') === 'string',
});
// Think time con jitter: cada VU espera entre 0.5 s y 1.0 s (no en lockstep).
sleep(0.5 + Math.random() * 0.5);
}
Léelo con lo que ya sabes de M2 y M6, fijándote en las cuatro decisiones que lo hacen un escenario y no una petición suelta:
- El flujo de dos pasos. El VU hace
http.posta/quotey luego a/book, en orden. Es el camino del usuario, no un endpoint aislado. Cada petición HTTP cuenta para las métricas de la prueba, así que cada iteración genera dos peticiones. - Los
check()de corrección. Cinco checks en total: dos en la cotización (status 200 yprice_centscorrecto) y tres en la reserva (status 200,confirmed: true,booking_idpresente). Uncheckno aborta la iteración (eso es un threshold, M5/M6): registra si la respuesta fue correcta y sigue. Bajo carga queremos saber no solo si el servidor respondió rápido, sino si respondió bien —un200con un precio equivocado es un fallo silencioso que solo un check atrapa—. - Los datos parametrizados. Cada iteración elige
room,tieryhoursal azar, y el check de precio usaexpectedPrice(...)para saber qué esperar de ese pedido. Golpear siempre el mismo Focus/basic/3h mediría una sola ruta; variar los datos ejercita las tres salas y los dos planes, más cerca del tráfico real. - El
sleepcon jitter.sleep(0.5 + Math.random() * 0.5)hace que cada VU espere entre medio segundo y un segundo, distinto en cada iteración. Sin el jitter, todos los VUs cotizarían, reservarían y dormirían al mismo tiempo, en oleadas sincronizadas (lockstep) que no parecen tráfico real. El jitter desincroniza a los usuarios, como en la vida.
La correlación aquí es sencilla —reservamos el mismo pedido que cotizamos— pero el patrón es el que usarías con datos reales: extraer un valor de la primera respuesta (un booking_id, un token, un id de carrito) y usarlo en la siguiente. En Reservo el book no necesita el resultado del quote para funcionar, pero el escenario respeta el orden del usuario: cotizar primero, reservar después.
El escenario ejecutado en Python
Y este es el escenario que sí se ejecuta: el mismo flujo cotizar→reservar, escrito en Python, corrido unas pocas veces contra la API canónica para verlo funcionar antes de escalarlo a carga. Cada iteración hace las dos peticiones y evalúa los cinco checks:
# scenario_demo.py — el escenario cotizar->reservar con checks, unas iteraciones.
# SE EJECUTA contra Reservo. Uso: python3.14 scenario_demo.py <base_url> [n]
import json, sys, urllib.request
BASE_URL = sys.argv[1]
N = int(sys.argv[2]) if len(sys.argv) > 2 else 6
HOURLY_CENTS = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}
def expected_price(room, tier, hours):
total = HOURLY_CENTS[room] * hours
return total * 80 // 100 if tier == "pro" else total # descuento pro entero
def post(path, payload):
data = json.dumps(payload).encode()
req = urllib.request.Request(BASE_URL + path, data=data,
headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=10) as res:
return res.status, json.loads(res.read())
# Datos parametrizados: distintas salas / planes / horas (M6).
ORDERS = [
("Focus", "basic", 3), ("Focus", "pro", 3), ("Studio", "basic", 2),
("Boardroom", "pro", 4), ("Studio", "pro", 1), ("Boardroom", "basic", 5),
]
total_checks = passed = 0
for room, tier, hours in ORDERS[:N]:
want = expected_price(room, tier, hours)
st_q, body_q = post("/quote", {"room": room, "tier": tier, "hours": hours}) # cotizar
st_b, body_b = post("/book", {"room": room, "tier": tier, "hours": hours}) # reservar
checks = {
"quote status 200": st_q == 200,
"quote price ok": body_q.get("price_cents") == want,
"book status 200": st_b == 200,
"book confirmed": body_b.get("confirmed") is True,
"book has id": isinstance(body_b.get("booking_id"), str),
}
total_checks += len(checks); passed += sum(checks.values())
mark = "OK " if all(checks.values()) else "XX "
print(f"{mark}{room:<9}/{tier:<5}/{hours}h quote={body_q.get('price_cents')} "
f"want={want} book={body_b.get('booking_id')} confirmed={body_b.get('confirmed')}")
print(f"checks: {passed}/{total_checks} ({passed/total_checks*100:.2f}%)")
Con la API corriendo, lo lanzamos:
Qué esperar — las seis iteraciones cotizan y reservan; cada price_cents coincide con el esperado (los números-ancla y sus variantes), cada reserva se confirma con un booking_id, y los checks quedan en 100%. Salida real contra Reservo:
$ python3.14 scenario_demo.py http://127.0.0.1:PORT 6
OK Focus /basic/3h quote=7500 want=7500 book=bk_102372 confirmed=True
OK Focus /pro /3h quote=6000 want=6000 book=bk_102373 confirmed=True
OK Studio /basic/2h quote=8000 want=8000 book=bk_102374 confirmed=True
OK Boardroom/pro /4h quote=25600 want=25600 book=bk_102375 confirmed=True
OK Studio /pro /1h quote=3200 want=3200 book=bk_102376 confirmed=True
OK Boardroom/basic/5h quote=40000 want=40000 book=bk_102377 confirmed=True
checks: 30/30 (100.00%)
Léela con calma, porque es el escenario funcionando:
- Los números-ancla y sus variantes. Focus/basic/3h →
7500, Focus/pro/3h →6000(las dos anclas de la guía), Boardroom/pro/4h →25600(8000 × 4 = 32000,× 80 // 100 = 25600), Studio/pro/1h →3200(4000 × 80 // 100). Cadaquotecoincide con suwant: el check de precio pasa porque la lógica del servidor y la del escenario calculan lo mismo. - La correlación en acción. Cada iteración cotizó y luego reservó el mismo pedido, recibiendo un
booking_iddistinto (bk_102372,bk_102373, ...) yconfirmed=True. El flujo de dos pasos corrió en orden, seis veces. checks: 30/30 (100.00%). Cinco checks por iteración × seis iteraciones = 30 checks, todos verdes. Con la API sana y sin carga, la corrección es perfecta. La pregunta interesante —¿se mantiene al 100% bajo carga?— es lo que las lecciones siguientes miden al escalar este mismo escenario a decenas de VUs concurrentes.
Este es el mapeo de siempre entre las dos caras: el http.post de k6 es el urllib.request de Python; el check(res, {...}) de k6 es la evaluación booleana que aquí cuenta en checks; el sleep con jitter de k6 es el think time que el generador por etapas añadirá en la lección 5. Mismo escenario, dos lenguajes.
Errores comunes
Medir una petición suelta en vez del flujo del usuario. Qué pasa: se escribe un escenario que solo hace POST /quote mil veces, y se reporta como "la carga de Reservo". Por qué pasa: una petición sola es más fácil de escribir. Cómo detectarlo: si tu default tiene un solo http.post, no estás midiendo el camino del usuario (que cotiza y reserva). Cómo corregirlo: modela el flujo —los pasos en orden que un cliente real recorre—. Un escenario de un paso mide un endpoint; uno de varios pasos mide la experiencia.
Verificar solo el status y no la corrección. Qué pasa: el escenario checa status === 200 y da por bueno cualquier 200, aunque el precio esté equivocado. Por qué pasa: el status es lo primero que viene a la mente. Cómo detectarlo: si tus checks no comparan el price_cents contra el esperado, un 200 con un precio roto pasa como bueno. Cómo corregirlo: añade el check de corrección (price_cents === want, confirmed === true). Bajo carga, un servidor estresado puede devolver 200 con datos corruptos; solo un check de contenido lo atrapa. "Rápido y con status 200" no es lo mismo que "correcto".
Olvidar el jitter y mandar el tráfico en lockstep. Qué pasa: se usa un sleep(1) fijo, y todos los VUs actúan en oleadas perfectamente sincronizadas. Por qué pasa: un sleep fijo es lo primero que uno escribe. Cómo detectarlo: si todos tus VUs tienen el mismo think time exacto, generan picos artificiales cada segundo que no parecen tráfico real. Cómo corregirlo: añade jitter —sleep(0.5 + Math.random() * 0.5) en k6, time.sleep(random.uniform(0.5, 1.0)) en Python—. El tráfico real está desincronizado; el jitter lo imita.
Ejercicios
Ejercicio 1 — Predice los checks. Para el pedido Studio/pro/2h, di qué price_cents espera el escenario y cuántos de los cinco checks pasarían si el servidor responde {"price_cents": 6400} en /quote y {"booking_id": "bk_000123", "confirmed": true, "price_cents": 6400} en /book.
Ver solución
- Precio esperado: Studio =
4000/h, 2h =8000; pro:8000 × 80 // 100 = 6400. El escenario esperawant = 6400. - Los cinco checks: quote status 200 ✓, quote price ok (
6400 === 6400) ✓, book status 200 ✓, book confirmed (true) ✓, book has booking_id ("bk_000123"es string) ✓. Los cinco pasan (5/5).
El servidor respondió correcto: el precio coincide con el esperado y la reserva se confirmó con id. Si /quote hubiera devuelto 6401, el segundo check fallaría (4/5), aunque el status siguiera siendo 200 —justo el fallo silencioso que el check de corrección atrapa—.
Ejercicio 2 — Por qué dos peticiones por iteración. El escenario hace POST /quote y POST /book en cada iteración. (a) Si corres 1000 iteraciones, ¿cuántas peticiones HTTP genera la prueba? (b) ¿Cuántos checks? (c) ¿Por qué importa esta distinción al leer las métricas?
Ver solución
- (a)
1000 × 2 = 2000peticiones HTTP (una a/quote, otra a/bookpor iteración). - (b)
1000 × 5 = 5000checks (dos en la cotización, tres en la reserva). - (c) Porque
http_reqs(peticiones) no es igual aiterations. En un escenario de un paso, peticiones ≈ iteraciones; en este de dos pasos, peticiones = 2 × iteraciones. Al leer el RPS o la tasa de error hay que saber que cada iteración pesa dos peticiones —si no, malinterpretas el throughput—. La tasa de error, además, se mide sobre las peticiones (2000), no sobre las iteraciones.
Ejercicio 3 — Añade un paso al flujo. Quieres que el escenario, después de reservar, verifique la reserva con un tercer paso GET /rooms (para simular que el usuario vuelve a la lista). Describe cómo lo añadirías al default de k6 y qué check le pondrías, y di cuántas peticiones por iteración tendría el escenario entonces.
Ver solución
Después del check(book, {...}), añadiría una tercera petición y su check:
const rooms = http.get(`${BASE_URL}/rooms`);
check(rooms, {
'rooms status is 200': (r) => r.status === 200,
'rooms has 3 rooms': (r) => r.json('rooms').length === 3,
});
El escenario tendría entonces tres peticiones por iteración (/quote, /book, /rooms), así que 1000 iteraciones generarían 3000 peticiones HTTP. El think time (sleep con jitter) iría al final, después del tercer paso. Cada paso nuevo que añadas al flujo suma sus peticiones y sus checks al total —modelar el camino completo del usuario cuesta más peticiones, pero mide lo que de verdad importa—.
Resumen y siguiente paso
En esta lección construiste el corazón del capstone: la función default con el escenario cotizar→reservar. No una petición suelta, sino el flujo de dos pasos que un cliente real recorre —POST /quote y luego POST /book—, con check() de corrección en cada paso (status, price_cents, confirmed, booking_id), datos parametrizados (salas, planes y horas variables) y un sleep() con jitter para desincronizar a los VUs. Lo escribiste en k6 (contenido) y lo corriste de verdad en Python: seis iteraciones, los números-ancla correctos, y checks 30/30 (100%).
Integraste dos módulos: la anatomía del script y http.post (M2), y los check(), la correlación, los datos parametrizados y el think time realista (M6). Antes de avanzar deberías poder: escribir un escenario de varios pasos con sus checks; explicar por qué se verifica la corrección y no solo el status; y decir cuántas peticiones y checks genera una iteración de este escenario.
Lo que sigue, en la lección 3, es envolver este escenario en una forma de carga: el perfil smoke→load→stress con stages (M4). El escenario dice qué hace cada VU; el perfil dice cuántos VUs hay y cuándo —de un calentamiento suave a un pico que empuja el sistema hasta ver dónde se dobla—.
Recursos
- k6 —
check()— la referencia oficial delcheckque verifica la corrección de la respuesta sin abortar la iteración; los cinco checks del escenario salen de aquí. - k6 — El objeto Response y
res.json()— cómo el check leeprice_cents,confirmedybooking_iddel cuerpo de la respuesta. La pieza que hace posible el check de corrección. - k6 —
sleep()— el think time entre iteraciones; conMath.random()se le añade el jitter que desincroniza a los VUs. urllib.request— documentación de Python — el cliente HTTP de la biblioteca estándar con el que el escenario ejecutado hacePOST /quoteyPOST /book. El equivalente dehttp.postde k6.