A Javalin-based REST server for a Gold Mine game, demonstrating a test architecture that runs the same tests both in-memory and over the network.
Test architecture
The tests are written once and executed in two ways: fast in-memory and full network. Both runners extend the same base class and run the same assertions.
In-memory lane Network lane
(speed) (accuracy)
============= =============
+----------------------------+
| GoldMineTest |
| (shared tests) |
+-------------+--------------+
|
extends
|
+-----------------+-----------------+
| |
v v
+----------------------------+ +----------------------------+
| GoldMineInMemoryTest | | GoldMineServerTest |
+-------------+--------------+ | (starts the server) |
| +-------------+--------------+
| |
v v
+----------------------------+ +----------------------------+
| GoldMineInMemory | | GoldMineClient |
| implements GoldMine | | implements GoldMine |
+-------------+--------------+ +-------------+--------------+
| |
| HTTP POST /lookDown
| |
| v
| +----------------------------+
| | GoldMineServer |
| | (Javalin) |
| +-------------+--------------+
| |
+----------------+-------------------+
|
v
+----------------------------+
| GoldMineService |
| (business logic) |
+-------------+--------------+
|
v
+----------------------------+
| Map<Player, GoldMineGame> |
| (game database) |
+----------------------------+
In-memory runner (speed)
GoldMineInMemoryTest creates a GoldMineInMemory instance that calls
GoldMineService directly. No server, no network, no serialization.
This makes it fast and ideal for rapid feedback during development.
Network runner (accuracy)
GoldMineServerTest starts the server before each test and shuts it down
after. It uses GoldMineClient (Java's built-in HttpClient) to send
HTTP requests. This exercises the full stack: JSON serialization, HTTP
routing, and response handling.
Why both?
The in-memory tests catch logic errors quickly. The network tests catch integration errors (serialization bugs, routing mistakes, HTTP status codes) that only show up when the full stack is running. Running both gives you speed during development and confidence before deployment.
Common building blocks
These classes are shared by both lanes. Understanding them first makes the lane-specific code easier to follow.
The interface - GoldMine
Everything starts with a simple interface that defines what the game can do:
public interface GoldMine {
char lookDown(Player player);
}
This is the contract that both lanes implement. The in-memory lane implements it with a thin wrapper around the service. The network lane implements it with an HTTP client. Because the tests only talk to this interface, they don't know or care which implementation they are using.
The shared tests - GoldMineTest
The test logic lives in an abstract base class. It declares a goldMine field
of type GoldMine but does not create it. That is left to the subclasses:
public abstract class GoldMineTest {
GoldMine goldMine;
@Test
void should_look_down_and_find_gold() {
Player player = new Player("Thomas");
char actual = goldMine.lookDown(player);
assertThat(actual).isEqualTo('G');
}
}
Every test method in this class will run once for each subclass. Add a new test here and both lanes pick it up automatically.
The service - GoldMineService
The business logic lives here. Both lanes end up here:
public class GoldMineService implements GoldMine {
Map<Player, GoldMineGame> games = new HashMap<>();
@Override
public char lookDown(Player player) {
// Fetch the game, do the work, and return the result
// GoldMineGame game = games.get(player);
// char view = game.lookDown();
return 'G';
}
}
The Map<Player, GoldMineGame> acts as an in-memory database that maps
each player to their game. The service looks up the player's game and
returns what the player sees when looking down.
In-memory lane in detail
The in-memory lane is the left side of the diagram. It skips all network infrastructure and talks directly to the business logic.
The in-memory adapter - GoldMineInMemory
This class implements GoldMine by calling the service directly, with no
network in between:
public class GoldMineInMemory implements GoldMine {
private final GoldMineService service = new GoldMineService();
@Override
public char lookDown(Player player) {
return service.lookDown(player);
}
}
It creates a GoldMineService in its constructor and delegates every call
to it. This is the entire in-memory "stack", one object calling another
in the same JVM process.
The test runner - GoldMineInMemoryTest
The test class itself is minimal. It extends GoldMineTest and plugs in
the in-memory adapter:
class GoldMineInMemoryTest extends GoldMineTest {
public GoldMineInMemoryTest() {
goldMine = new GoldMineInMemory();
}
}
That is it. The constructor assigns a GoldMineInMemory to the goldMine
field. JUnit discovers the inherited test methods and runs them. No setup,
no teardown, no configuration. No server startup, no HTTP serialization,
and no network round-trip, so the tests run in milliseconds. Use this lane
while developing game logic. The feedback loop is near-instant.
Network lane in detail
The network lane is the right side of the diagram. It exercises the full HTTP stack: JSON serialization, HTTP transport, server routing, and deserialization. You find out here if the pieces actually work together over the wire. There is more code here than in the in-memory lane because HTTP requires serialization on both ends and careful request construction.
The test runner - GoldMineServerTest
This class extends GoldMineTest just like the in-memory version, but it
needs to start a server before each test and shut it down after:
class GoldMineServerTest extends GoldMineTest {
private GoldMineServer server;
@BeforeEach
void setUp() {
server = new GoldMineServer();
server.start(7070);
goldMine = new GoldMineClient("http://localhost:7070");
}
@AfterEach
void tearDown() {
server.stop();
}
}
@BeforeEach runs before every test method. It creates a server, starts it
on port 7070, and then creates a GoldMineClient that points to that server.
The client is assigned to the goldMine field so the inherited tests use it.
@AfterEach shuts the server down so each test starts with a clean slate.
The HTTP client - GoldMineClient
This is where things get messy. The GoldMine interface has a clean
signature, char lookDown(Player player), but HTTP requires you to
serialize the player to JSON, build a request, send it, check the status
code, and read the response body. All of that ceremony lives here:
public class GoldMineClient implements GoldMine {
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
private final String baseUrl;
public GoldMineClient(String baseUrl) {
this.baseUrl = baseUrl;
this.httpClient = HttpClient.newHttpClient();
this.objectMapper = new ObjectMapper();
}
@Override
public char lookDown(Player player) {
try {
String json = objectMapper.writeValueAsString(player);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/lookDown"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = httpClient.send(
request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException(
"Unexpected status code: " + response.statusCode());
}
return response.body().charAt(0);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
Walking through lookDown step by step:
- Serialize the player.
objectMapper.writeValueAsString(player)turns thePlayerrecord into a JSON string like{"name":"Thomas"}. Jackson knows how to do this becausePlayeris a record with anamecomponent. - Build the HTTP request. The JDK
HttpRequest.newBuilder()creates a POST request tohttp://localhost:7070/lookDownwith aContent-Typeheader set toapplication/jsonand the JSON string as the body. - Send it.
httpClient.send()sends the request and blocks until the response comes back. TheBodyHandlers.ofString()tells it to read the response body as aString. - Check the status code. If the server returns anything other than 200, something went wrong. Throw an exception.
- Read the result. The server sends back a single character as plain
text.
response.body().charAt(0)extracts it.
The try/catch wraps the whole thing because httpClient.send() throws
checked exceptions (IOException, InterruptedException). The GoldMine
interface does not declare checked exceptions, so we wrap them in a
RuntimeException.
The HttpClient and ObjectMapper are created once in the constructor and
reused across calls. The baseUrl is passed in so the client can point to
any server. In tests it is http://localhost:7070, in production it could
be a remote address.
The HTTP server - GoldMineServer
The server side is also messier than the in-memory path. It needs to set up an HTTP endpoint, deserialize the incoming JSON, call the service, and serialize the response back:
public class GoldMineServer {
private final GoldMineService goldMineService =
new GoldMineService();
private final Javalin app;
public GoldMineServer() {
app = Javalin.create(routeConfig());
}
private Consumer<JavalinConfig> routeConfig() {
return config -> {
// Add more endpoints here
config.routes.post("/lookDown", ctx -> {
Player player = ctx.bodyAsClass(Player.class);
char view = goldMineService.lookDown(player);
ctx.result(String.valueOf(view));
});
};
}
public void start(int port) {
app.start(port);
}
public void stop() {
app.stop();
}
}
Walking through what happens when a request hits /lookDown:
- Deserialize the player.
ctx.bodyAsClass(Player.class)reads the JSON request body and turns it into aPlayerrecord. Javalin uses Jackson under the hood to do this. - Call the service.
goldMineService.lookDown(player)is the same call the in-memory lane makes. The two lanes converge here. - Send the response.
ctx.result(String.valueOf(view))writes the character back as a plain text HTTP response.
The GoldMineService is created once per server instance and shared across
all requests. The start and stop methods control the embedded Jetty
server that Javalin runs on.
More endpoints can be added by stacking them inside the lambda, for example
config.routes.post("/lookUp", ...), config.routes.get("/score", ...),
and so on. This works well for a small project. A larger project would want
to extract the route definitions into separate classes.
Why the network lane is messier
Compare the in-memory adapter to the client:
- In-memory: call
service.lookDown(player)and return the result. One line. - Network: serialize to JSON, build an HTTP request, send it, check the status code, read the response body, handle checked exceptions. Plus the server has to do the reverse: deserialize JSON, call the service, serialize the result back.
This is the cost of going over HTTP. The in-memory lane proves the business logic works. The network lane proves that all this serialization and HTTP plumbing does not break anything along the way.
Dependencies
The project uses these Maven dependencies:
<dependencies>
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin</artifactId>
<version>7.1.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.3</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.17</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.3</version>
<scope>test</scope>
</dependency>
</dependencies>
- Javalin is the web framework used for the server.
- Jackson handles JSON serialization in both the client and the server.
- SLF4J Simple provides logging output for Javalin.
- JUnit Jupiter runs the tests.
- AssertJ provides readable assertions.
Build
Requires Java 17+. Uses the Maven wrapper from the parent directory.
../mvnw test install
This runs all tests and installs the artifact in the local Maven repository.
Conclusion
You want both test lanes to verify the same contract. The in-memory lane verifies it fast. The network lane verifies it accurately. Together they give you quick feedback during development and confidence that the full stack works before deployment.
Resources
- Source code - the complete example on GitHub
- Javalin - the web framework used in this example
- Thomas Sundberg - the author