Skip to content
wannacode

API and user interface

In short: A component test calls the rule but does not see how the server uses it. An API test sends a request like a real client and checks the status code and the body: that is how it catches a server that copied the rule and forgot the refusal. A UI test opens a browser and finds buttons by role and name, the way a person sees them. It is slow and brittle, so there are few of them and they do not run on every change.

Problem

Everything is green, and the empty cart goes through

The scenarios pass. The component tests pass. On Friday the shop releases a new version, and on Monday the database has orders for 0.00 € with paid delivery of 4.90 €.

The rule in the delivery class is correct. But the programmer on the server "optimised" things and computed the fee right in the code that answers requests. He did not copy the refusal. No test went through the server.

The idea

The API test

The shop server answers requests. The customer app asks GET /delivery?total=4999 and gets the fee. For an empty cart it gets the status code 400 and an explanation:

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());
}
}

An API test is a client of this server. It starts the server, sends a real HTTP request and checks two things: the status code and the body. It does not see the insides of the server, only its contract with the client:

src/test/java/shop/DeliveryApiTest.java
@Test
@DisplayName("empty cart → 400")
void emptyCartIsRefused() throws Exception {
HttpResponse<String> response = get(0);
assertEquals(400, response.statusCode());
assertEquals("An empty cart cannot be ordered", response.body());
}

The server starts in the same process, on a free port the system picks. That way the test depends on nothing outside itself and can run anywhere.

More precisely, if you are curious

The servers here are the smallest each language has built in: com.sun.net.httpserver in Java, ASP.NET Core Minimal API in C#, http.server in Python and node:http in JavaScript. In a real project the server is big, but the test is the same: a request, a status code, a body. Java and C# also have tools that start the application in test mode without a real network (for example WebApplicationFactory in ASP.NET Core). Python and JavaScript have their own (TestClient in FastAPI, supertest).

The idea

The run

The whole API test file, against the server that calls the rule. Everything passes:

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 € → delivery 4.90 €")
void paidDelivery() throws Exception {
HttpResponse<String> response = get(4999);
assertEquals(200, response.statusCode());
assertEquals("490", response.body());
}
@Test
@DisplayName("50.00 € → free delivery")
void freeDelivery() throws Exception {
HttpResponse<String> response = get(5000);
assertEquals(200, response.statusCode());
assertEquals("0", response.body());
}
@Test
@DisplayName("empty cart → 400")
void emptyCartIsRefused() throws Exception {
HttpResponse<String> response = get(0);
assertEquals(400, response.statusCode());
assertEquals("An empty cart cannot be ordered", response.body());
}
}
Output
PASS 49.99 € → delivery 4.90 €
PASS 50.00 € → free delivery
PASS empty cart → 400
Tests: 3, passed: 3, failed: 0

Now the server from the start of the lesson: the rule is copied, without the refusal.

src/main/java/shop/ShopApi.java
private static void delivery(HttpExchange exchange) throws IOException {
int total = Integer.parseInt(exchange.getRequestURI().getQuery().replace("total=", ""));
// The rule is copied here instead of calling Delivery.
int fee = total >= Delivery.FREE_FROM ? 0 : Delivery.FEE;
send(exchange, 200, String.valueOf(fee));
}

The same tests fail on the empty cart. The server says 200, and it should say 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 € → delivery 4.90 €")
void paidDelivery() throws Exception {
HttpResponse<String> response = get(4999);
assertEquals(200, response.statusCode());
assertEquals("490", response.body());
}
@Test
@DisplayName("50.00 € → free delivery")
void freeDelivery() throws Exception {
HttpResponse<String> response = get(5000);
assertEquals(200, response.statusCode());
assertEquals("0", response.body());
}
@Test
@DisplayName("empty cart → 400")
void emptyCartIsRefused() throws Exception {
HttpResponse<String> response = get(0);
assertEquals(400, response.statusCode());
assertEquals("An empty cart cannot be ordered", response.body());
}
}
Output
FAIL empty cart → 400: expected: <400> but was: <200>
PASS 49.99 € → delivery 4.90 €
PASS 50.00 € → free delivery
Tests: 3, passed: 2, failed: 1

The component tests from the previous lesson still pass, because the delivery class has not been touched. Every level catches its own bugs.

Try it

What does only the API test see?

1. The component tests pass. What else can be broken that only the API test sees?
2. An empty cart has to be refused. The API test checks only the body "An empty cart cannot be ordered". What does it miss?
3. Why does the API test start the server on a free port (port 0) and not on 8080?

The idea

The UI test with Playwright

A UI test opens a real browser and does what the customer does: opens the cart, presses "Place order", reads what it says. The tool here is Playwright. It has versions for all four languages:

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("an order exactly on the boundary")
void freeDeliveryOnTheBoundary() {
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().launch();
Page page = browser.newPage();
// a cart of 50.00 €
page.navigate("http://localhost:4100/cart?test-cart=5000");
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Place order")).click();
assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("Order accepted"))).isVisible();
assertThat(page.getByText("Delivery: free")).isVisible();
}
}
}

Notice how the test finds the button: by role and name ("a button named Place order"), not by CSS (#app > div:nth-child(3) > button). Role and name are what the customer sees and what a screen reader says to a blind person. They change when the meaning changes. CSS changes when the designer moves something.

Playwright checks wait by themselves: "is the heading visible" waits for up to a few seconds until it appears. That is why there are no pauses in the test.

More precisely, if you are curious

The test cart (?test-cart=5000) is an arrangement with the programmers: in the test environment the shop can start with a ready cart, so that the UI test does not have to search for and add products. This is a part of the test that lives not in Playwright but in the shop.

Playwright is made by Microsoft and is open source. Other widespread tools are Selenium (the oldest) and Cypress (JavaScript only). Locating by role was made popular by Testing Library, and the roles are those of the ARIA accessibility standard.

The idea

Why UI tests break and why there are few

A UI test is the closest to the customer and the most expensive:

  • slow: a browser, a network, a page. Seconds per test, not milliseconds;
  • needs everything: a running shop, a database, test data;
  • brittle: it fails because of a moved button, a slow network, an animation. A brittle test fails without a defect, and people stop trusting it.

That is why there are few UI tests: only the end-to-end paths that must not break. The rules are checked further down: the amounts at component level, the server contract with an API test.

This UI test does not run in the course: it needs a browser and a running shop, which the course checker does not have. For the same reason, in the CI configuration in the next lesson the UI tests are kept apart and run once a night against the test environment, not on every change. The scenarios, the component tests and the API tests in this module really run.

Try it

Which locator survives?

1. Which locator survives if the designer moves the "Place order" button into another block and changes its classes?
2. The button is <div class="btn" onclick="…">Place order</div>. A test looking for the role "button" does not find it. What does this mean?
3. After "Place order" the confirmation appears after half a second. How does the test wait for it?

Exercise

Which level for what

1. API, UI or further down. The shop is sorting out its tests.

1. What is best checked with an API test?
2. The shop has 400 UI tests, they run for 2 hours, and every day five or six fail without a defect. What do you suggest?
3. Which UI test is worth the most?

Test

Check yourself

1. What does an API test check?
2. Why does a UI test find elements by role and name and not by CSS?
3. Why are there few UI tests?
4. The server copied the rule and forgot the refusal. Which test catches it?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·