API и потребителски интерфейс
Накратко: Тестът на ниво компонент вика правилото, но не вижда как сървърът го ползва. API тестът праща заявка като истински клиент и проверява кода на отговора и тялото: така хваща сървър, който е преписал правилото и е забравил отказа. UI тестът отваря браузър и намира бутоните по роля и име, както ги вижда човек. Той е бавен и крехък, затова е малко на брой и не върви при всяка промяна.
Проблем
Всичко е зелено, а празната количка минава
Сценариите минават. Тестовете на ниво компонент минават. В петък магазинът пуска нова версия, и в понеделник в базата има поръчки за 0,00 € с платена доставка 4,90 €.
Правилото в класа за доставката е вярно. Но програмистът на сървъра „оптимизирал“ и сметнал таксата направо в кода, който отговаря на заявките. Отказа не преписал. Никой тест не минаваше през сървъра.
Идеята
API тестът
Сървърът на магазина отговаря на заявки. Приложението на клиента пита GET /delivery?total=4999 и получава таксата. За празна количка получава код на отговора 400 и обяснение:
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); }});API тестът е клиент на този сървър. Той пуска сървъра, праща истинска HTTP заявка и проверява две неща: кода на отговора и тялото. Не вижда вътрешностите на сървъра, само договора му с клиента:
@Test@DisplayName("празна количка → 400")void emptyCartIsRefused() throws Exception { HttpResponse<String> response = get(0); assertEquals(400, response.statusCode()); assertEquals("Празна количка не се поръчва", response.body());}[Fact(DisplayName = "празна количка → 400")]public async Task EmptyCartIsRefused(){ var response = await client.GetAsync("/delivery?total=0"); Assert.Equal(400, (int)response.StatusCode); Assert.Equal("Празна количка не се поръчва", await response.Content.ReadAsStringAsync());}def test_empty_cart_is_refused(base_url): status, text = get(base_url, 0) assert status == 400 assert text == "Празна количка не се поръчва"test("празна количка → 400", async () => { const response = await fetch(`${base}/delivery?total=0`); assert.equal(response.status, 400); assert.equal(await response.text(), "Празна количка не се поръчва");});Сървърът се пуска в същия процес, на свободен порт, който системата избира. Така тестът не зависи от нищо извън себе си и може да върви навсякъде.
По-точно, за любопитните
Сървърите тук са най-малките, които всеки език има вградени: com.sun.net.httpserver в Java, ASP.NET Core Minimal API в C#, http.server в Python и node:http в JavaScript. В истински проект сървърът е голям, но тестът е същият: заявка, код на отговора, тяло. Във Java и C# има и инструменти, които пускат приложението в тестов режим без истинска мрежа (например WebApplicationFactory в ASP.NET Core). Python и JavaScript имат свои (TestClient във FastAPI, supertest).
Идеята
Пускането
Целият файл с API тестовете, срещу сървъра, който вика правилото. Всичко минава:
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 € → доставка 4,90 €") void paidDelivery() throws Exception { HttpResponse<String> response = get(4999); assertEquals(200, response.statusCode()); assertEquals("490", response.body()); }
@Test @DisplayName("50,00 € → безплатна доставка") void freeDelivery() throws Exception { HttpResponse<String> response = get(5000); assertEquals(200, response.statusCode()); assertEquals("0", response.body()); }
@Test @DisplayName("празна количка → 400") void emptyCartIsRefused() throws Exception { HttpResponse<String> response = get(0); assertEquals(400, response.statusCode()); assertEquals("Празна количка не се поръчва", response.body()); }}ОК 49,99 € → доставка 4,90 €ОК 50,00 € → безплатна доставкаОК празна количка → 400Тестове: 3, минали: 3, паднали: 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 € → доставка 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 € → безплатна доставка")] 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 = "празна количка → 400")] public async Task EmptyCartIsRefused() { var response = await client.GetAsync("/delivery?total=0"); Assert.Equal(400, (int)response.StatusCode); Assert.Equal("Празна количка не се поръчва", await response.Content.ReadAsStringAsync()); }}ОК 49,99 € → доставка 4,90 €ОК 50,00 € → безплатна доставкаОК празна количка → 400Тестове: 3, минали: 3, паднали: 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 == "Празна количка не се поръчва"ОК test_empty_cart_is_refusedОК test_free_deliveryОК test_paid_deliveryТестове: 3, минали: 3, паднали: 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 € → доставка 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 € → безплатна доставка", async () => { const response = await fetch(`${base}/delivery?total=5000`); assert.equal(response.status, 200); assert.equal(await response.text(), "0");});
test("празна количка → 400", async () => { const response = await fetch(`${base}/delivery?total=0`); assert.equal(response.status, 400); assert.equal(await response.text(), "Празна количка не се поръчва");});ОК 49,99 € → доставка 4,90 €ОК 50,00 € → безплатна доставкаОК празна количка → 400Тестове: 3, минали: 3, паднали: 0Сега сървърът от началото на урока: правилото е преписано, без отказа.
private static void delivery(HttpExchange exchange) throws IOException { int total = Integer.parseInt(exchange.getRequestURI().getQuery().replace("total=", "")); // Правилото е преписано тук, вместо да се вика Delivery. int fee = total >= Delivery.FREE_FROM ? 0 : Delivery.FEE; send(exchange, 200, String.valueOf(fee));}app.MapGet("/delivery", (int total) =>{ // Правилото е преписано тук, вместо да се вика 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]) # Правилото е преписано тук, вместо да се вика 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")); // Правилото е преписано тук, вместо да се вика Delivery. const fee = total >= FREE_FROM ? 0 : FEE; send(res, 200, String(fee));});Същите тестове падат на празната количка. Сървърът казва 200, а трябва 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 € → доставка 4,90 €") void paidDelivery() throws Exception { HttpResponse<String> response = get(4999); assertEquals(200, response.statusCode()); assertEquals("490", response.body()); }
@Test @DisplayName("50,00 € → безплатна доставка") void freeDelivery() throws Exception { HttpResponse<String> response = get(5000); assertEquals(200, response.statusCode()); assertEquals("0", response.body()); }
@Test @DisplayName("празна количка → 400") void emptyCartIsRefused() throws Exception { HttpResponse<String> response = get(0); assertEquals(400, response.statusCode()); assertEquals("Празна количка не се поръчва", response.body()); }}ОК 49,99 € → доставка 4,90 €ОК 50,00 € → безплатна доставкаПАДА празна количка → 400: expected: <400> but was: <200>Тестове: 3, минали: 2, паднали: 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 € → доставка 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 € → безплатна доставка")] 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 = "празна количка → 400")] public async Task EmptyCartIsRefused() { var response = await client.GetAsync("/delivery?total=0"); Assert.Equal(400, (int)response.StatusCode); Assert.Equal("Празна количка не се поръчва", await response.Content.ReadAsStringAsync()); }}ОК 49,99 € → доставка 4,90 €ОК 50,00 € → безплатна доставкаПАДА празна количка → 400: Assert.Equal() Failure: Values differ Expected: 400 Actual: 200Тестове: 3, минали: 2, паднали: 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 == "Празна количка не се поръчва"ОК test_free_deliveryОК test_paid_deliveryПАДА test_empty_cart_is_refused: assert 200 == 400Тестове: 3, минали: 2, паднали: 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 € → доставка 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 € → безплатна доставка", async () => { const response = await fetch(`${base}/delivery?total=5000`); assert.equal(response.status, 200); assert.equal(await response.text(), "0");});
test("празна количка → 400", async () => { const response = await fetch(`${base}/delivery?total=0`); assert.equal(response.status, 400); assert.equal(await response.text(), "Празна количка не се поръчва");});ОК 49,99 € → доставка 4,90 €ОК 50,00 € → безплатна доставкаПАДА празна количка → 400: 200 !== 400Тестове: 3, минали: 2, паднали: 1Тестовете на ниво компонент от миналия урок минават и сега, защото класът за доставката не е пипан. Всяко ниво хваща своите грешки.
Пробвай
Какво вижда само API тестът?
Идеята
UI тестът с Playwright
UI тестът отваря истински браузър и прави каквото прави клиентът: отваря количката, натиска „Поръчай“, чете какво пише. Инструментът тук е Playwright. Той има версии за четирите езика:
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("поръчка точно на границата") void freeDeliveryOnTheBoundary() { try (Playwright playwright = Playwright.create()) { Browser browser = playwright.chromium().launch(); Page page = browser.newPage(); // количка за 50,00 € page.navigate("http://localhost:4100/cart?test-cart=5000"); page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Поръчай")).click(); assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("Поръчката е приета"))).isVisible(); assertThat(page.getByText("Доставка: безплатна")).isVisible(); } }}using Microsoft.Playwright;using Xunit;
namespace Shop;
public class CheckoutUiTest{ [Fact(DisplayName = "поръчка точно на границата")] [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(); // количка за 50,00 € await page.GotoAsync("http://localhost:4100/cart?test-cart=5000"); await page.GetByRole(AriaRole.Button, new() { Name = "Поръчай" }).ClickAsync(); await Assertions.Expect(page.GetByRole(AriaRole.Heading, new() { Name = "Поръчката е приета" })).ToBeVisibleAsync(); await Assertions.Expect(page.GetByText("Доставка: безплатна")).ToBeVisibleAsync(); }}import pytestfrom playwright.sync_api import Page, expect
@pytest.mark.uidef test_free_delivery_on_the_boundary(page: Page): # количка за 50,00 € page.goto("http://localhost:4100/cart?test-cart=5000") page.get_by_role("button", name="Поръчай").click() expect(page.get_by_role("heading", name="Поръчката е приета")).to_be_visible() expect(page.get_by_text("Доставка: безплатна")).to_be_visible()import { test, expect } from "@playwright/test";
test("поръчка точно на границата", async ({ page }) => { // количка за 50,00 € await page.goto("http://localhost:4100/cart?test-cart=5000"); await page.getByRole("button", { name: "Поръчай" }).click(); await expect(page.getByRole("heading", { name: "Поръчката е приета" })).toBeVisible(); await expect(page.getByText("Доставка: безплатна")).toBeVisible();});Обърни внимание как тестът намира бутона: по роля и име („бутон с име Поръчай“), а не по CSS (#app > div:nth-child(3) > button). Ролята и името са това, което клиентът вижда и което екранният четец казва на незрящ човек. Те се сменят, когато се смени смисълът. CSS се сменя, когато дизайнерът премести нещо.
Проверките на Playwright чакат сами: „видим ли е заглавието“ чака до няколко секунди, докато се появи. Затова в теста няма паузи.
По-точно, за любопитните
Тестовата количка (?test-cart=5000) е договорка с програмистите: магазинът в тестова среда може да започне с готова количка, за да не минава UI тестът през търсене и добавяне на продукти. Това е част от теста, която не е в Playwright, а в магазина.
Playwright е на Microsoft и е с отворен код. Други разпространени инструменти са Selenium (най-старият) и Cypress (само за JavaScript). Търсенето по роля е популяризирано от Testing Library, а ролите са тези от стандарта за достъпност ARIA.
Идеята
Защо UI тестовете се чупят и защо са малко
UI тестът е най-близо до клиента и най-скъп:
- бавен: браузър, мрежа, страница. Секунди за тест, не милисекунди;
- иска всичко: работещ магазин, база, тестови данни;
- крехък: пада от преместен бутон, бавна мрежа, анимация. Крехък тест пада без дефект, и хората спират да му вярват.
Затова UI тестовете са малко: само пътищата, които не бива да се чупят, от край до край. Правилата се проверяват по-надолу: сумите на ниво компонент, договорът на сървъра с API тест.
Този UI тест не се пуска в курса: за него трябват браузър и работещ магазин, които проверката на курса няма. По същата причина в CI конфигурацията от следващия урок UI тестовете са отделени и вървят веднъж на нощ срещу тестовата среда, а не при всяка промяна. Сценариите, тестовете на ниво компонент и API тестовете в този модул се пускат наистина.
Пробвай
Кой локатор оцелява?
Упражнение
Кое ниво за какво
1. API, UI или по-надолу. Магазинът подрежда тестовете си.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Използваемост и достъпност · Защо и какво да автоматизираш · От сценарий към стъпки · Тестове на ниво компонент
- Продължава в: Тестовете в CI
- Задачи по темата: Автоматизация