Módulo 1: Por qué load y performance testing

8. Mini-proyecto: tu primera medición de carga

Descripción

Este es el capstone del módulo, donde juntas todo lo aprendido y lo haces con tus manos, de principio a fin. La misión es hacer tu primera medición de carga real contra la API de Reservo y, en paralelo, dejar escrito el equivalente en k6 como contenido —para tener los dos lados de la moneda que viste en la lección 7—. Concretamente, vas a: (1) levantar la API de Reservo local (el servidor de la lección 6) y confirmar que responde; (2) escribir un mini-generador de carga en Python que la golpee con N peticiones concurrentes a /quote y reporte latencia mín/máx/promedio y p95 real; (3) correrlo de verdad y leer sus números; y (4) escribir el script de k6 equivalente como contenido rotulado. Al terminar tendrás tres artefactos —el servidor, el generador ejecutado con su salida real, y el script de k6— y, sobre todo, habrás respondido con datos una pregunta que un test funcional no puede: ¿qué latencia p95 tiene mi API bajo N usuarios concurrentes?

Conexión con el módulo: este proyecto es la síntesis. Usa el blanco de la lección 6, la técnica de la lección 7, las métricas de la lección 3 y la honestidad de la regla del entorno (Python se ejecuta, k6 va como contenido) de la lección 5. Es el ensayo de lo que harás a lo largo de toda la guía, en pequeño. El proyecto final de la guía (módulo 8) es este mismo recorrido, pero con perfiles de carga completos, thresholds que gatean, checks de un flujo cotizar→reservar y CI. Aquí plantas la semilla.

Qué vas a entregar

Un mini-proyecto con cuatro piezas:

  1. La API de Reservo corriendo. El servidor reservo_server.py de la lección 6, levantado en localhost (puerto 0), y una comprobación de que responde el número-ancla (/quote de Focus/basic/3h → 7500).
  2. El mini-generador en Python. Un script que lanza N peticiones concurrentes a /quote y reporta latencia mín/máx/promedio/p95, más la verificación de que todas las respuestas fueron correctas.
  3. La salida real del generador. El texto que imprimió tu generador al correrlo —tus números medidos—, para al menos dos niveles de concurrencia (para ver la tendencia).
  4. El script de k6 equivalente (contenido). El .js que haría la misma prueba en k6, claramente rotulado como contenido (no ejecutado, salvo que instales k6).

Los pasos, uno por uno

Paso 1 — Levantar el blanco

Dónde estás: partes de cero; necesitas la API viva antes de medir nada. Toma el servidor de la lección 6 (reservo_server.py), guárdalo, y arráncalo. Escribe en el puerto 0 para no chocar con otros procesos; el servidor deja el puerto elegido en un archivo PORT.

Qué esperar — al arrancar, imprime el puerto; una cotización de prueba devuelve el ancla 7500. Salida real (referencia):

$ python3.14 reservo_server.py &
Reservo escuchando en http://127.0.0.1:51568

$ curl -s -X POST http://127.0.0.1:51568/quote \
    -H 'Content-Type: application/json' \
    -d '{"room":"Focus","tier":"basic","hours":3}'
{"price_cents": 7500}

Si ves 7500, el blanco está listo. (Esto es, además, tu smoke test de la lección 4: confirmar que la API vive y responde correcto antes de lanzarle carga.)

Paso 2 — Escribir el mini-generador

Dónde estás: el blanco responde; ahora construyes el instrumento de medición. Escribe un script que: (a) reciba la URL base, el número total de peticiones y la concurrencia; (b) use un ThreadPoolExecutor para mantener esa concurrencia de peticiones en vuelo; (c) mida cada latencia con time.perf_counter; y (d) reporte mín, promedio, máx y p95, más si todas las respuestas fueron 7500. Tienes el código completo en la lección 7; el reto es entender cada parte, no copiarla a ciegas.

Los tres puntos que no pueden faltar:

  • La concurrencia va en max_workers. Es la que crea la carga (lección 2). Sin concurrencia real, mides peticiones sueltas, no carga.
  • El cronómetro rodea solo la petición HTTP. perf_counter justo antes de enviar y justo después de recibir; nada más dentro.
  • El p95 se obtiene ordenando y cortando. Ordena las latencias y toma la de la posición del 95%.

Paso 3 — Correrlo y leer los números

Dónde estás: tienes blanco e instrumento; toca medir. Corre el generador para al menos dos niveles de concurrencia —por ejemplo 20 y 50— para ver cómo responde el p95. Corre cada uno un par de veces: los números varían un poco entre corridas (es normal; la latencia es una medición estadística, no un valor fijo).

Qué esperar — a mayor concurrencia, más contención, p95 más alto; todas las respuestas correctas. Salida real (referencia, dos niveles):

$ python3.14 load_generator.py http://127.0.0.1:51568 200 20
peticiones .......... 200 (concurrencia 20)
todas devolvieron ... price_cents=7500 (correcto: True)
duracion total ...... 0.048 s
throughput .......... 4157.5 req/s
latencia min ........ 2.67 ms
latencia promedio ... 4.50 ms
latencia max ........ 20.20 ms
latencia p95 ........ 17.21 ms

$ python3.14 load_generator.py http://127.0.0.1:51568 1000 50
peticiones .......... 1000 (concurrencia 50)
todas devolvieron ... price_cents=7500 (correcto: True)
duracion total ...... 0.180 s
throughput .......... 5549.9 req/s
latencia min ........ 4.84 ms
latencia promedio ... 8.67 ms
latencia max ........ 38.43 ms
latencia p95 ........ 22.15 ms

Lee tus números con las preguntas de la lección 3 en la mano: ¿cuál es tu p95 en cada nivel? ¿Cuánto subió al aumentar la concurrencia? ¿El promedio esconde una cola (compara promedio con máx)? Con la corrida de 50 concurrentes de referencia, el promedio (8.67 ms) es menos de la mitad del p95 (22.15 ms) y menos de un cuarto del máx (38.43 ms): la cola está ahí, y solo el p95 y el máx la muestran. Estos son tus datos medidos —lo que un test funcional jamás te habría dado—.

Paso 4 — Escribir el equivalente en k6 (contenido)

Dónde estás: ya mediste de verdad con Python; ahora dejas escrito cómo se haría con la herramienta industrial. Escribe el script de k6 que hace la misma prueba —POST /quote con la misma concurrencia— y rotúlalo como contenido (no lo ejecutas, salvo que instales k6). Tienes el modelo en la lección 7. Lo importante es que el vus de k6 coincida conceptualmente con tu max_workers, y que el check de k6 verifique el mismo 7500 que tu generador comprueba.

// CONTENIDO (no ejecutado aqui, salvo que instales k6): equivalente en k6.
// Se correria con: k6 run quote_test.js
import http from "k6/http";
import { check } from "k6";

export const options = {
  vus: 50,          // <-- coincide con max_workers=50 del generador Python
  duration: "10s",
};

export default function () {
  const url = "http://127.0.0.1:8000/quote";
  const payload = JSON.stringify({ room: "Focus", tier: "basic", hours: 3 });
  const params = { headers: { "Content-Type": "application/json" } };

  const res = http.post(url, payload, params);

  check(res, {
    "status es 200": (r) => r.status === 200,
    "price_cents es 7500": (r) => r.json("price_cents") === 7500,
  });
}

Guárdalo junto a los otros artefactos. Si algún día instalas k6, este archivo corre tal cual contra tu API de Reservo.

La reflexión (parte de la entrega)

Un mini-proyecto de carga no termina en los números; termina en lo que los números significan. Escribe un párrafo corto respondiendo:

  • ¿Qué respondió tu medición que un test funcional no habría podido? (Pista: un test funcional te dice que /quote devuelve 7500; tu generador te dice cuánto tarda bajo N usuarios concurrentes —una propiedad distinta, la de esta guía—.)
  • ¿Cómo cambió tu p95 al subir la concurrencia, y qué sugiere eso?
  • ¿Por qué reportarías el p95 y no el promedio si tuvieras que prometerle una latencia a un cliente?

Rúbrica de autoevaluación

CriterioInsuficienteBienExcelente
API levantadaNo arranca o no responde 7500Arranca y responde el anclaArranca en puerto 0 y verificas /rooms, /quote (basic y pro) y /book
Concurrencia realConcurrencia 1 (peticiones sueltas)Usa un pool con max_workers > 1Mides a dos o más niveles de concurrencia y comparas
Latencia medidaSolo promedio, o mal cronometradaReportas mín/promedio/máx/p95Además comparas p95 vs promedio y explicas la cola
Corrección bajo cargaNo verificas las respuestasConfirmas que todas dan 7500Lo reportas explícitamente (correcto: True)
Script k6 (contenido)Ausente o presentado como ejecutadoPresente y rotulado como contenidoRotulado, y el vus/check corresponden al generador
ReflexiónAusenteExplicas qué medisteConectas p95↔promedio↔experiencia del usuario

Apunta al "Excelente" en al menos las columnas de concurrencia y latencia: son el corazón de lo que enseña esta guía.

Errores comunes (en este proyecto)

Entregar una corrida de concurrencia 1 y llamarla "prueba de carga". Sin concurrencia no hay contención, y el p95 sale casi igual al promedio (lo viste en la lección 2: 0.35 vs 0.33 ms). Súbela; la carga nace de la simultaneidad.

Presentar el resumen de k6 como si lo hubieras corrido. Si no instalaste k6, no lo ejecutaste. Rotula el script y el resumen de k6 como contenido; tus números medidos son los del generador Python. Esta honestidad es parte de la disciplina del performance testing, no un tecnicismo.

Fiarte de una sola corrida. La latencia varía entre corridas; una sola foto puede engañar. Corre cada nivel un par de veces y mira si los números son estables. (En la guía profundizaremos en cómo dar rigor a esto.)

Ejercicios

Ejercicio 1 — Amplía el reporte. Modifica (mentalmente o en código) tu generador para que reporte también el p50 (mediana) y el p99. (a) ¿Cómo los calcularías a partir de la lista ordenada de latencias? (b) En la corrida de referencia de 50 concurrentes (promedio 8.67, p95 22.15, máx 38.43), ¿esperarías que el p99 esté más cerca del p95 o del máximo, y por qué?

Ver solución
  • (a) Con latencies ordenada: p50 = latencies[int(len(latencies) * 0.50)] y p99 = latencies[int(len(latencies) * 0.99)] (con el mismo cuidado de no salirte del índice que ya tiene el p95). O con statistics.quantiles(latencies, n=100) para obtener todos los percentiles de una vez (lo veremos en el módulo 3).
  • (b) El p99 estaría entre el p95 (22.15) y el máximo (38.43), probablemente más cerca del p95 que del máximo. El máximo es el peor caso único —una sola petición desafortunada—, mientras el p99 sigue siendo un percentil (el peor 1%), que suele quedar por debajo del extremo absoluto. La distancia entre p99 y máx te dice cuán "puntiaguda" es la cola: si el máx dispara muy por encima del p99, hubo uno o dos casos extremos aislados.

Ejercicio 2 — Diseña un smoke antes del load. Antes de tu corrida grande, ¿qué smoke test mínimo correrías para no desperdiciar una prueba larga con un script roto? Describe el comando y qué mirarías en su salida.

Ver solución

Un smoke sería correr el generador con muy poca carga —por ejemplo python3.14 load_generator.py http://127.0.0.1:PORT 5 1 (5 peticiones, concurrencia 1)—. En su salida miraría correcto: True: que las pocas respuestas fueron el 7500 esperado, confirmando que el script apunta bien, el cuerpo JSON está correcto y la API responde. Los números de latencia con 5 peticiones no importan; el smoke solo valida que todo el andamiaje funciona antes de lanzar 1000 concurrentes. (Regla de oro de la lección 4: siempre el smoke primero.)

Ejercicio 3 — Interpreta para un no técnico. Tu jefe, que no es técnico, ve tu reporte de 50 concurrentes (promedio 8.67 ms, p95 22.15 ms) y pregunta: "¿Entonces la app responde en 8.67 milisegundos?". Respóndele en dos o tres frases, sin jerga, corrigiendo con honestidad y usando la cifra correcta.

Ver solución

Un ejemplo de respuesta honesta y sin jerga: "8.67 ms es el promedio, pero el promedio esconde a los usuarios que peor la pasaron. La cifra más honesta es que el 95% de las peticiones respondió en 22 ms o menos —es decir, 1 de cada 20 usuarios esperó un poco más que eso—. Prometer 8.67 sería quedarnos con la parte bonita; 22 ms (el p95) es lo que de verdad podemos garantizarle a casi todos." Lo importante es no vender el promedio como si fuera la experiencia de todos, y usar el p95 como la promesa sostenible.

Resumen y siguiente paso

En este mini-proyecto hiciste tu primera medición de carga real, de principio a fin y con tus manos: levantaste la API de Reservo (confirmando el ancla 7500), escribiste y corriste un mini-generador en Python que la golpea con N peticiones concurrentes y reporta latencia mín/máx/promedio/p95, leíste tus números medidos a dos niveles de concurrencia (viendo el p95 subir con la carga), y dejaste escrito el script de k6 equivalente como contenido. Y —lo más importante— respondiste con datos una pregunta que ningún test funcional puede: ¿qué latencia p95 tiene mi API bajo carga?

Con esto cierras el módulo 1. Ya sabes por qué existe el performance testing (es una pregunta distinta de la corrección: ¿aguanta? frente a ¿funciona?), qué preguntas responde (capacidad, latencia p95, punto de quiebre), qué tipos hay (smoke, load, stress, spike, soak), qué es k6 y su runtime propio, cómo es la API de Reservo canónica, y cómo se siente lanzar carga y medir —con números reales—. Tienes el mapa completo y ya diste el primer paso ejecutado.

Lo que sigue es abrir la herramienta industrial en serio. En el módulo 2 diseccionamos la anatomía de un script de k6 y el modelo de VUs: qué es exactamente un usuario virtual, cómo la función default es su bucle, cómo http.get/post, check y sleep componen una iteración, y cómo export const options da forma a la carga. Todo lo que aquí viste "de lejos" en el script de k6, allá lo abrimos pieza por pieza —y el generador Python seguirá siendo tu banco de pruebas ejecutable para cada concepto—.

Recursos