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

2. Funcional vs carga: dos preguntas distintas

Descripción

En la lección anterior enunciamos la distinción; en esta la abrimos hasta el hueso, porque de entenderla bien depende que sepas cuándo escribir una prueba de carga y qué esperar de ella. La idea es esta: un test funcional y un test de carga no son "dos versiones de lo mismo con distinto tamaño". Son pruebas de naturaleza distinta, que fijan cosas distintas y miden cosas distintas, y que pueden dar veredictos opuestos sobre el mismísimo sistema. Un test funcional fija una entrada y verifica una salida: le paso a /quote la sala Focus, plan basic, 3 horas, y compruebo que responde exactamente 7500. Pasa o falla por corrección: o el número es el correcto, o no lo es. Un test de carga fija un nivel de tráfico —cuántos usuarios, durante cuánto tiempo— y mide propiedades agregadas: la latencia (¿cuánto tardó?), el throughput (¿cuántas por segundo?), la tasa de error (¿cuántas fallaron?). Pasa o falla por rendimiento bajo presión: no importa si un número es correcto, sino si el sistema aguantó y respondió a tiempo.

Conexión con el módulo: esta lección es el contraste central del que cuelga todo lo demás. Ya conoces la API de Reservo (lección 1); aquí la usamos como banco para ver las dos pruebas lado a lado —una petición funcional con curl frente a un chorro de peticiones concurrentes con el generador Python—. La lección 3 tomará el lado de carga y lo descompondrá en sus tres preguntas (usuarios, p95, punto de quiebre). La 4 mostrará que "un nivel de tráfico" tiene varias formas (los tipos: smoke, load, stress...). Aquí nos quedamos en el porqué: por qué son dos preguntas, por qué necesitas las dos, y por qué la de carga no se puede deducir de la funcional.

Dos preguntas sobre el mismo puente

Un ingeniero recibe un puente nuevo y le hace dos clases de prueba, porque responden dos preguntas que no se deducen una de la otra.

La primera: ¿el puente está bien construido? Camina por él, revisa que cada junta esté soldada, que la barandilla no tenga huecos, que el asfalto esté parejo. Es una inspección de corrección: ¿está cada pieza donde debe, hace cada cosa lo que debe? Un puente puede pasar esta inspección con sobresaliente —todo perfectamente soldado, sin un defecto— y aun así no sabemos lo segundo.

La segunda: ¿cuánto peso aguanta? El ingeniero pone camiones encima. Uno, y mide cuánto se flexiona. Diez, y vuelve a medir. Cincuenta, cien, hasta encontrar el punto donde la estructura empieza a ceder. Es una prueba de carga: no le importa si una junta está bien soldada (eso ya se revisó), le importa cuánta demanda soporta antes de fallar y cuánto se deforma bajo cada nivel de peso. Y aquí está lo crucial: un puente impecablemente construido puede colapsar con cincuenta camiones, y un puente feo y mal terminado puede aguantar quinientos. Las dos propiedades —corrección y capacidad— son independientes. Saber que el puente está bien soldado no te dice cuánto peso aguanta, y saber que aguanta mucho peso no te dice si la barandilla tiene huecos.

Tu API es el puente. El test funcional revisa las soldaduras: ¿/quote de Focus/basic/3h devuelve 7500? El test de carga pone los camiones: ¿cuántas peticiones concurrentes aguanta, y cuánto se "flexiona" (sube la latencia) bajo cada nivel? Necesitas las dos inspecciones, y ninguna te ahorra la otra.

Ejemplo trabajado: la misma API, dos pruebas

Vamos a hacer las dos pruebas sobre el mismo endpoint —POST /quote— para que la diferencia deje de ser abstracta. Supón que la API de Reservo ya está corriendo en localhost (la construimos en la lección 6; por ahora basta con ver las dos pruebas).

La prueba funcional: una petición, un veredicto de corrección

Una prueba funcional lanza una petición con una entrada conocida y verifica que la salida es la esperada. Con curl:

Qué esperar — al pedir una cotización de Focus/basic/3h, la respuesta debe ser exactamente {"price_cents": 7500}. Esto es salida real, ejecutada contra el servidor de Reservo en localhost:

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

Veredicto: correcto. La API devolvió 7500, que es lo que esperábamos (2500 × 3). Fíjate en lo que esta prueba no midió: no sabemos cuánto tardó (bueno, tardó algo, pero no lo medimos ni nos importó), no sabemos qué pasaría con cien de estas a la vez, no sabemos si la respuesta seguiría siendo 7500 bajo presión. La prueba funcional fijó la entrada (Focus/basic/3h) y verificó la salida (7500). Punto. Es rápida, es barata, y responde una sola pregunta: ¿es correcto? Sí.

La prueba de carga: muchas peticiones, un veredicto de rendimiento

Una prueba de carga lanza muchas peticiones —idealmente concurrentes, para simular usuarios reales pegando a la vez— y no verifica un número, sino que mide el comportamiento agregado. Aquí usamos el mini-generador en Python (lo construimos en la lección 7; por ahora, mira lo que reporta). Le pedimos 500 peticiones con 50 concurrentes contra el mismo /quote:

Qué esperar — el generador lanza 500 cotizaciones de Focus/basic/3h con 50 clientes a la vez, mide la latencia de cada una y reporta el agregado. Esto es salida real, ejecutada contra el servidor de Reservo:

$ python3.14 load_generator.py http://127.0.0.1:PORT 500 50
peticiones .......... 500 (concurrencia 50)
todas devolvieron ... price_cents=7500 (correcto: True)
duracion total ...... 0.103 s
throughput .......... 4856.2 req/s
latencia min ........ 2.74 ms
latencia promedio ... 9.60 ms
latencia max ........ 46.19 ms
latencia p95 ........ 29.13 ms

Veredicto: depende de tu umbral. La prueba de carga no dice "correcto" o "incorrecto"; entrega números que tú comparas contra un objetivo. Con 50 usuarios concurrentes, la API sostuvo unas 4856 peticiones por segundo, con una latencia que en el 95% de los casos estuvo por debajo de 29 ms, aunque alguna llegó a 46 ms. ¿Está bien? Si tu objetivo era "p95 por debajo de 500 ms", pasa con holgura. Si era "p95 por debajo de 10 ms", falla. Ese umbral —el threshold— es el que convierte los números en un veredicto, y es el tema del módulo 5.

Fíjate en dos cosas de esta salida. Primero: el generador también verificó la corrección (price_cents=7500 (correcto: True)) —incluso bajo carga, las 500 respuestas fueron correctas—. Eso es un check de corrección dentro de la carga, algo que profundizaremos en el módulo 6; una prueba de carga puede (y suele) verificar que las respuestas siguen siendo correctas mientras mide el rendimiento. Segundo, y más importante: mira la brecha entre el promedio (9.60 ms) y el máximo (46.19 ms). El promedio dice una cosa amable; la cola de la distribución dice otra. Esa brecha es la razón de ser del percentil p95, y es el corazón de la lección 3.

Poniéndolas lado a lado

Prueba funcionalPrueba de carga
Qué fijaUna entrada (Focus/basic/3h)Un nivel de tráfico (500 peticiones, 50 concurrentes)
Qué mide/verificaQue la salida sea correcta (7500)Latencia, throughput, tasa de error (agregados)
Cómo pasa/fallaCorrecto vs incorrectoLos números vs un umbral (threshold)
Cuántas peticionesUna (o unas pocas)Muchas, concurrentes
Pregunta que responde¿Funciona?¿Aguanta, y qué tan rápido bajo presión?
Herramienta en esta guíacurl, un test funcionalk6 (contenido), generador Python (ejecutado)

Las dos golpearon el mismo endpoint, con la misma entrada correcta, y midieron cosas distintas. Ese es el punto entero de la lección.

Por qué la de carga no se deduce de la funcional

Aquí está la trampa que atrapa a mucha gente: creer que si el sistema es correcto y "se siente rápido" en una petición, entonces aguantará bajo carga. No se deduce, y la razón es la contención de recursos.

Cuando una sola petición llega a tu API, tiene todos los recursos para ella: un núcleo del procesador libre, memoria de sobra, la conexión a la base de datos disponible. Responde rápido —en Reservo, fracciones de milisegundo—. Pero cuando cincuenta peticiones llegan a la vez, compiten: por los núcleos, por los hilos del servidor, por las conexiones a la base de datos, por el candado que protege un recurso compartido. Algunas tienen que esperar su turno, y esa espera se suma a su latencia. Por eso el mismo endpoint que responde en 0.3 ms sin contención puede responder en 29 ms de p95 con 50 clientes encima. No es que el código sea más lento; es que las peticiones hacen fila.

Podemos verlo con el generador, contrastando dos corridas contra la misma API. Primero sin contención (concurrencia 1: una petición a la vez, cada una con todos los recursos):

Qué esperar — con un solo cliente secuencial, no hay fila; la latencia es minúscula y el p95 casi igual al promedio. Salida real:

$ python3.14 load_generator.py http://127.0.0.1:PORT 100 1
peticiones .......... 100 (concurrencia 1)
todas devolvieron ... price_cents=7500 (correcto: True)
duracion total ...... 0.036 s
throughput .......... 2795.5 req/s
latencia min ........ 0.22 ms
latencia promedio ... 0.33 ms
latencia max ........ 6.43 ms
latencia p95 ........ 0.35 ms

Con un solo cliente, el p95 es 0.35 ms —prácticamente igual al promedio (0.33 ms)—: sin fila, casi todas las peticiones tardan lo mismo. Ahora con contención (concurrencia 50), que ya viste arriba: el p95 sube a 29.13 ms, casi cien veces más, y se despega del promedio. Misma API, mismo código correcto, latencia radicalmente distinta —solo cambió cuánta gente pega a la vez—. Esa es la propiedad que una prueba funcional jamás revela, porque siempre mide con un cliente y la cocina vacía. Y es la razón de existir de esta guía.

Errores comunes

Correr una prueba de carga con concurrencia 1 y creer que mediste carga. Qué pasa: alguien lanza 10.000 peticiones pero de una en una (secuencial), ve un p95 bajísimo, y declara la API "probada bajo carga". Por qué pasa: se confunde muchas peticiones en total con muchas a la vez. La carga la crea la concurrencia, no el volumen acumulado. Cómo detectarlo: si tu prueba nunca tiene dos peticiones en vuelo al mismo tiempo, no hay contención, y no mediste carga —mediste 10.000 pruebas funcionales seguidas—. Cómo corregirlo: la carga se define por cuántos usuarios/clientes pegan simultáneamente (los VUs de k6, el max_workers del generador). El contraste de arriba (concurrencia 1 vs 50) lo muestra: solo la segunda mide algo de carga.

Esperar que una prueba de carga diga "correcto" o "incorrecto". Qué pasa: alguien corre k6, ve el resumen lleno de números, y no sabe si "pasó". Por qué pasa: viene del mundo funcional, donde una prueba da un booleano (verde/rojo). Cómo detectarlo: si miras un p95 de 29 ms y no sabes si es bueno o malo, te falta el objetivo. Cómo corregirlo: una prueba de carga entrega mediciones; el veredicto lo da un threshold —un umbral como "p95 < 500 ms"— que tú defines a partir de tus requisitos (el módulo 5). Sin umbral, los números son solo información, no aprobación.

Deducir el comportamiento bajo carga de una sola petición rápida. Qué pasa: se mide un curl que tarda 3 ms y se concluye "la API es rápida, aguantará". Por qué pasa: la intuición dice que si una es rápida, muchas también. Pero la contención rompe esa intuición. Cómo detectarlo: si tu evidencia de "aguanta carga" es una medición sin concurrencia, no tienes evidencia. Cómo corregirlo: mide con concurrencia creciente y observa cómo cambia el p95 —que es justo lo que hace el generador Python y lo que hace k6 con sus VUs—.

Ejercicios

Ejercicio 1 — ¿Qué fija y qué mide cada prueba? Para cada descripción, di si es una prueba funcional o de carga, y nombra qué fija y qué mide/verifica. (a) "Enviar /book con Focus/basic/3h y comprobar que confirmed es true." (b) "Lanzar 1000 peticiones a /quote con 100 concurrentes y reportar el p95." (c) "Mandar /quote con una sala inexistente y comprobar que responde 400." (d) "Sostener 200 usuarios cotizando durante 10 minutos y medir la tasa de error."

Ver solución
  • (a) Funcional. Fija una entrada (Focus/basic/3h) y verifica una salida (confirmed == true). Corrección de una petición.
  • (b) Carga. Fija un nivel de tráfico (1000 peticiones, 100 concurrentes) y mide una métrica de latencia (p95). Rendimiento bajo presión.
  • (c) Funcional. Fija una entrada inválida (sala inexistente) y verifica la respuesta correcta a ese caso (status 400). Corrección del manejo de errores; una sola petición.
  • (d) Carga. Fija un nivel de tráfico sostenido (200 usuarios, 10 minutos) y mide la tasa de error. Rendimiento —y, por la duración, un tipo particular que verás en la lección 4 (soak)—.

Ejercicio 2 — Interpreta la brecha promedio/máximo. Vuelve a mirar la corrida de concurrencia 50: promedio 9.60 ms, p95 29.13 ms, máximo 46.19 ms. (a) ¿Por qué el máximo es tan superior al promedio? (b) Si un usuario cualquiera hace una petición durante esa carga, ¿qué latencia es más honesta prometerle: el promedio o el p95? (c) ¿Qué habría que cambiar en la prueba para que el p95 se acercara al promedio?

Ver solución
  • (a) Bajo concurrencia, algunas peticiones esperan su turno (contención): la mayoría se atiende rápido, pero unas pocas quedan al final de la fila y acumulan más latencia. Esas pocas "colas largas" estiran el máximo muy por encima del promedio, aunque sean minoría.
  • (b) El p95 es más honesto. El promedio esconde a los desafortunados: prometer "9.60 ms" ignora que 1 de cada 20 usuarios vio 29 ms o más. El p95 dice "el 95% vio esto o menos", que es una promesa que de verdad puedes sostener. (El porqué a fondo es la lección 3.)
  • (c) Bajar la concurrencia (menos peticiones a la vez → menos fila → cola más corta). Con concurrencia 1, viste que el p95 (0.35 ms) casi igualaba al promedio (0.33 ms). La brecha crece con la contención.

Ejercicio 3 — Un caso engañoso. Un compañero dice: "Corrí /quote 5000 veces con curl en un bucle y el promedio fue 0.4 ms, así que la API aguanta perfecto la carga." Explica en dos o tres frases por qué su conclusión no está justificada, y qué prueba tendría que correr para justificarla.

Ver solución

Su bucle de curl corre las 5000 peticiones una tras otra (secuencialmente): nunca hay dos en vuelo a la vez, así que no hubo contención y no midió carga —midió 5000 pruebas funcionales rápidas seguidas—. Un promedio bajo sin concurrencia no dice nada sobre el comportamiento con muchos usuarios simultáneos. Para justificar "aguanta la carga" tendría que lanzar las peticiones concurrentes (p. ej. 100 clientes a la vez) y mirar el p95 y la tasa de error bajo esa concurrencia, no el promedio de una corrida secuencial.

Resumen y siguiente paso

En esta lección viste, con la misma API y el mismo endpoint, que el testing funcional y el de carga son pruebas de naturaleza distinta. La funcional fija una entrada y verifica una salida/quote de Focus/basic/3h → 7500—; pasa o falla por corrección. La de carga fija un nivel de tráfico y mide propiedades agregadas —latencia, throughput, tasa de error—; pasa o falla por rendimiento, según un umbral que tú defines. Son como las dos pruebas del puente: revisar las soldaduras (corrección) y poner los camiones (capacidad) son preguntas independientes, y ninguna se deduce de la otra.

Sobre todo, viste por qué no se deducen: la contención de recursos. Una petición sola tiene todos los recursos y responde en 0.3 ms; cincuenta a la vez hacen fila y el p95 salta a 29 ms —mismo código correcto, latencia cien veces mayor—. Esa es la propiedad que una prueba funcional nunca revela, porque siempre mide con la cocina vacía.

Antes de avanzar deberías poder: decir qué fija y qué mide cada tipo de prueba; explicar por qué la concurrencia (no el volumen total) es lo que crea la carga; e interpretar la brecha entre el promedio y el máximo de una corrida como el efecto de la fila.

Lo que sigue es descomponer el lado de carga en sus preguntas concretas. En la lección 3 veremos las tres que responde toda prueba de carga —¿cuántos usuarios concurrentes aguanta?, ¿cuál es la latencia p95?, ¿dónde está el punto de quiebre?— y por qué, de todas las formas de resumir la latencia, el promedio es el que más miente.

Recursos