API e interfaz de usuario
En resumen: Una prueba de componente llama a la regla, pero no ve cómo la usa el servidor. Una prueba de API envía una petición como un cliente real y comprueba el código de estado y el cuerpo: así atrapa un servidor que copió la regla y olvidó el rechazo. Una prueba de interfaz abre un navegador y encuentra los botones por rol y nombre, como los ve una persona. Es lenta y frágil, así que hay pocas y no se ejecutan con cada cambio.
Problema
Todo está en verde, y el carrito vacío pasa
Los escenarios pasan. Las pruebas de componente pasan. El viernes la tienda lanza una versión nueva, y el lunes en la base de datos hay pedidos de 0,00 € con 4,90 € de envío pagado.
La regla de la clase del envío es correcta. Pero el programador del servidor «optimizó» y calculó la tarifa directamente en el código que responde a las peticiones. No copió el rechazo. Ninguna prueba pasó por el servidor.
La idea
La prueba de API
El servidor de la tienda responde a peticiones. La aplicación del cliente pregunta GET /delivery?total=4999 y recibe la tarifa. Para un carrito vacío recibe el código de estado 400 y una explicación:
private static void delivery(HttpExchange exchange) throws IOException { int total = Integer.parseInt(exchange.getRequestURI().getQuery().replace("total=", "")); try { send(exchange, 200, String.valueOf(Delivery.feeFor(total))); } catch (IllegalArgumentException e) { send(exchange, 400, e.getMessage()); }}app.MapGet("/delivery", (int total) =>{ try { return Results.Text(Delivery.FeeFor(total).ToString()); } catch (ArgumentException e) { return Results.Text(e.Message, statusCode: 400); }});def do_GET(self): total = int(parse_qs(urlparse(self.path).query)["total"][0]) try: self.send(200, str(delivery_fee(total))) except ValueError as e: self.send(400, str(e))const server = createServer((req, res) => { const total = Number(new URL(req.url, "http://localhost").searchParams.get("total")); try { send(res, 200, String(deliveryFee(total))); } catch (e) { send(res, 400, e.message); }});Una prueba de API es un cliente de este servidor. Arranca el servidor, envía una petición HTTP real y comprueba dos cosas: el código de estado y el cuerpo. No ve el interior del servidor, solo su contrato con el cliente:
@Test@DisplayName("carrito vacío → 400")void emptyCartIsRefused() throws Exception { HttpResponse<String> response = get(0); assertEquals(400, response.statusCode()); assertEquals("Un carrito vacío no se puede pedir", response.body());}[Fact(DisplayName = "carrito vacío → 400")]public async Task EmptyCartIsRefused(){ var response = await client.GetAsync("/delivery?total=0"); Assert.Equal(400, (int)response.StatusCode); Assert.Equal("Un carrito vacío no se puede pedir", await response.Content.ReadAsStringAsync());}def test_empty_cart_is_refused(base_url): status, text = get(base_url, 0) assert status == 400 assert text == "Un carrito vacío no se puede pedir"test("carrito vacío → 400", async () => { const response = await fetch(`${base}/delivery?total=0`); assert.equal(response.status, 400); assert.equal(await response.text(), "Un carrito vacío no se puede pedir");});El servidor arranca en el mismo proceso, en un puerto libre que elige el sistema. Así la prueba no depende de nada fuera de ella y se puede ejecutar en cualquier sitio.
Más precisamente, para curiosos
Los servidores de aquí son los más pequeños que trae cada lenguaje: com.sun.net.httpserver en Java, ASP.NET Core Minimal API en C#, http.server en Python y node:http en JavaScript. En un proyecto real el servidor es grande, pero la prueba es la misma: una petición, un código de estado, un cuerpo. Java y C# tienen además herramientas que arrancan la aplicación en modo de prueba sin una red real (por ejemplo WebApplicationFactory en ASP.NET Core). Python y JavaScript tienen las suyas (TestClient en FastAPI, supertest).
La idea
La ejecución
El archivo entero de la prueba de API, contra el servidor que llama a la regla. Todo pasa:
package shop;
import com.sun.net.httpserver.HttpServer;import org.junit.jupiter.api.AfterAll;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;
import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DeliveryApiTest { private static HttpServer server; private static final HttpClient client = HttpClient.newHttpClient();
@BeforeAll static void start() throws Exception { server = ShopApi.start(0); }
@AfterAll static void stop() { server.stop(0); }
private HttpResponse<String> get(int total) throws Exception { URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/delivery?total=" + total); return client.send(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofString()); }
@Test @DisplayName("49,99 € → envío 4,90 €") void paidDelivery() throws Exception { HttpResponse<String> response = get(4999); assertEquals(200, response.statusCode()); assertEquals("490", response.body()); }
@Test @DisplayName("50,00 € → envío gratis") void freeDelivery() throws Exception { HttpResponse<String> response = get(5000); assertEquals(200, response.statusCode()); assertEquals("0", response.body()); }
@Test @DisplayName("carrito vacío → 400") void emptyCartIsRefused() throws Exception { HttpResponse<String> response = get(0); assertEquals(400, response.statusCode()); assertEquals("Un carrito vacío no se puede pedir", response.body()); }}OK 49,99 € → envío 4,90 €OK 50,00 € → envío gratisOK carrito vacío → 400Pruebas: 3, pasan: 3, fallan: 0using Microsoft.AspNetCore.Builder;using Xunit;
namespace Shop;
public class DeliveryApiTest : IAsyncLifetime{ private WebApplication app; private readonly HttpClient client = new();
public async Task InitializeAsync() { app = ShopApi.Create(); app.Urls.Add("http://127.0.0.1:0"); await app.StartAsync(); client.BaseAddress = new Uri(app.Urls.First()); }
public async Task DisposeAsync() => await app.StopAsync();
[Fact(DisplayName = "49,99 € → envío 4,90 €")] public async Task PaidDelivery() { var response = await client.GetAsync("/delivery?total=4999"); Assert.Equal(200, (int)response.StatusCode); Assert.Equal("490", await response.Content.ReadAsStringAsync()); }
[Fact(DisplayName = "50,00 € → envío gratis")] public async Task FreeDelivery() { var response = await client.GetAsync("/delivery?total=5000"); Assert.Equal(200, (int)response.StatusCode); Assert.Equal("0", await response.Content.ReadAsStringAsync()); }
[Fact(DisplayName = "carrito vacío → 400")] public async Task EmptyCartIsRefused() { var response = await client.GetAsync("/delivery?total=0"); Assert.Equal(400, (int)response.StatusCode); Assert.Equal("Un carrito vacío no se puede pedir", await response.Content.ReadAsStringAsync()); }}OK 49,99 € → envío 4,90 €OK 50,00 € → envío gratisOK carrito vacío → 400Pruebas: 3, pasan: 3, fallan: 0import urllib.requestfrom urllib.error import HTTPError
import pytest
from shop_api import start
@pytest.fixture(scope="module")def base_url(): server = start() yield f"http://127.0.0.1:{server.server_address[1]}" server.shutdown()
def get(base_url, total): try: with urllib.request.urlopen(f"{base_url}/delivery?total={total}") as response: return response.status, response.read().decode() except HTTPError as e: return e.code, e.read().decode()
def test_paid_delivery(base_url): status, text = get(base_url, 4999) assert status == 200 assert text == "490"
def test_free_delivery(base_url): status, text = get(base_url, 5000) assert status == 200 assert text == "0"
def test_empty_cart_is_refused(base_url): status, text = get(base_url, 0) assert status == 400 assert text == "Un carrito vacío no se puede pedir"OK test_empty_cart_is_refusedOK test_free_deliveryOK test_paid_deliveryPruebas: 3, pasan: 3, fallan: 0import { after, before, test } from "node:test";import assert from "node:assert/strict";import { start } from "./shop-api.js";
let server;let base;
before(async () => { server = await start(); base = `http://127.0.0.1:${server.address().port}`;});
after(() => server.close());
test("49,99 € → envío 4,90 €", async () => { const response = await fetch(`${base}/delivery?total=4999`); assert.equal(response.status, 200); assert.equal(await response.text(), "490");});
test("50,00 € → envío gratis", async () => { const response = await fetch(`${base}/delivery?total=5000`); assert.equal(response.status, 200); assert.equal(await response.text(), "0");});
test("carrito vacío → 400", async () => { const response = await fetch(`${base}/delivery?total=0`); assert.equal(response.status, 400); assert.equal(await response.text(), "Un carrito vacío no se puede pedir");});OK 49,99 € → envío 4,90 €OK 50,00 € → envío gratisOK carrito vacío → 400Pruebas: 3, pasan: 3, fallan: 0Ahora el servidor del principio de la lección: la regla está copiada, sin el rechazo.
private static void delivery(HttpExchange exchange) throws IOException { int total = Integer.parseInt(exchange.getRequestURI().getQuery().replace("total=", "")); // La regla está copiada aquí en lugar de llamar a Delivery. int fee = total >= Delivery.FREE_FROM ? 0 : Delivery.FEE; send(exchange, 200, String.valueOf(fee));}app.MapGet("/delivery", (int total) =>{ // La regla está copiada aquí en lugar de llamar a Delivery. int fee = total >= Delivery.FreeFrom ? 0 : Delivery.Fee; return Results.Text(fee.ToString());});def do_GET(self): total = int(parse_qs(urlparse(self.path).query)["total"][0]) # La regla está copiada aquí en lugar de llamar a Delivery. fee = 0 if total >= FREE_FROM else FEE self.send(200, str(fee))const server = createServer((req, res) => { const total = Number(new URL(req.url, "http://localhost").searchParams.get("total")); // La regla está copiada aquí en lugar de llamar a Delivery. const fee = total >= FREE_FROM ? 0 : FEE; send(res, 200, String(fee));});Las mismas pruebas fallan con el carrito vacío. El servidor dice 200, y debería decir 400:
package shop;
import com.sun.net.httpserver.HttpServer;import org.junit.jupiter.api.AfterAll;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;
import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DeliveryApiTest { private static HttpServer server; private static final HttpClient client = HttpClient.newHttpClient();
@BeforeAll static void start() throws Exception { server = ShopApi.start(0); }
@AfterAll static void stop() { server.stop(0); }
private HttpResponse<String> get(int total) throws Exception { URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/delivery?total=" + total); return client.send(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofString()); }
@Test @DisplayName("49,99 € → envío 4,90 €") void paidDelivery() throws Exception { HttpResponse<String> response = get(4999); assertEquals(200, response.statusCode()); assertEquals("490", response.body()); }
@Test @DisplayName("50,00 € → envío gratis") void freeDelivery() throws Exception { HttpResponse<String> response = get(5000); assertEquals(200, response.statusCode()); assertEquals("0", response.body()); }
@Test @DisplayName("carrito vacío → 400") void emptyCartIsRefused() throws Exception { HttpResponse<String> response = get(0); assertEquals(400, response.statusCode()); assertEquals("Un carrito vacío no se puede pedir", response.body()); }}FALLA carrito vacío → 400: expected: <400> but was: <200>OK 49,99 € → envío 4,90 €OK 50,00 € → envío gratisPruebas: 3, pasan: 2, fallan: 1using Microsoft.AspNetCore.Builder;using Xunit;
namespace Shop;
public class DeliveryApiTest : IAsyncLifetime{ private WebApplication app; private readonly HttpClient client = new();
public async Task InitializeAsync() { app = ShopApi.Create(); app.Urls.Add("http://127.0.0.1:0"); await app.StartAsync(); client.BaseAddress = new Uri(app.Urls.First()); }
public async Task DisposeAsync() => await app.StopAsync();
[Fact(DisplayName = "49,99 € → envío 4,90 €")] public async Task PaidDelivery() { var response = await client.GetAsync("/delivery?total=4999"); Assert.Equal(200, (int)response.StatusCode); Assert.Equal("490", await response.Content.ReadAsStringAsync()); }
[Fact(DisplayName = "50,00 € → envío gratis")] public async Task FreeDelivery() { var response = await client.GetAsync("/delivery?total=5000"); Assert.Equal(200, (int)response.StatusCode); Assert.Equal("0", await response.Content.ReadAsStringAsync()); }
[Fact(DisplayName = "carrito vacío → 400")] public async Task EmptyCartIsRefused() { var response = await client.GetAsync("/delivery?total=0"); Assert.Equal(400, (int)response.StatusCode); Assert.Equal("Un carrito vacío no se puede pedir", await response.Content.ReadAsStringAsync()); }}FALLA carrito vacío → 400: Assert.Equal() Failure: Values differ Expected: 400 Actual: 200OK 49,99 € → envío 4,90 €OK 50,00 € → envío gratisPruebas: 3, pasan: 2, fallan: 1import urllib.requestfrom urllib.error import HTTPError
import pytest
from shop_api import start
@pytest.fixture(scope="module")def base_url(): server = start() yield f"http://127.0.0.1:{server.server_address[1]}" server.shutdown()
def get(base_url, total): try: with urllib.request.urlopen(f"{base_url}/delivery?total={total}") as response: return response.status, response.read().decode() except HTTPError as e: return e.code, e.read().decode()
def test_paid_delivery(base_url): status, text = get(base_url, 4999) assert status == 200 assert text == "490"
def test_free_delivery(base_url): status, text = get(base_url, 5000) assert status == 200 assert text == "0"
def test_empty_cart_is_refused(base_url): status, text = get(base_url, 0) assert status == 400 assert text == "Un carrito vacío no se puede pedir"FALLA test_empty_cart_is_refused: assert 200 == 400OK test_free_deliveryOK test_paid_deliveryPruebas: 3, pasan: 2, fallan: 1import { after, before, test } from "node:test";import assert from "node:assert/strict";import { start } from "./shop-api.js";
let server;let base;
before(async () => { server = await start(); base = `http://127.0.0.1:${server.address().port}`;});
after(() => server.close());
test("49,99 € → envío 4,90 €", async () => { const response = await fetch(`${base}/delivery?total=4999`); assert.equal(response.status, 200); assert.equal(await response.text(), "490");});
test("50,00 € → envío gratis", async () => { const response = await fetch(`${base}/delivery?total=5000`); assert.equal(response.status, 200); assert.equal(await response.text(), "0");});
test("carrito vacío → 400", async () => { const response = await fetch(`${base}/delivery?total=0`); assert.equal(response.status, 400); assert.equal(await response.text(), "Un carrito vacío no se puede pedir");});FALLA carrito vacío → 400: 200 !== 400OK 49,99 € → envío 4,90 €OK 50,00 € → envío gratisPruebas: 3, pasan: 2, fallan: 1Las pruebas de componente de la lección anterior siguen pasando, porque la clase del envío no se ha tocado. Cada nivel atrapa sus propios errores.
Pruébalo
¿Qué ve solo la prueba de API?
La idea
La prueba de interfaz con Playwright
Una prueba de interfaz (UI test) abre un navegador real y hace lo que hace el cliente: abre el carrito, pulsa «Hacer el pedido», lee lo que dice. La herramienta aquí es Playwright. Tiene versiones para los cuatro lenguajes:
package shop;
import com.microsoft.playwright.Browser;import com.microsoft.playwright.Page;import com.microsoft.playwright.Playwright;import com.microsoft.playwright.options.AriaRole;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Tag;import org.junit.jupiter.api.Test;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
@Tag("ui")class CheckoutUiTest { @Test @DisplayName("pedido justo en el límite") void freeDeliveryOnTheBoundary() { try (Playwright playwright = Playwright.create()) { Browser browser = playwright.chromium().launch(); Page page = browser.newPage(); // un carrito de 50,00 € page.navigate("http://localhost:4100/cart?test-cart=5000"); page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Hacer el pedido")).click(); assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("Pedido aceptado"))).isVisible(); assertThat(page.getByText("Envío: gratis")).isVisible(); } }}using Microsoft.Playwright;using Xunit;
namespace Shop;
public class CheckoutUiTest{ [Fact(DisplayName = "pedido justo en el límite")] [Trait("Category", "UI")] public async Task FreeDeliveryOnTheBoundary() { using var playwright = await Playwright.CreateAsync(); await using var browser = await playwright.Chromium.LaunchAsync(); var page = await browser.NewPageAsync(); // un carrito de 50,00 € await page.GotoAsync("http://localhost:4100/cart?test-cart=5000"); await page.GetByRole(AriaRole.Button, new() { Name = "Hacer el pedido" }).ClickAsync(); await Assertions.Expect(page.GetByRole(AriaRole.Heading, new() { Name = "Pedido aceptado" })).ToBeVisibleAsync(); await Assertions.Expect(page.GetByText("Envío: gratis")).ToBeVisibleAsync(); }}import pytestfrom playwright.sync_api import Page, expect
@pytest.mark.uidef test_free_delivery_on_the_boundary(page: Page): # un carrito de 50,00 € page.goto("http://localhost:4100/cart?test-cart=5000") page.get_by_role("button", name="Hacer el pedido").click() expect(page.get_by_role("heading", name="Pedido aceptado")).to_be_visible() expect(page.get_by_text("Envío: gratis")).to_be_visible()import { test, expect } from "@playwright/test";
test("pedido justo en el límite", async ({ page }) => { // un carrito de 50,00 € await page.goto("http://localhost:4100/cart?test-cart=5000"); await page.getByRole("button", { name: "Hacer el pedido" }).click(); await expect(page.getByRole("heading", { name: "Pedido aceptado" })).toBeVisible(); await expect(page.getByText("Envío: gratis")).toBeVisible();});Fíjate en cómo la prueba encuentra el botón: por rol y nombre («un botón llamado Hacer el pedido»), no por CSS (#app > div:nth-child(3) > button). El rol y el nombre son lo que ve el cliente y lo que un lector de pantalla le dice a una persona ciega. Cambian cuando cambia el significado. El CSS cambia cuando el diseñador mueve algo.
Las comprobaciones de Playwright esperan solas: «el encabezado es visible» espera hasta unos segundos a que aparezca. Por eso no hay pausas en la prueba.
Más precisamente, para curiosos
El carrito de prueba (?test-cart=5000) es un acuerdo con los programadores: en el entorno de pruebas la tienda puede arrancar con un carrito ya preparado, para que la prueba de interfaz no tenga que buscar y añadir productos. Es una parte de la prueba que vive no en Playwright sino en la tienda.
Playwright es de Microsoft y es de código abierto. Otras herramientas extendidas son Selenium (la más antigua) y Cypress (solo JavaScript). Localizar por rol lo popularizó Testing Library, y los roles son los del estándar de accesibilidad ARIA.
La idea
Por qué las pruebas de interfaz se rompen y por qué hay pocas
Una prueba de interfaz es la más cercana al cliente y la más cara:
- lenta: un navegador, una red, una página. Segundos por prueba, no milisegundos;
- necesita todo: una tienda en marcha, una base de datos, datos de prueba;
- frágil: falla por un botón que se movió, una red lenta, una animación. Una prueba frágil (brittle) falla sin un defecto, y la gente deja de confiar en ella.
Por eso hay pocas pruebas de interfaz: solo los caminos de punta a punta que no pueden romperse. Las reglas se comprueban más abajo: los importes en el nivel de componente, el contrato del servidor con una prueba de API.
Esta prueba de interfaz no se ejecuta en el curso: necesita un navegador y una tienda en marcha, que el verificador del curso no tiene. Por la misma razón, en la configuración de CI de la siguiente lección las pruebas de interfaz se mantienen aparte y se ejecutan una vez por noche contra el entorno de pruebas, no con cada cambio. Los escenarios, las pruebas de componente y las pruebas de API de este módulo sí se ejecutan de verdad.
Pruébalo
¿Qué localizador sobrevive?
Ejercicio
Qué nivel para qué
1. API, interfaz o más abajo. La tienda está ordenando sus pruebas.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Usabilidad y accesibilidad · Por qué y qué automatizar · Del escenario a los pasos · Pruebas de componentes
- Continúa en: Pruebas en CI
- Ejercicios sobre el tema: Automatización