Към съдържанието
wannacode

API и потребителски интерфейс

Накратко: Тестът на ниво компонент вика правилото, но не вижда как сървърът го ползва. API тестът праща заявка като истински клиент и проверява кода на отговора и тялото: така хваща сървър, който е преписал правилото и е забравил отказа. UI тестът отваря браузър и намира бутоните по роля и име, както ги вижда човек. Той е бавен и крехък, затова е малко на брой и не върви при всяка промяна.

Проблем

Всичко е зелено, а празната количка минава

Сценариите минават. Тестовете на ниво компонент минават. В петък магазинът пуска нова версия, и в понеделник в базата има поръчки за 0,00 € с платена доставка 4,90 €.

Правилото в класа за доставката е вярно. Но програмистът на сървъра „оптимизирал“ и сметнал таксата направо в кода, който отговаря на заявките. Отказа не преписал. Никой тест не минаваше през сървъра.

Идеята

API тестът

Сървърът на магазина отговаря на заявки. Приложението на клиента пита GET /delivery?total=4999 и получава таксата. За празна количка получава код на отговора 400 и обяснение:

src/main/java/shop/ShopApi.java
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());
}
}

API тестът е клиент на този сървър. Той пуска сървъра, праща истинска HTTP заявка и проверява две неща: кода на отговора и тялото. Не вижда вътрешностите на сървъра, само договора му с клиента:

src/test/java/shop/DeliveryApiTest.java
@Test
@DisplayName("празна количка → 400")
void emptyCartIsRefused() throws Exception {
HttpResponse<String> response = get(0);
assertEquals(400, response.statusCode());
assertEquals("Празна количка не се поръчва", response.body());
}

Сървърът се пуска в същия процес, на свободен порт, който системата избира. Така тестът не зависи от нищо извън себе си и може да върви навсякъде.

По-точно, за любопитните

Сървърите тук са най-малките, които всеки език има вградени: 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 тестовете, срещу сървъра, който вика правилото. Всичко минава:

src/test/java/shop/DeliveryApiTest.java
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, паднали: 0

Сега сървърът от началото на урока: правилото е преписано, без отказа.

src/main/java/shop/ShopApi.java
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));
}

Същите тестове падат на празната количка. Сървърът казва 200, а трябва 400:

src/test/java/shop/DeliveryApiTest.java
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, паднали: 1

Тестовете на ниво компонент от миналия урок минават и сега, защото класът за доставката не е пипан. Всяко ниво хваща своите грешки.

Пробвай

Какво вижда само API тестът?

1. Тестовете на ниво компонент минават. Какво още може да е счупено, което само API тестът вижда?
2. Празната количка трябва да се откаже. API тестът проверява само тялото „Празна количка не се поръчва“. Какво пропуска?
3. Защо API тестът пуска сървъра на свободен порт (порт 0), а не на 8080?

Идеята

UI тестът с Playwright

UI тестът отваря истински браузър и прави каквото прави клиентът: отваря количката, натиска „Поръчай“, чете какво пише. Инструментът тук е Playwright. Той има версии за четирите езика:

src/test/java/shop/CheckoutUiTest.java
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();
}
}
}

Обърни внимание как тестът намира бутона: по роля и име („бутон с име Поръчай“), а не по 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. Кой локатор ще оцелее, ако дизайнерът премести бутона „Поръчай“ в друг блок и смени класовете му?
2. Бутонът е <div class="btn" onclick="…">Поръчай</div>. Тестът с роля „бутон“ не го намира. Какво значи това?
3. След „Поръчай“ потвърждението се появява след половин секунда. Как го чака тестът?

Упражнение

Кое ниво за какво

1. API, UI или по-надолу. Магазинът подрежда тестовете си.

1. Кое се проверява най-добре с API тест?
2. Магазинът има 400 UI теста, пускат се 2 часа и всеки ден пет-шест падат без дефект. Какво предлагаш?
3. Кой UI тест си струва най-много?

Тест

Провери се

1. Какво проверява API тестът?
2. Защо UI тестът намира елементите по роля и име, а не по CSS?
3. Защо UI тестовете са малко на брой?
4. Сървърът е преписал правилото и е забравил отказа. Кой тест го хваща?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·