JAVA / CAPSTONE PROJECTS
Project: a task API with tests against every endpoint
Build a small HTTP task API in plain Java and drive every route from a test that starts the server on a random port and asserts status codes and bodies.
What you will learn
- Bind the test server to port 0 and build the base URL from the port you get back
- Assert status code, then Location and other headers, then the JSON body
- Cover failure routes too: 400 on missing title, 404 on bad id, 405 on wrong verb
- Reset the store in setup so no test depends on another test having run first
Understanding Project: a task API with tests against every endpoint
An endpoint test and a unit test answer different questions. A unit test on the task store can prove that creating a task leaves done set to false; only a request sent over a socket can prove that POST /tasks is the path that reaches that code, that it answers 201, and that the JSON on the wire spells the field done rather than isDone. The mental model is that your handler's real caller is a socket, so the test should be a socket too: build the request, read the status line, then look at the body.
That means the test owns the server's lifecycle. Bind to port 0 and the operating system hands you a free port, which you read back with getAddress().getPort() and use to build the base URL, so nothing in the test file mentions 8080 and two suites can run side by side on one machine. Stop the server when the suite finishes, or the HttpServer dispatcher thread keeps the JVM alive after the last assertion. Rebuild the store in the same setup step, otherwise the first test that creates a task quietly changes the expected answer for every test after it.
Every endpoint means every method-and-path pair plus the failure branch of each one. Write the list down first: POST /tasks 201, GET /tasks 200, GET /tasks/{id} 200 or 404, PUT /tasks/{id} 200 or 404, DELETE /tasks/{id} 204 or 404, and a wrong verb on the collection 405. The status code deserves its own assertion because it is the part clients branch on, and a create handler that returns 200 with a perfectly correct body has still broken every client that expected 201 and a Location header.
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
public class TaskApi {
record Task(int id, String title, boolean done) {
String json() {
return "{\"id\":" + id + ",\"title\":\"" + title + "\",\"done\":" + done + "}";
}
}
static final Map<Integer, Task> store = new LinkedHashMap<>();
static int nextId = 1;
static int checks = 0, failures = 0;
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/tasks", TaskApi::route);
server.start();
String base = "http://127.0.0.1:" + server.getAddress().getPort() + "/tasks";
HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
HttpResponse<String> created = call(http, "POST", base, "{\"title\":\"write tests\"}");
check("POST /tasks -> 201 + Location", created.statusCode() == 201
&& created.headers().firstValue("Location").orElse("").equals("/tasks/1"));
check("POST /tasks without title -> 400", call(http, "POST", base, "{}").statusCode() == 400);
HttpResponse<String> list = call(http, "GET", base, null);
check("GET /tasks -> 200 with the new task", list.statusCode() == 200
&& list.body().equals("[{\"id\":1,\"title\":\"write tests\",\"done\":false}]"));
check("GET /tasks/1 -> 200", call(http, "GET", base + "/1", null).statusCode() == 200);
check("GET /tasks/99 -> 404", call(http, "GET", base + "/99", null).statusCode() == 404);
HttpResponse<String> updated = call(http, "PUT", base + "/1", "{\"title\":\"write tests\",\"done\":true}");
check("PUT /tasks/1 -> 200 and done=true", updated.statusCode() == 200
&& updated.body().contains("\"done\":true"));
HttpResponse<String> deleted = call(http, "DELETE", base + "/1", null);
check("DELETE /tasks/1 -> 204 with empty body", deleted.statusCode() == 204 && deleted.body().isEmpty());
check("GET /tasks/1 after delete -> 404", call(http, "GET", base + "/1", null).statusCode() == 404);
check("DELETE /tasks -> 405", call(http, "DELETE", base, null).statusCode() == 405);
server.stop(0);
System.out.println(checks + " checks, " + failures + " failures");
}
static void route(HttpExchange ex) throws IOException {
String rest = ex.getRequestURI().getPath().substring("/tasks".length());
String method = ex.getRequestMethod();
if (rest.isEmpty() || rest.equals("/")) {
if (method.equals("GET")) send(ex, 200, listJson());
else if (method.equals("POST")) create(ex);
else send(ex, 405, "{\"error\":\"method not allowed\"}");
return;
}
Task task = store.get(idOf(rest.substring(1)));
if (task == null) {
send(ex, 404, "{\"error\":\"no task with that id\"}");
return;
}
switch (method) {
case "GET" -> send(ex, 200, task.json());
case "PUT" -> {
String body = read(ex);
String title = titleOf(body);
Task edited = new Task(task.id(), title == null ? task.title() : title,
body.contains("\"done\":true"));
store.put(edited.id(), edited);
send(ex, 200, edited.json());
}
case "DELETE" -> {
store.remove(task.id());
send(ex, 204, "");
}
default -> send(ex, 405, "{\"error\":\"method not allowed\"}");
}
}
static void create(HttpExchange ex) throws IOException {
String title = titleOf(read(ex));
if (title == null || title.isBlank()) {
send(ex, 400, "{\"error\":\"title is required\"}");
return;
}
Task task = new Task(nextId++, title, false);
store.put(task.id(), task);
ex.getResponseHeaders().set("Location", "/tasks/" + task.id());
send(ex, 201, task.json());
}
static String listJson() {
StringBuilder out = new StringBuilder("[");
for (Task task : store.values()) {
if (out.length() > 1) out.append(',');
out.append(task.json());
}
return out.append(']').toString();
}
static String titleOf(String json) {
int key = json.indexOf("\"title\"");
int colon = key < 0 ? -1 : json.indexOf(':', key);
int open = colon < 0 ? -1 : json.indexOf('"', colon);
int close = open < 0 ? -1 : json.indexOf('"', open + 1);
return close < 0 ? null : json.substring(open + 1, close);
}
static Integer idOf(String text) {
try {
return Integer.valueOf(text);
} catch (NumberFormatException e) {
return null;
}
}
static String read(HttpExchange ex) throws IOException {
return new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
}
static void send(HttpExchange ex, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0) {
ex.sendResponseHeaders(status, -1);
} else {
ex.getResponseHeaders().set("Content-Type", "application/json");
ex.sendResponseHeaders(status, bytes.length);
ex.getResponseBody().write(bytes);
}
ex.close();
}
static HttpResponse<String> call(HttpClient http, String method, String url, String body) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body))
.build();
return http.send(request, HttpResponse.BodyHandlers.ofString());
}
static void check(String label, boolean ok) {
checks++;
if (!ok) failures++;
System.out.println((ok ? "PASS " : "FAIL ") + label);
}
}An endpoint is tested only when a real request travels over HTTP and the test asserts on the status code, headers and body that a client would see.
Worked examples
Let the OS choose the port
Two API servers start in one JVM because neither of them names a port, and the test builds its URL from the port it was given.
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class EphemeralPort {
public static void main(String[] args) throws Exception {
HttpServer first = boot();
HttpServer second = boot();
int a = first.getAddress().getPort();
int b = second.getAddress().getPort();
System.out.println("both bound: " + (a > 0 && b > 0));
System.out.println("ports differ: " + (a != b));
HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
HttpResponse<String> r = http.send(
HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + a + "/health")).build(),
HttpResponse.BodyHandlers.ofString());
System.out.println("GET /health -> " + r.statusCode() + " " + r.body());
first.stop(0);
second.stop(0);
}
static HttpServer boot() throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/health", ex -> {
byte[] body = "ok".getBytes();
ex.sendResponseHeaders(200, body.length);
ex.getResponseBody().write(body);
ex.close();
});
server.start();
return server;
}
}Example explained
Line 1Port 0 in the InetSocketAddress means any free port, so boot() can be called twice without an Address already in use failure.
Line 2getAddress().getPort() reports the port that was actually assigned, which is the only safe source for the base URL.
Line 3Binding to 127.0.0.1 instead of 0.0.0.0 keeps the test server off the network while the suite runs.
Line 4ex.close() is what finishes the response; a handler that returns without closing leaves the client blocked until it times out.
The body can be right while the response is wrong
Two create handlers return identical JSON, but only one satisfies the assertion on status code and Location.
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class CreateContract {
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/v1/tasks", ex -> reply(ex, 200, null));
server.createContext("/v2/tasks", ex -> reply(ex, 201, "/v2/tasks/1"));
server.start();
String base = "http://127.0.0.1:" + server.getAddress().getPort();
HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
for (String version : new String[] {"v1", "v2"}) {
HttpRequest request = HttpRequest.newBuilder(URI.create(base + "/" + version + "/tasks"))
.POST(HttpRequest.BodyPublishers.ofString("{\"title\":\"buy milk\"}"))
.build();
HttpResponse<String> r = http.send(request, HttpResponse.BodyHandlers.ofString());
boolean ok = r.statusCode() == 201 && r.headers().firstValue("Location").isPresent();
System.out.println(version + ": status=" + r.statusCode()
+ " location=" + r.headers().firstValue("Location").isPresent()
+ " body=" + r.body() + " -> " + (ok ? "PASS" : "FAIL"));
}
server.stop(0);
}
static void reply(HttpExchange ex, int status, String location) throws IOException {
if (location != null) ex.getResponseHeaders().set("Location", location);
byte[] body = "{\"id\":1,\"title\":\"buy milk\",\"done\":false}".getBytes();
ex.getResponseHeaders().set("Content-Type", "application/json");
ex.sendResponseHeaders(status, body.length);
ex.getResponseBody().write(body);
ex.close();
}
}Example explained
Line 1Both contexts write the same bytes, so a test that only inspected r.body() would report two passes.
Line 2r.statusCode() == 201 is the assertion that catches v1: 200 tells a client nothing was created.
Line 3headers().firstValue("Location") is empty for v1, so a client that follows the new task's URL has nothing to follow.
Line 4Running one test function against two handlers shows that the verdict comes from the assertions, not from the server being up.
Leaked state makes the second run fail
The same request and the same assertion pass once and then fail, because the store outlives the test.
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
public class FreshState {
static List<String> store = new ArrayList<>();
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/tasks", ex -> {
if (ex.getRequestMethod().equals("POST")) store.add("t" + (store.size() + 1));
byte[] body = store.toString().getBytes();
ex.sendResponseHeaders(200, body.length);
ex.getResponseBody().write(body);
ex.close();
});
server.start();
String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/tasks";
HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
for (int run = 1; run <= 2; run++) {
// store = new ArrayList<>(); // the setup step that is missing
String body = post(http, url);
System.out.println("run " + run + ": body=" + body + " exactly one task? " + body.equals("[t1]"));
}
server.stop(0);
}
static String post(HttpClient http, String url) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
return http.send(request, HttpResponse.BodyHandlers.ofString()).body();
}
}Example explained
Line 1store is static, so it lives as long as the JVM and the second request sees the first request's task.
Line 2body.equals("[t1]") passes in run 1 and fails in run 2 although neither the handler nor the request changed.
Line 3Uncommenting the reset line is what a JUnit @BeforeEach does: rebuild the store so every test starts from the same known state.
Line 4This is why a suite that passes when run one test at a time can still fail when run as a whole.
Important notes
A 204 response carries no body: send it with sendResponseHeaders(status, -1) and write nothing, because a 204 with bytes after it is not a valid HTTP response and clients may reject or mis-frame it.
com.sun.net.httpserver speaks only HTTP/1.1, so pinning the test client to HTTP_1_1 avoids a pointless upgrade attempt; the hand-written JSON here just keeps the file dependency-free, and swapping in a framework plus Jackson leaves the tests unchanged because they only ever see HTTP.
Common mistakes
Hardcoding http://localhost:8080 in the tests: the suite passes on your machine and then fails in CI with Address already in use, or silently talks to a dev server you left running and asserts against its data.
Asserting only on the response body: a create endpoint that answers 200 with no Location header passes every check, and the client that was supposed to read the new task's URL from that header breaks in production.
Reusing one static store for the whole suite: the first test that creates a task changes the expected answer for every later test, so you end up making tests green by reordering them instead of by fixing the code.
Try it yourself
Change, predict, then run
Add a PATCH /tasks/{id} route that flips done and returns the updated task, then add two checks: one asserting 200 with the flipped value, and one asserting 404 for /tasks/999.
Open the Java workspaceCheck your understanding
Your task store already has a unit test proving that creating a task leaves done set to false. Why is that not a substitute for a test that POSTs to /tasks?
- Because only a request over HTTP exercises the path-to-method mapping, the 201 status, the Location header and the JSON field names
- Because unit tests cannot assert on boolean fields inside a record
- Because the HTTP test runs faster, so it is the one worth keeping
- Because the store method skips persistence while the HTTP request writes to it
Show answer
The unit test covers the domain logic and nothing at the boundary: the route, the verb, the status code and the serialised field names only exist once a request crosses HTTP. Option 4 is tempting but wrong, since the HTTP request ends up calling exactly the same store; what differs is the contract in front of it, not where the data goes.