Filtrado, ordenación y paginación
Los bugs de los endpoints de lista que nadie nota hasta la página 3.
Un endpoint de lista parece sencillo: pides productos y recibes productos. Luego la lista crece más allá de lo que cabe en una respuesta, los usuarios la quieren ordenada por precio, filtrada por categoría y con búsqueda por nombre, y cada una de esas funcionalidades es un trocito de código con sus propios casos límite. Los bugs de las listas son traicioneros porque la respuesta siempre parece plausible. Una página con los diez productos equivocados sigue siendo una página con diez productos.
Esta lección usa /api/Products, que tiene 20 productos en sus datos iniciales, y los endpoints de demostración de /api/paginationdocs. Los mismos parámetros funcionan en todas las listas de esta API, y la referencia de la API los describe.
| Parámetro | Qué hace aquí |
|---|---|
limit | Tamaño de página, de 1 a 100. Sin él, recibes todo. |
page | Número de página empezando en 1, usado junto con limit. |
offset | Salta este número de registros, como alternativa a page. |
sort, order | Ordena por cualquier campo, asc (por defecto) o desc. |
q | Búsqueda sin distinguir mayúsculas en campos de texto como title y description. |
cualquier nombre de campo | Filtro exacto sin distinguir mayúsculas, como ?category=books. |
Paginación
Pide tres productos por página y mira las cabeceras además del cuerpo:
/api/Products?limit=3&page=1Veinte productos en páginas de tres son siete páginas, y la última tiene dos. La última página es el primer sitio donde buscar bugs, porque es el único caso en que la página no está llena:
$ curl -i "https://api.testingapis.com/api/Products?limit=3&page=7"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
x-page: 7
x-per-page: 3
x-total-count: 20
x-total-pages: 7
[{"id":19,"title":"Non-Slip Yoga Mat",...},{"id":20,"title":"Resistance Bands Set",...}]Correcto: siete páginas, y la última tiene los productos 19 y 20. Un bug clásico aquí es calcular el número de páginas con división entera (20 / 3 = 6), lo que oculta los dos últimos productos a quien vaya pasando las páginas.
Valores fuera del rango
A continuación, envía valores que la documentación dice que no son válidos, o que ni menciona. Esto es lo que hace la API con ellos:
?limit=3&page=8 200, x-page: 8, body []
?limit=3&page=0 200, x-page: 1, same as page 1
?limit=0 200, x-per-page: 1
?limit=-1 200, x-per-page: 1
?limit=101 200, x-per-page: 100
?limit=abc 200, x-per-page: 20 (limit ignored, all 20 returned)El servidor nunca se niega. Ajusta un valor malo al válido más cercano, o lo ignora. Es un diseño legítimo, y amable con los clientes, pero un test debería dejarlo registrado, porque la alternativa (un 400 que explique el rango válido) es igual de habitual. Los casos peligrosos son aquellos en que el ajuste esconde un bug del cliente. Un cliente que envía limit=abc por un error de plantilla recibe aquí los 20 registros. En una tabla con dos millones de filas, el mismo comportamiento significa que el servidor intenta devolverlas todas.
Una página más allá del final devuelve una lista vacía con un 200. Es lo que hacen la mayoría de las APIs, y lo que esperan los clientes que recorren páginas hasta que no reciben nada. Un 404 para una página vacía también se podría defender. Un 500 no.
Sin huecos, sin duplicados
El test de paginación más importante es también el más fácil de describir: recorre todas las páginas, reúne los IDs y comprueba que recibiste cada registro exactamente una vez. Compara cuántos reuniste con X-Total-Count.
async function allPages(path, limit) {
const seen = [];
for (let page = 1; ; page++) {
const res = await fetch(`https://api.testingapis.com${path}?limit=${limit}&page=${page}`);
const items = await res.json();
if (items.length === 0) break;
seen.push(...items.map((item) => item.id));
}
return seen;
}
const ids = await allPages("/api/Products", 3);
const total = 20; // from X-Total-Count
console.log(ids.length === total, new Set(ids).size === ids.length);Ejecútalo con varios tamaños de página: 1, 3, 7 y uno mayor que la lista entera. Los errores por uno en los cálculos de desplazamiento suelen aparecer solo con tamaños concretos. Si la API admite tanto page como offset, comprueba que limit=5&page=3 y limit=5&offset=10 devuelven los mismos registros.
Este test solo pasa de forma fiable si nadie cambia los datos mientras se ejecuta. En un servidor compartido como este, alguien podría hacerlo. Eso lleva a la principal debilidad de los números de página, que se trata más abajo.
Ordenación
Ordena por precio, de mayor a menor, y comprueba el orden del resultado entero, no solo el primer elemento:
/api/Products?sort=price&order=desc&limit=5Los cinco primeros vuelven como 249.99, 199.99, 89.99, 89.99, 59.99. Los productos 3 y 8 cuestan lo mismo. Los empates importan para la paginación: si el servidor no desempata de una forma fija, el mismo producto puede aparecer al final de la página 1 y al principio de la página 2, o en ninguna. La mayoría de las bases de datos no prometen nada sobre el orden de filas iguales salvo que la consulta añada una segunda clave de ordenación, normalmente el ID.
Ordenar por categoría muestra cómo lo resuelve esta API. Dentro de cada categoría, los productos siguen en orden de ID, tanto ascendente como descendente:
?sort=category
10:books 11:books 12:books 13:books 6:clothing 7:clothing 8:clothing 9:clothing
1:electronics 2:electronics 3:electronics 4:electronics 5:electronics 14:home ...
?sort=category&order=desc
18:sports 19:sports 20:sports 14:home 15:home 16:home 17:home 1:electronics ...Eso es estable, que es lo que quieres. Para probarlo, ordena por un campo con muchos duplicados, recupera la lista dos veces (y recórrela por páginas con un límite pequeño) y comprueba que el orden nunca cambia.
Más casos de ordenación que probar en cualquier API:
- Cadenas con mayúsculas y minúsculas mezcladas. ¿"apple" va antes o después de "Banana"?
- Números guardados como cadenas, donde "10" va antes que "9".
- Registros en los que el campo de ordenación es null o falta. ¿Van primero, al final o desaparecen?
- Fechas en zonas horarias distintas.
Parámetros que se ignoran
Lo que pasa cuando te equivocas con un parámetro da más problemas que cualquiera de las funcionalidades en sí:
?sort=colour&limit=5 200, ids 1,2,3,4,5 (unsorted)
?sort=price&order=sideways 200, sorted ascending
?categry=books 200, all 20 products
?rating.rate=4.3 200, all 20 productsTodos devuelven 200 y una lista que parece razonable. Una ordenación por un campo que no existe se descarta. Un orden sideways se convierte en ascendente. Un filtro mal escrito, categry, se ignora, así que el cliente pidió libros y recibió los 20 productos. El filtrado por un campo anidado con notación de puntos no está soportado, y también se ignora.
En la práctica, el filtro mal escrito es el peor de todos. Imagina una pantalla que muestra "pedidos del cliente 42", construida sobre un parámetro de filtro al que alguien cambió el nombre. Tras el cambio, cada cliente ve todos los pedidos, y la API devuelve 200 todo el tiempo. Cuando pruebes filtros, incluye siempre un caso en que el filtro debería eliminar la mayoría de los registros, y comprueba que lo hizo. Un test que solo comprueba que "cada producto devuelto es de la categoría books" también pasa con una lista vacía, así que comprueba también el número.
Filtros y búsqueda
En esta API los filtros son exactos y no distinguen mayúsculas: ?category=Electronics devuelve los cinco productos de electrónica, y ?category=electron devuelve una lista vacía. Los campos booleanos funcionan igual:
/api/Products?inStock=falseLa búsqueda con q busca subcadenas. Aquí empieza a importar la codificación, porque los términos de búsqueda contienen espacios, ampersands, signos de porcentaje y comillas, y todos tienen un significado especial en una URL:
?q=YOGA 19
?q=yoga+mat 19
?q=yoga%20mat 19
?q=100%25 6 (description contains "100% organic cotton")
?q=%27 11,13 (titles and descriptions with an apostrophe)
?q= all 20
?q=a&q=b []
?category=books&category=home []Los espacios funcionan escritos como + o como %20. Un % literal hay que enviarlo como %25, y encuentra correctamente la camiseta de "100% organic cotton". Una búsqueda de un apóstrofo funciona y devuelve dos libros, una pequeña señal de que la búsqueda no construye SQL pegando cadenas (la lección de seguridad explica por qué importa). Si envías un espacio sin codificar, curl se niega directamente a enviar la petición.
Las dos últimas filas merecen una nota. Repetir un parámetro es legal en una URL, y las APIs lo tratan de formas distintas: gana el primer valor, gana el último, los dos valores se combinan con OR, o se unen en una sola cadena. Aquí, repetir q o category devuelve una lista vacía, lo que sugiere que los valores se unieron en algo como books,home que no coincide con nada. Si la interfaz de tu producto llega a permitir elegir dos categorías, aquí hay un bug esperando a que lo encuentren.
Combinarlo todo
Filtra, ordena y pagina en una sola petición, y comprueba que se aplican en el orden correcto: primero el filtro, luego la ordenación y después la página.
/api/Products?category=electronics&sort=price&order=desc&limit=2&page=2La página 2 contiene los productos 3 y 4 (89.99 y 59.99), y X-Total-Countes 5, el número de productos de electrónica, no 20. Si el total ignorara el filtro, un cliente mostraría "página 1 de 10" para una lista de tres páginas. Es un error habitual, porque los totales a menudo se cuentan con una consulta aparte que alguien olvidó actualizar.
Por qué fallan los números de página
Los números de página y los offsets describen una posición en la lista: "salta 20, dame 10". Eso está bien para datos que no cambian. Cuando cambian, las posiciones se mueven:
- Recuperas la página 1 (elementos 1 a 10) de una lista ordenada de más nuevo a más antiguo.
- Alguien crea un elemento nuevo. Va arriba del todo, y todo baja una posición.
- Recuperas la página 2 (posiciones 11 a 20). La posición 11 es ahora el elemento que era el 10, así que lo ves dos veces.
Borrar un elemento hace lo contrario: todo sube una posición y un elemento nunca se muestra. En un feed de noticias es una pequeña molestia. En un proceso que exporta todas las facturas recorriendo páginas, significa que falta una factura en la exportación y nadie se da cuenta.
La paginación por cursor lo evita describiendo dónde te paraste en lugar de cuánto llevas. Cada respuesta te da un token para la siguiente petición:
/api/pagination/cursor?limit=3{
"object": "list",
"url": "/api/pagination/cursor",
"data": [
{ "id": 1, "name": "Smart Headset", "category": "electronics", "price": 42.01, "createdAt": "2026-01-01T21:00:00Z" },
{ "id": 2, "name": "Compact Speaker", "category": "outdoors", "price": 79.02, "createdAt": "2026-01-02T10:00:00Z" },
{ "id": 3, "name": "Deluxe Blender", "category": "office", "price": 116.03, "createdAt": "2026-01-02T23:00:00Z" }
],
"has_more": true,
"next_cursor": "eyJsYXN0SWQiOjN9"
}Devuelves next_cursor como ?cursor= y sigues hasta que has_moresea false. Un cursor no válido recibe un 400 claro: "Invalid cursor. Pass the 'next_cursor' value from a previous response unchanged."
La paginación keyset es la misma idea sin el envoltorio. El cliente envía el último ID que vio, y el servidor devuelve los registros posteriores:
/api/pagination/keyset?after_id=10&limit=3{
"data": [
{ "id": 11, "name": "Smart Lamp", "category": "electronics", "price": 12.11, "createdAt": "2026-01-07T07:00:00Z" },
{ "id": 12, "name": "Compact Headset", "category": "outdoors", "price": 49.12, "createdAt": "2026-01-07T20:00:00Z" },
{ "id": 13, "name": "Deluxe Speaker", "category": "office", "price": 86.13, "createdAt": "2026-01-08T09:00:00Z" }
],
"has_more": true,
"next_after_id": 13
}El tercer estilo pone los enlaces de página en una cabecera Link, como hace la API de GitHub. El cuerpo sigue siendo un array normal:
$ curl -i "https://api.testingapis.com/api/pagination/link?page=2&per_page=3"
HTTP/1.1 200 OK
link: <http://api.testingapis.com/api/pagination/link?page=3&per_page=3>; rel="next",
<http://api.testingapis.com/api/pagination/link?page=34&per_page=3>; rel="last",
<http://api.testingapis.com/api/pagination/link?page=1&per_page=3>; rel="first",
<http://api.testingapis.com/api/pagination/link?page=1&per_page=3>; rel="prev"
x-total-count: 100El número de páginas es correcto (100 elementos en páginas de 3 son 34 páginas). Pero mira las URLs. La petición fue a https, y todos los enlaces apuntan a http. Es un bug real, y habitual: la aplicación funciona detrás de un proxy que gestiona HTTPS, ella misma ve HTTP normal y construye los enlaces a partir de lo que ve. Un cliente que siga estos enlaces o bien recibe una redirección o, en una configuración más estricta, envía su siguiente petición sin cifrar. Comprobar el esquema y el host de cada URL que genera una API es un test barato que lo atrapa.
Para cada estilo de paginación, los tests básicos son los mismos: recorre hasta el final, reúne los IDs, busca huecos y duplicados, y comprueba qué pasa en la última página. Para cursores y keysets, añade un registro mientras recorres y confirma que sigues viendo cada registro original exactamente una vez.
Ejercicio
Elige /api/Booksinicia sesión para verlo y apunta los resultados de cada paso.
- Averigua cuántos libros hay con
X-Total-County recorre la lista conlimit=4. ¿Recibiste cada ID una vez? - Ordena por
genre. Recupéralo dos veces y conlimit=3a través de las páginas. ¿Es estable el orden dentro de cada género? - Filtra por un género, ordena por
pricede mayor a menor y pide la página 2. ¿EsX-Total-Countel número filtrado? - Escribe mal el nombre del filtro. ¿Qué vuelve?
- Recorre
/api/pagination/offsetinicia sesión para verlo y/api/pagination/linkinicia sesión para verlo hasta el final. ¿Dónde indican la última página, y coinciden los dos en el total?
Para cada sorpresa, decide si es un bug, un hueco en la documentación o una decisión de diseño que aun así cuestionarías.