A minimal web app in Java using some common tools.

§Project Structure

1
2
3
4
5
6
7
8
9
10
my-app/
├── javalin-server/
│ ├── App.java
│ └── build.gradle
├── k6-tests/
│ └── load-test.js
├── playwright-tests/
│ ├── test.js
│ └── package.json
└── Makefile

§The Web App

For this extremely lean application, Javalin hits the sweet spot: minimal code, active maintenance, and zero ceremony. One file, CRUD endpoints, done. Alternatives: Spring Boot, Helidon.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import io.javalin.Javalin;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.UUID;

public class App {
static final Map<String, Item> items = new ConcurrentHashMap<>();
record Item(String id, String name, String description) {}

public static void main(String[] args) {
var app = Javalin.create().start(8080);
System.out.println("CRUD server on http://localhost:8080");

// CREATE
app.post("/items", ctx -> {
var id = UUID.randomUUID().toString();
items.put(id, new Item(id, ctx.formParam("name"), ctx.formParam("description")));
ctx.status(201).json(items.get(id));
});

// READ all
app.get("/items", ctx -> ctx.json(items.values()));

// READ one
app.get("/items/{id}", ctx -> {
var item = items.get(ctx.pathParam("id"));
if (item == null) ctx.status(404);
else ctx.json(item);
});

// UPDATE
app.put("/items/{id}", ctx -> {
var id = ctx.pathParam("id");
if (!items.containsKey(id)) { ctx.status(404); return; }
items.put(id, new Item(id, ctx.formParam("name"), ctx.formParam("description")));
ctx.json(items.get(id));
});

// DELETE
app.delete("/items/{id}", ctx -> {
ctx.status(items.remove(ctx.pathParam("id")) == null ? 404 : 204);
});
}
}

§Build with Gradle

Think of build.gradle as a Makefile for Java projects. Here’s what each section does:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
plugins {
id 'application' // Enables: gradle run (like make run)
}

repositories {
mavenCentral() // Where to download libraries (like apt source)
}

dependencies {
implementation 'io.javalin:javalin:6.3.0' // Web framework
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.0' // JSON
}

application {
mainClass = 'App' // Entry point: which class has main()
}

sourceSets {
main {
java {
srcDirs = ['.'] // Look for .java here, not src/main/java/
}
}
}

§Load Testing with k6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
stages: [
{ duration: '5s', target: 5 },
{ duration: '10s', target: 5 },
{ duration: '5s', target: 0 },
],
};

const BASE = 'http://localhost:8080';

export default function () {
// CREATE
const payload = { name: 'Test', description: 'Test item' };
const create = http.post(`${BASE}/items`, payload);
check(create, { 'POST /items 201': (r) => r.status === 201 });
const id = JSON.parse(create.body).id;

// READ
const get = http.get(`${BASE}/items/${id}`);
check(get, { 'GET /items/{id} 200': (r) => r.status === 200 });

// READ all
const list = http.get(`${BASE}/items`);
check(list, { 'GET /items 200': (r) => r.status === 200 });

// UPDATE
const update = http.put(`${BASE}/items/${id}`, { name: 'Updated', description: 'Updated desc' });
check(update, { 'PUT /items/{id} 200': (r) => r.status === 200 });

// DELETE
const del = http.del(`${BASE}/items/${id}`);
check(del, { 'DELETE /items/{id} 204': (r) => r.status === 204 });

sleep(1);
}

The test exercises the full CRUD cycle: create an item, read it back, list all items, update it, then delete it. The stages ramp up to 5 virtual users, hold, then ramp down. The sleep(1) simulates realistic user pacing between requests.

§API Testing with Playwright

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const { chromium, request, expect } = require('@playwright/test');

(async () => {
// Setup: Create records via API
const apiContext = await request.newContext({ baseURL: 'http://localhost:8080' });

const item1 = await apiContext.post('/items', { form: { name: 'Widget', description: 'A useful widget' } });
const item2 = await apiContext.post('/items', { form: { name: 'Gadget', description: 'A fancy gadget' } });

console.log('Created:', await item1.json());
console.log('Created:', await item2.json());

await apiContext.dispose();

// GUI: Launch browser to view records
const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();

await page.goto('http://localhost:8080/items');
console.log('Viewing items list...');

// Assert that the page contains the created items
await page.waitForLoadState('networkidle');

const body = page.locator('body');

// Print the body content
// console.log('Page content:', await body.textContent());

await expect(body).toContainText('Widget');
await expect(body).toContainText('A useful widget');
await expect(body).toContainText('Gadget');
await expect(body).toContainText('A fancy gadget');

console.log('✓ Both items found on page with correct content');

await browser.close();
console.log('Test completed successfully');
})();

Playwright tests both the API and the GUI. First, it creates records via the HTTP API. Then it launches a browser, navigates to the items page, and asserts that the created items are visible using expect().toContainText().

§Makefile

1
2
3
4
5
6
7
8
9
10
.PHONY: server k6 playwright

server:
cd javalin-server && gradle run

k6:
k6 run k6-tests/load-test.js

playwright:
cd playwright-tests && node test.js