Módulo 2: El script de k6 y los usuarios virtuales
8. Mini-proyecto: un script k6 y su modelo en Python
Descripción
Esta lección es tu graduación del módulo 2. A lo largo de siete lecciones desarmaste el script de k6 pieza por pieza —la función default, http.post, check, sleep, options— y entendiste el modelo del VU. Ahora los juntas todos en un solo entregable de principio a fin: escribes un script k6 completo para cotizar en /quote (contenido, con su resumen) y su equivalente ejecutable en Python —N VUs con ThreadPoolExecutor, check de status y precio, conteo de iteraciones y errores— y lo corres de verdad contra la API canónica de Reservo, viendo las dos caras del mismo mundo lado a lado.
El entregable tiene cuatro partes, y las cuatro importan: (1) el script de k6 completo y correcto (contenido), con default, http.post, check, sleep y options; (2) el resumen de k6 que ese script produciría, leído (contenido); (3) el generador de Python que modela los mismos VUs y su corrida real contra la API, con iteraciones, checks y errores medidos; y (4) una reflexión sobre el mapeo —qué línea de k6 corresponde a qué línea de Python, y en qué se parecen y difieren sus resúmenes—. Esa cuarta parte amarra lo que escribiste con lo que mediste: el punto del módulo no es solo tener un script, sino entender el modelo que describe.
Conexión con el módulo: aquí se cierra el arco. Las lecciones 2 a 6 te dieron las piezas; la 7, cómo leer lo que producen. Esta lección te pone a producir el recorrido completo con tus manos, en orden, como lo harías el primer día que escribes una prueba de carga para una API real. El script y el resumen de k6 van como contenido rotulado (correctos, no ejecutados aquí); el generador Python se ejecuta de verdad, y su salida —20 VUs, 408 iteraciones, 98.04 % de checks, 8 errores— fue medida en este entorno con Python 3.14.0 contra la API canónica. Cuando termines, entras al módulo 3 —las métricas a fondo— con el script y el modelo del VU ya interiorizados.
El simulador de vuelo y el vuelo real
Piénsalo así. Un piloto en formación domina dos cosas que se reflejan mutuamente. Primero, el simulador: un modelo completo del avión donde practica cada maniobra, con sus instrumentos y sus lecturas —todo fiel a la realidad, aunque el avión no despegue del hangar—. Segundo, el vuelo real: subirse a la aeronave y hacer el trayecto de verdad, midiendo altitud y velocidad reales. El piloto competente entiende ambos y, sobre todo, entiende cómo se corresponden: sabe que la palanca del simulador es la misma que la del avión, que el altímetro simulado lee lo mismo que el real. El simulador no reemplaza el vuelo; lo modela con fidelidad, para que cuando vueles de verdad, ya sepas qué esperar.
Tu mini-proyecto es exactamente esto. El script de k6 es el simulador: un modelo completo y fiel de la prueba de carga —con su default, sus checks, su resumen— aunque en este entorno k6 no "despegue" (no está instalado). El generador de Python es el vuelo real: sube VUs de verdad, golpea la API de verdad, mide iteraciones y errores de verdad. Y la cuarta parte del entregable —la reflexión sobre el mapeo— es lo que hace de ti un piloto competente y no solo alguien que apretó botones: entender que http.post es urllib.request, que check es una comparación contada, que ambos resúmenes leen el mismo mundo. Dominar las dos caras y su correspondencia es saber de verdad qué es una prueba de carga.
El script de k6 modela la prueba con fidelidad (el simulador); el generador de Python la ejecuta de verdad (el vuelo real). El valor del mini-proyecto no está solo en tener ambos, sino en entender cómo se corresponden línea por línea. Eso es saber qué es una prueba de carga, no solo escribirla.
Lo que vas a construir
Un mini-proyecto con tres archivos: la API canónica (el blanco), el script de k6 (el simulador) y el generador de Python (el vuelo real).
reservo-load/
├── reservo_api.py # la API canonica de Reservo (el blanco de carga)
├── quote_test.js # el script de k6 (CONTENIDO: k6 no esta instalado)
└── loadgen.py # el generador Python que modela los VUs (SE EJECUTA)
Sigue los pasos en orden; cada uno se apoya en el anterior.
Paso 1 — La API canónica (el blanco de carga)
Este es el servidor de Reservo. Parte del canónico del módulo 1 y le declaramos una variante (mismo blanco, con tres añadidos respecto a M1): valida la entrada de forma más estricta (plan y horas dentro de rango, JSON malformado → 400); nombra su tabla de tarifas ROOM_RATES_CENTS y expone el campo rate_cents en /rooms (donde M1 usa HOURLY_CENTS/hourly_cents); y genera el booking_id con un contador secuencial (bk-000001, bk-000002, …) para que cada reserva tenga un id único —el mismo formato que reusará la correlación del módulo 6, distinto del descriptivo bk_Focus_basic_3 de M1—. La lógica de precio no cambia: tarifa por hora (Focus 2500, Studio 4000, Boardroom 8000 centavos), descuento pro entero (*80//100), y usa puerto 0 para que el sistema operativo asigne un puerto libre (así no choca si algo más está corriendo):
# reservo_api.py — la API canonica de Reservo (blanco de carga).
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json, itertools
ROOM_RATES_CENTS = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}
VALID_TIERS = {"basic", "pro"}
_booking_counter = itertools.count(1)
def price_cents(room, tier, hours):
"""Precio en centavos (int). Descuento pro entero: *80//100."""
total = ROOM_RATES_CENTS[room] * hours
if tier == "pro":
total = total * 80 // 100
return total
class ReservoHandler(BaseHTTPRequestHandler):
def log_message(self, *args): # silencia el log por request
pass
def _send_json(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/rooms":
rooms = [{"room": n, "rate_cents": r} for n, r in ROOM_RATES_CENTS.items()]
self._send_json(200, {"rooms": rooms})
else:
self._send_json(404, {"error": "not_found"})
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
try:
data = json.loads(raw or b"{}")
except json.JSONDecodeError:
return self._send_json(400, {"error": "invalid_json"})
room, tier, hours = data.get("room"), data.get("tier"), data.get("hours")
if room not in ROOM_RATES_CENTS or tier not in VALID_TIERS:
return self._send_json(400, {"error": "invalid_room_or_tier"})
if not isinstance(hours, int) or not (1 <= hours <= 12):
return self._send_json(400, {"error": "invalid_hours"})
cents = price_cents(room, tier, hours)
if self.path == "/quote":
self._send_json(200, {"price_cents": cents})
elif self.path == "/book":
bid = f"bk-{next(_booking_counter):06d}"
self._send_json(200, {"booking_id": bid, "confirmed": True, "price_cents": cents})
else:
self._send_json(404, {"error": "not_found"})
def main():
# Puerto 0: el SO asigna un puerto libre. Lo escribimos a port.txt.
server = ThreadingHTTPServer(("127.0.0.1", 0), ReservoHandler)
_, port = server.server_address
with open("port.txt", "w") as f:
f.write(str(port))
print(f"Reservo API en http://127.0.0.1:{port}")
server.serve_forever()
if __name__ == "__main__":
main()
Arráncala en una terminal —python3 reservo_api.py— y déjala corriendo. Escribirá el puerto asignado en port.txt, que el generador leerá.
Paso 2 — El script de k6 (el simulador, contenido)
Este es el entregable de k6: un script completo que junta las cinco piezas del módulo. Se muestra como contenido —correcto y verificado contra la documentación de k6, pero k6 no está instalado en este entorno, así que no lo ejecutamos aquí—:
// quote_test.js — prueba de carga de /quote en Reservo.
// MOSTRADO COMO CONTENIDO: k6 no esta instalado en este entorno.
// Se correria con: k6 run quote_test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
// (1) options: la hoja de reparto. 20 VUs durante 10 segundos.
export const options = {
vus: 20,
duration: '10s',
};
const BASE_URL = 'http://localhost:8000';
// (2) default: el guion que cada VU repite en bucle.
export default function () {
// (3) http.post con cuerpo JSON y header Content-Type.
const payload = JSON.stringify({ room: 'Focus', tier: 'basic', hours: 3 });
const params = { headers: { 'Content-Type': 'application/json' } };
const res = http.post(`${BASE_URL}/quote`, payload, params);
// (4) check: status 200 y precio correcto (7500 para Focus/basic/3h).
check(res, {
'status is 200': (r) => r.status === 200,
'price is 7500': (r) => r.json('price_cents') === 7500,
});
// (5) sleep: medio segundo de think time entre iteraciones.
sleep(0.5);
}
Las cinco piezas del módulo, en un archivo: options (lección 6), default (lección 2), http.post con cuerpo y headers (lección 3), check (lección 4) y sleep (lección 5). Si tuvieras k6 instalado, lo correrías con k6 run quote_test.js.
Paso 3 — El resumen de k6 (contenido, leído)
Este es el resumen que quote_test.js produciría con 20 VUs / 10 s y sleep(0.5). Contenido de referencia, correcto según la documentación de k6, no ejecutado aquí:
scenarios: (100.00%) 1 scenario, 20 max VUs, 10s max duration (incl. graceful stop):
* default: 20 looping VUs for 10s (gracefulStop: 30s)
✓ status is 200
✓ price is 7500
checks.........................: 100.00% ✓ 800 ✗ 0
data_received..................: 82 kB 8.0 kB/s
data_sent......................: 79 kB 7.7 kB/s
http_req_duration..............: avg=2.8ms min=0.3ms med=2.1ms max=115ms p(90)=4.3ms p(95)=6.0ms
http_req_failed................: 0.00% ✓ 0 ✗ 400
http_reqs......................: 400 39.9/s
iteration_duration.............: avg=0.5s min=0.5s med=0.5s max=0.62s p(90)=0.5s p(95)=0.5s
iterations.....................: 400 39.9/s
vus............................: 20 min=20 max=20
vus_max........................: 20 min=20 max=20
running (0m10.0s), 00/20 VUs, 400 complete and 0 interrupted iterations
default ✓ [======================================] 20 VUs 10s
Léelo con lo aprendido en la lección 7: vus: 20 (los concurrentes), iterations: 400 (≈ vus × duración / think = 20 × 10 / 0.5 = 400 vueltas), http_reqs: 400 (una petición por vuelta), checks: 800 (dos por vuelta, todos verdes). El bloque http_req_duration está ahí, con su avg y sus percentiles, esperando al módulo 3.
Paso 4 — El generador de Python (el vuelo real)
Y este es el entregable que sí se ejecuta: el generador que modela los VUs de k6 con ThreadPoolExecutor. Cada worker es un VU que repite default_fn() en bucle, hace el mismo check (status 200 y precio correcto) y cuenta iteraciones, checks y errores:
# loadgen.py — modela los VUs de k6 con un ThreadPoolExecutor y SE EJECUTA.
# Uso: python loadgen.py <base_url> <vus> <duration_s> [think_ms]
import sys, json, time, threading, random
import urllib.request, urllib.error
from concurrent.futures import ThreadPoolExecutor
BASE_URL, VUS, DURATION_S = sys.argv[1], int(sys.argv[2]), float(sys.argv[3])
THINK_MS = int(sys.argv[4]) if len(sys.argv) > 4 else 0
ROOM_RATES = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}
TIERS = ["basic", "pro"]
def expected_price(room, tier, hours):
total = ROOM_RATES[room] * hours
if tier == "pro":
total = total * 80 // 100
return total
lock = threading.Lock()
c = {"iterations": 0, "status_ok": 0, "status_bad": 0,
"price_ok": 0, "price_bad": 0, "http_errors": 0}
def default_fn(rng):
"""El guion de un VU: cotizar y verificar (como default() en k6)."""
room, tier, hours = rng.choice(list(ROOM_RATES)), rng.choice(TIERS), rng.randint(1, 8)
payload = json.dumps({"room": room, "tier": tier, "hours": hours}).encode()
req = urllib.request.Request(f"{BASE_URL}/quote", data=payload,
headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=5) as res:
status, body = res.status, json.loads(res.read())
except Exception:
with lock:
c["iterations"] += 1; c["http_errors"] += 1
c["status_bad"] += 1; c["price_bad"] += 1
return
ok_status = status == 200 # check 1
ok_price = body.get("price_cents") == expected_price(room, tier, hours) # check 2
with lock:
c["iterations"] += 1
c["status_ok"] += ok_status; c["status_bad"] += (not ok_status)
c["price_ok"] += ok_price; c["price_bad"] += (not ok_price)
if THINK_MS:
time.sleep(THINK_MS / 1000.0) # think time (sleep)
def vu_loop(vu_id, deadline):
rng = random.Random(vu_id)
while time.perf_counter() < deadline: # el bucle del VU
default_fn(rng)
def main():
start = time.perf_counter(); deadline = start + DURATION_S
with ThreadPoolExecutor(max_workers=VUS) as pool: # VUS workers = VUS VUs
for vu_id in range(1, VUS + 1):
pool.submit(vu_loop, vu_id, deadline)
elapsed = time.perf_counter() - start
passed = c["status_ok"] + c["price_ok"]
total = passed + c["status_bad"] + c["price_bad"]
pct = passed / total * 100 if total else 0
print(f" vus............: {VUS}")
print(f" iterations.....: {c['iterations']} ({c['iterations']/elapsed:.1f}/s)")
print(f" checks.........: {pct:.2f}% ({passed} de {total})")
print(f" status is 200....: {c['status_ok']} ok / {c['status_bad']} fail")
print(f" price is correct.: {c['price_ok']} ok / {c['price_bad']} fail")
print(f" http_errors....: {c['http_errors']}")
if __name__ == "__main__":
main()
Con el servidor corriendo (Paso 1) y su puerto en port.txt, córrelo con 20 VUs, 10 segundos y think time de 500 ms:
python3 loadgen.py "http://127.0.0.1:$(cat port.txt)" 20 10 500
Qué esperar. Con sleep(0.5), cada VU hace ~2 iteraciones por segundo; 20 VUs × 10 s × 2 ≈ 400 iteraciones. Bajo 20 VUs concurrentes contra un servidor local, es normal que aparezcan algunos errores de conexión —el sistema operativo rechaza alguna conexión bajo la ráfaga—, y eso es dato real, no un fallo del ejercicio. Salida real en este entorno:
vus............: 20
iterations.....: 408 (39.7/s)
checks.........: 98.04% (800 de 816)
status is 200....: 400 ok / 8 fail
price is correct.: 400 ok / 8 fail
http_errors....: 8
Léela con calma, porque es el vuelo real:
vus: 20yiterations: 408— 20 usuarios concurrentes completaron 408 vueltas en 10 segundos. La fórmula predijo ~400 (20 × 10 / 0.5); el real fue 408. El modelo del VU, medido.checks: 98.04% (800 de 816)— 816 checks totales (408 iteraciones × 2), de los cuales 800 pasaron. No es 100 %, y eso es lo interesante: bajo carga real, algo falló.http_errors: 8— ocho iteraciones no lograron conectarse. Bajo 20 VUs golpeandolocalhosta la vez, el SO rechazó 8 conexiones. Esas 8 iteraciones fallaron sus dos checks (status y precio), lo que explica el8 failen cada criterio y por qué los checks bajaron de 100 %.- La lección honesta. Ninguna de las corridas de un solo dígito de VUs (lecciones 2-6) mostró errores; esta, con 20 VUs, sí. Esa es la esencia de una prueba de carga: el comportamiento cambia bajo concurrencia. Con pocos usuarios todo es verde; al subir la carga, aparecen los errores que en producción serían usuarios reales viendo una pantalla rota. Encontrar ese punto —y medirlo— es para lo que existe todo esto.
La reflexión: el mapeo entre las dos caras
La cuarta parte del entregable es escribir, con tus palabras, cómo se corresponden el script de k6 y el generador de Python. Esta tabla es la guía; complétala mentalmente (o por escrito) verificando cada fila en los dos archivos que construiste:
| Pieza | En quote_test.js (k6, contenido) | En loadgen.py (Python, ejecutado) |
|---|---|---|
| Reparto | options = { vus: 20, duration: '10s' } | argumentos VUS=20, DURATION_S=10 |
| Un VU | un virtual user que hace loop | un worker de ThreadPoolExecutor en vu_loop |
| El bucle | k6 envuelve default (implícito) | while time.perf_counter() < deadline |
| El guion | export default function () {...} | default_fn(rng) |
| La petición | http.post(url, payload, params) | urllib.request.urlopen(req) |
| El cuerpo | JSON.stringify({...}) | json.dumps({...}).encode() |
| El header | params.headers['Content-Type'] | headers={"Content-Type": ...} |
| Check status | 'status is 200': (r) => r.status === 200 | ok_status = status == 200 |
| Check precio | 'price is 7500': (r) => r.json('price_cents') === 7500 | ok_price = body[...] == expected_price(...) |
| Think time | sleep(0.5) | time.sleep(THINK_MS / 1000) |
| Resumen | bloque checks/iterations/vus | las líneas impresas checks/iterations/vus |
Y anota las diferencias honestas: el resumen de k6 trae percentiles (p(90), p(95)) y separa métricas de red (http_req_waiting, http_req_sending) que el generador de Python aún no calcula —eso es maquinaria de k6, y las métricas a fondo son el módulo 3—. El generador reporta lo que este módulo necesita: cuántos VUs, cuántas iteraciones, cuántos checks, cuántos errores. Ambos resúmenes leen el mismo mundo; el de k6 lo lee con más instrumentos.
Rúbrica de tu mini-proyecto
Tu entregable está completo si cumple estas cuatro partes:
- (1) El script de k6 (contenido). ¿Tiene las cinco piezas —
optionsconvus/duration,default,http.postcon cuerpo JSON y header,checkcon status y precio,sleep— correctas y en su lugar? ¿Elhttp.postestá en el orden(url, body, params)? ¿El cuerpo va conJSON.stringifyy el headerContent-Type? - (2) El resumen de k6 (contenido), leído. ¿Puedes ubicar y explicar
vus,iterations,http_reqsychecks, y decir por quéiterationsno es igual avus? ¿Reconoces quehttp_req_durationexiste pero es del módulo 3? - (3) El generador de Python (ejecutado). ¿Corre contra la API canónica y produce una salida real con
vus,iterations,checksyhttp_errors? ¿UsaThreadPoolExecutorconmax_workers = vus(un worker por VU)? ¿Hace los dos checks (status y precio)? - (4) La reflexión. ¿Puedes mapear al menos cinco piezas entre las dos caras (petición, check, think time, reparto, bucle) y nombrar una diferencia honesta entre los resúmenes?
Si las cuatro están, dominas la anatomía del script y el modelo del VU —el objetivo entero del módulo—.
Errores comunes
Correr el generador sin arrancar la API primero. Qué pasa: alguien corre loadgen.py sin haber lanzado reservo_api.py, y todas las iteraciones fallan con error de conexión (http_errors altísimo). Por qué pasa: no hay servidor escuchando en el puerto. Cómo detectarlo: http_errors iguala a iterations y los checks quedan en 0 %. Cómo corregirlo: arranca la API en una terminal y déjala corriendo; en otra, corre el generador leyendo el puerto de port.txt. Es la regla de las dos terminales: servidor arriba, generador después.
Leer un puerto viejo de port.txt. Qué pasa: alguien reinicia la API (que toma un puerto nuevo, porque usa puerto 0) pero el generador apunta a un puerto anterior que ya no escucha. Por qué pasa: port.txt quedó de una corrida previa o se leyó antes de que el nuevo servidor lo reescribiera. Cómo detectarlo: http_errors alto pese a tener "una" API corriendo. Cómo corregirlo: asegúrate de leer port.txt después de arrancar la API actual ($(cat port.txt) en el mismo momento de correr el generador). Con puerto 0, el puerto cambia en cada arranque.
Interpretar los errores bajo carga como un fallo del ejercicio. Qué pasa: alguien ve los 8 http_errors con 20 VUs y cree que hizo algo mal. Por qué pasa: las corridas de pocos VUs fueron 100 % verdes, así que un fallo se siente como error propio. Cómo detectarlo: los errores aparecen solo al subir la concurrencia, no con 1-5 VUs. Cómo corregirlo: entiende que eso es el resultado, no un bug. Una prueba de carga existe justo para revelar que el sistema se comporta distinto bajo concurrencia. Los 8 errores son un dato válido: bajo 20 VUs, ~2 % de las conexiones no prosperaron. Medir eso es el trabajo.
Ejercicios
Ejercicio 1 — Cambia el reparto. Modifica el generador para correr con 10 VUs durante 6 segundos y think time de 1000 ms. (a) ¿Cuántas iteraciones aproximadas esperas? (b) ¿Crees que verás http_errors, comparado con la corrida de 20 VUs? Justifica.
Ver solución
- (a) Con
sleep(1), cada VU hace ~1 iteración/s: 10 VUs × 6 s ≈ ~60 iteraciones. (El comando seríapython3 loadgen.py "http://127.0.0.1:$(cat port.txt)" 10 6 1000.) - (b) Probablemente menos o ningún error que con 20 VUs. Dos razones: hay la mitad de VUs concurrentes (10 vs 20), así que menos conexiones simultáneas presionando al SO; y el think time es mayor (1000 ms vs 500 ms), lo que espacia aún más las peticiones. Menos concurrencia y más pausa = menos probabilidad de conexiones rechazadas. Esa relación —más VUs y menos think time producen más estrés— es justo lo que una prueba de carga explora.
Ejercicio 2 — Del script al resumen. Para el script quote_test.js con options = { vus: 20, duration: '10s' } y sleep(0.5), y sin errores, predice tres líneas del resumen de k6: (a) iterations; (b) http_reqs; (c) checks.
Ver solución
- (a)
iterations: ~400— consleep(0.5), cada VU hace ~2 vueltas/s: 20 × 10 × 2 = 400. - (b)
http_reqs: ~400— el guion hace una petición (http.post) por iteración, así que peticiones ≈ iteraciones. - (c)
checks: 100.00% ✓ 800 ✗ 0— dos checks por iteración (status y precio): 400 × 2 = 800, todos verdes si no hay errores.
(En la corrida real de Python con la misma configuración hubo 408 iteraciones y 8 errores, así que los checks fueron 800 de 816 = 98.04 %. El resumen de k6 mostrado asume una corrida sin errores; la realidad bajo 20 VUs metió 8. Ambos son válidos: uno es el modelo ideal, el otro la medición real.)
Ejercicio 3 — Justifica el puente. Tu reflexión debe explicar por qué el generador de Python es un modelo fiel de los VUs de k6 y no solo "algo parecido". Elige tres piezas del script de k6 y explica, para cada una, qué la modela en Python y por qué la correspondencia es exacta (no aproximada).
Ver solución
Tres correspondencias exactas (cualquiera de estas sirve):
- El VU y su bucle. En k6, un VU repite
defaulten un bucle que k6 envuelve. En Python, cada worker delThreadPoolExecutorcorrevu_loop, que es literalmentewhile time.perf_counter() < deadline: default_fn(). La correspondencia es exacta: N workers = N VUs, cada uno repitiendo el guion hasta la duración. El bucle que en k6 está oculto, en Python está a la vista, pero es el mismo bucle. - La petición con cuerpo y headers.
http.post(url, JSON.stringify(payload), { headers: {...} })en k6 manda un POST con cuerpo JSON yContent-Type. En Python,urllib.request.Request(url, data=json.dumps(payload).encode(), headers={"Content-Type": ...}, method="POST")manda exactamente lo mismo: el mismo método, el mismo cuerpo serializado, el mismo header. El servidor recibe bytes idénticos; no distingue quién los mandó. - El check.
check(res, { 'status is 200': (r) => r.status === 200 })evalúa una condición sobre la respuesta y la cuenta. En Python,ok_status = status == 200seguido de sumar astatus_ok/status_badhace lo mismo: evalúa la condición y lleva la cuenta. La semántica —verificar sin abortar y contar— es idéntica.
La correspondencia es exacta porque ambos hablan el mismo protocolo HTTP contra la misma API y aplican la misma lógica (mismo cuerpo, mismo criterio de precio con descuento pro entero). No es "parecido": es el mismo mundo, medido con dos herramientas.
Resumen y siguiente paso
En este mini-proyecto juntaste todo el módulo en un entregable de cuatro partes: un script de k6 completo para /quote (contenido, con las cinco piezas —options, default, http.post, check, sleep—), su resumen leído (contenido), un generador de Python que modela los mismos VUs con ThreadPoolExecutor y su corrida real contra la API canónica, y una reflexión sobre el mapeo entre ambas caras. La corrida real —20 VUs, 408 iteraciones, 98.04 % de checks, 8 errores— enseñó la lección más honesta de la guía hasta ahora: el sistema se comporta distinto bajo carga. Con pocos VUs todo fue verde; con 20, aparecieron errores de conexión reales. Encontrar y medir ese cambio es la razón de ser de una prueba de carga.
Con esto cierras el módulo 2. Sabes leer y escribir un script de k6 entendiendo cada pieza, explicar el modelo del VU y la diferencia entre VUs e iteraciones, leer un resumen, y —sobre todo— modelar y medir ese comportamiento tú mismo con Python contra una API real. El simulador y el vuelo, y cómo se corresponden.
Lo que sigue, el módulo 3, abre las métricas a fondo: ese bloque http_req_duration que hasta ahora solo ubicamos se vuelve el protagonista. Aprenderás por qué el promedio miente y los percentiles (p90/p95/p99) mandan, qué es el throughput/RPS, y cómo leer la tasa de error —y el generador de Python empezará a calcular p95 y RPS reales con statistics, acercando su resumen aún más al de k6—. El "qué tan rápido" que aquí dejamos guardado es el corazón del próximo módulo.
Recursos
- Escribir tu primer test de carga con k6 — un recorrido oficial que arma un script como el de este mini-proyecto, con
http,check,sleepyoptions. La forma completa que ensamblaste. - El objeto Response y
res.json()—k6/http— cómo el check leeprice_centsdel cuerpo, la pieza del criterio de precio. La referencia de lo que se verifica. concurrent.futures.ThreadPoolExecutor— Documentación de Python — el pool de hilos que modela los VUs (un worker por VU). El motor del generador que ejecutaste.http.server— Documentación de Python — el servidor de la biblioteca estándar con el que corre la API canónica de Reservo, el blanco de carga. Cómo se levanta el servidor local.