Core Concepts
Automated Testing
An automated test harness is an important part of any system.
Soklet implicitly supports unit testing; types which contain your Resource Methods are Plain Old Java Objects that can be instantiated and used however you like.
Soklet explicitly supports integration testing by providing a Simulator along with special "networkless" Server and ServerSentEventServer implementations to which you can send requests and access responses that would ordinarily be sent over the wire to clients. Server-Sent Events are fully supported as well. See Integration Testing for details.
Low-complexity testing is a design goal of Soklet.
All example code below uses JUnit 5, but Soklet has no dependency on it - you may use any testing framework you like.
Unit Testing
The main concept of unit testing is to test a method or behavior in isolation.
Because Soklet does not impose constraints on your types, instantiating and injecting mock dependencies is straightforward. You might build your own mocks or use a library like Mockito.
Let's define a ReverseResource and exercise it with unit tests.
public class ReverseResource {
// Reverse the input
@POST("/reverse")
public List<Integer> reverse(@RequestBody List<Integer> numbers) {
return numbers.reversed();
}
// Reverse the input and set custom headers/cookies
@POST("/reverse-again")
public Response reverseAgain(@RequestBody List<Integer> numbers) {
Integer largest = Collections.max(numbers);
Instant lastRequest = Instant.now();
return Response.withStatusCode(200)
.headers(Map.of("X-Largest", Set.of(String.valueOf(largest))))
.cookies(Set.of(
ResponseCookie.with("lastRequest", lastRequest.toString()).build()
))
.body(numbers.reversed())
.build();
}
}
public class ReverseResource {
// Reverse the input
@POST("/reverse")
public List<Integer> reverse(@RequestBody List<Integer> numbers) {
return numbers.reversed();
}
// Reverse the input and set custom headers/cookies
@POST("/reverse-again")
public Response reverseAgain(@RequestBody List<Integer> numbers) {
Integer largest = Collections.max(numbers);
Instant lastRequest = Instant.now();
return Response.withStatusCode(200)
.headers(Map.of("X-Largest", Set.of(String.valueOf(largest))))
.cookies(Set.of(
ResponseCookie.with("lastRequest", lastRequest.toString()).build()
))
.body(numbers.reversed())
.build();
}
}
Unit tests for our ReverseResource:
// Pull in JUnit
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@Test
public void reverseUnitTest() {
// This is a Plain Old Java Object, no Soklet dependency
ReverseResource resource = new ReverseResource();
List<Integer> input = List.of(1, 2, 3);
List<Integer> expected = List.of(3, 2, 1);
List<Integer> actual = resource.reverse(input);
assertEquals(expected, actual, "Reverse failed");
}
@Test
public void reverseAgainUnitTest() {
ReverseResource resource = new ReverseResource();
List<Integer> input = List.of(1, 2, 3);
// Set expectations
List<Integer> expectedBody = List.of(3, 2, 1);
Integer expectedCode = 200;
Integer expectedLargest = Collections.max(input);
Instant lastRequestAfter = Instant.now();
Response response = resource.reverseAgain(input);
// Extract actuals
Integer actualCode = response.getStatusCode();
List<Integer> actualBody = (List<Integer>) response.getBody().get();
Integer actualLargest = response.getHeaders().get("X-Largest").stream()
.findAny()
.map(value -> Integer.valueOf(value))
.get();
Instant actualLastRequest = response.getCookies().stream()
.filter(responseCookie -> responseCookie.getName().equals("lastRequest"))
.findAny()
.map(responseCookie -> Instant.parse(responseCookie.getValue().get()))
.get();
// Verify expectations vs. actuals
assertEquals(expectedCode, actualCode, "Bad status code");
assertEquals(expectedBody, actualBody, "Reverse failed");
assertEquals(expectedLargest, actualLargest, "Largest header failed");
assertTrue(actualLastRequest.isAfter(lastRequestAfter), "Last request too early");
}
// Pull in JUnit
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@Test
public void reverseUnitTest() {
// This is a Plain Old Java Object, no Soklet dependency
ReverseResource resource = new ReverseResource();
List<Integer> input = List.of(1, 2, 3);
List<Integer> expected = List.of(3, 2, 1);
List<Integer> actual = resource.reverse(input);
assertEquals(expected, actual, "Reverse failed");
}
@Test
public void reverseAgainUnitTest() {
ReverseResource resource = new ReverseResource();
List<Integer> input = List.of(1, 2, 3);
// Set expectations
List<Integer> expectedBody = List.of(3, 2, 1);
Integer expectedCode = 200;
Integer expectedLargest = Collections.max(input);
Instant lastRequestAfter = Instant.now();
Response response = resource.reverseAgain(input);
// Extract actuals
Integer actualCode = response.getStatusCode();
List<Integer> actualBody = (List<Integer>) response.getBody().get();
Integer actualLargest = response.getHeaders().get("X-Largest").stream()
.findAny()
.map(value -> Integer.valueOf(value))
.get();
Instant actualLastRequest = response.getCookies().stream()
.filter(responseCookie -> responseCookie.getName().equals("lastRequest"))
.findAny()
.map(responseCookie -> Instant.parse(responseCookie.getValue().get()))
.get();
// Verify expectations vs. actuals
assertEquals(expectedCode, actualCode, "Bad status code");
assertEquals(expectedBody, actualBody, "Reverse failed");
assertEquals(expectedLargest, actualLargest, "Largest header failed");
assertTrue(actualLastRequest.isAfter(lastRequestAfter), "Last request too early");
}
Integration Testing
Integration testing concerns itself with exercising a system or one of its subsystems as a whole, simulating real-world behavior as closely as possible.
Running tests of this nature for web-based applications has traditionally been awkward and complicated; Soklet attempts to lower this barrier. You are encouraged to think of your system as a collection of reusable components capable of running in any context, not just code that "lives inside" a web server.
To facilitate integration testing, Soklet provides a static Soklet::runSimulator method that takes your application's SokletConfig as input and allows you to run it on Simulator, using special "networkless" Server and ServerSentEventServer implementations. No ports are listened on, no sockets opened.
All parts of the request-handling flow execute as they normally would for a real HTTP request. For example, perhaps you have configured your application to:
- Perform per-request user authorization
- Wrap Resource Methods in database transactions
- Accept JSON request bodies and write JSON responses
These would all occur when running on the Simulator, just like they would for real client requests.
See Real-World Examples
The Toy Store App documentation has its own suite of integration tests - they exercise talking to a database, accepting and returning JSON, and more.
Marshaled Responses
Your ResponseMarshaler produces MarshaledResponse instances as output - these encapsulate what ultimately gets sent over the wire to the client. For example, a UTF-8 byte[] representation of a JSON response body, as opposed to a Java object that is later marshaled to JSON.
Let's define a HelloResource...
public class HelloResource {
// Respond with a friendly message
@GET("/hello")
public String hello(@QueryParameter String name) {
return String.format("Hello, %s", name);
}
}
public class HelloResource {
// Respond with a friendly message
@GET("/hello")
public String hello(@QueryParameter String name) {
return String.format("Hello, %s", name);
}
}
...and exercise it with an integration test.
Invoking Simulator::performRequest simulates a client request - without touching the network - and returns a RequestResult, which can be examined to determine the request's outcome.
@Test
public void integrationTest() {
// Get a reference to your app's configuration
SokletConfig config = obtainMySokletConfig();
// Running in the Simulator means:
// * Your config's server[s] are replaced with networkless variants
// * You can easily perform requests and examine responses
Soklet.runSimulator(config, (simulator -> {
// Construct a request.
// The `withPath` builder assumes paths and query parameters
// are already decoded, which is normally what you want for tests.
// But you can also construct with a "raw" encoded URL + query, e.g.
// Request.withRawUrl("/hello?name=Mark%20Test")
Request request = Request.withPath(HttpMethod.GET, "/hello")
.queryParameters(Map.of("name", Set.of("Mark")))
.build();
// Perform the request and get a handle to its result...
RequestResult result = simulator.performRequest(request);
// ...and get the finalized response that would have gone over the wire.
MarshaledResponse marshaledResponse = result.getMarshaledResponse();
// Verify status code
Integer expectedCode = 200;
Integer actualCode = marshaledResponse.getStatusCode();
assertEquals(expectedCode, actualCode, "Bad status code");
// Verify response body.
// Here we turn UTF-8 bytes back into a String
marshaledResponse.getBody().ifPresentOrElse(body -> {
String expectedBody = "Hello, Mark";
String actualBody = new String(body, StandardCharsets.UTF_8);
assertEquals(expectedBody, actualBody, "Bad response body");
}, () -> {
fail("No response body");
});
}));
}
@Test
public void integrationTest() {
// Get a reference to your app's configuration
SokletConfig config = obtainMySokletConfig();
// Running in the Simulator means:
// * Your config's server[s] are replaced with networkless variants
// * You can easily perform requests and examine responses
Soklet.runSimulator(config, (simulator -> {
// Construct a request.
// The `withPath` builder assumes paths and query parameters
// are already decoded, which is normally what you want for tests.
// But you can also construct with a "raw" encoded URL + query, e.g.
// Request.withRawUrl("/hello?name=Mark%20Test")
Request request = Request.withPath(HttpMethod.GET, "/hello")
.queryParameters(Map.of("name", Set.of("Mark")))
.build();
// Perform the request and get a handle to its result...
RequestResult result = simulator.performRequest(request);
// ...and get the finalized response that would have gone over the wire.
MarshaledResponse marshaledResponse = result.getMarshaledResponse();
// Verify status code
Integer expectedCode = 200;
Integer actualCode = marshaledResponse.getStatusCode();
assertEquals(expectedCode, actualCode, "Bad status code");
// Verify response body.
// Here we turn UTF-8 bytes back into a String
marshaledResponse.getBody().ifPresentOrElse(body -> {
String expectedBody = "Hello, Mark";
String actualBody = new String(body, StandardCharsets.UTF_8);
assertEquals(expectedBody, actualBody, "Bad response body");
}, () -> {
fail("No response body");
});
}));
}
Logical Responses
RequestResult also provides access to the logical Response, determined by the object returned by the Resource Method (if available). This lets you see what the response body looks like pre-marshaling.
For example, let's define a Toy and a Resource Method which vends a list of them...
record Toy(
String name,
Double price,
Currency currency
) {}
public class ToyResource {
@GET("/toys")
public List<Toy> toys() {
return List.of(
new Toy("Basketball", 25.99, Currency.getInstance("USD")),
new Toy("Bola de futebol", 9499.00, Currency.getInstance("BRL"))
);
}
}
record Toy(
String name,
Double price,
Currency currency
) {}
public class ToyResource {
@GET("/toys")
public List<Toy> toys() {
return List.of(
new Toy("Basketball", 25.99, Currency.getInstance("USD")),
new Toy("Bola de futebol", 9499.00, Currency.getInstance("BRL"))
);
}
}
...then, simulate a call to GET /toys and pull the logical Response returned by the Resource Method:
SokletConfig config = obtainMySokletConfig();
Soklet.runSimulator(config, (simulator -> {
Request request = Request.withPath(HttpMethod.GET, "/toys").build();
// Perform the request and get a handle to its result...
RequestResult result = simulator.performRequest(request);
// ...and get the "pre-marshaling" response.
Response response = result.getResponse().get();
// Does the Toy data look like we expect?
List<Toy> toys = (List<Toy>) response.getBody().get();
assertTrue(toys.size() == 2, "Wrong number of Toys");
Toy expectedToy = new Toy("Basketball", 25.99, Currency.getInstance("USD"));
Toy actualToy = toys.get(0);
assertEquals(expectedToy, actualToy, "Unexpected Toy");
}));
SokletConfig config = obtainMySokletConfig();
Soklet.runSimulator(config, (simulator -> {
Request request = Request.withPath(HttpMethod.GET, "/toys").build();
// Perform the request and get a handle to its result...
RequestResult result = simulator.performRequest(request);
// ...and get the "pre-marshaling" response.
Response response = result.getResponse().get();
// Does the Toy data look like we expect?
List<Toy> toys = (List<Toy>) response.getBody().get();
assertTrue(toys.size() == 2, "Wrong number of Toys");
Toy expectedToy = new Toy("Basketball", 25.99, Currency.getInstance("USD"));
Toy actualToy = toys.get(0);
assertEquals(expectedToy, actualToy, "Unexpected Toy");
}));
CORS Responses
If you'd like to ensure your CORS configuration is correct, you might examine RequestResult::getCorsPreflightResponse to verify it.
Here we make an OPTIONS request with Origin and Access-Control-Request-Method headers to simulate a CORS preflight, just like a browser would do. We verify that Soklet approves the preflight and responds appropriately.
// Specify an origin to whitelist for CORS...
String corsOrigin = "https://example.soklet.com";
// ...and tell Soklet about it.
SokletConfig config = obtainMySokletConfig().copy()
.corsAuthorizer(CorsAuthorizer.withWhitelistedOrigins(
Set.of(corsOrigin)
))
.finish();
Soklet.runSimulator(configuration, simulator -> {
// Make a CORS preflight request...
Request request = Request.withPath(HttpMethod.OPTIONS, "/hello")
.headers(Map.of(
"Origin", Set.of(corsOrigin),
"Access-Control-Request-Method", Set.of("GET")
))
.build()
RequestResult result = simulator.performRequest(request);
// ...and verify that Soklet has set the
// Access-Control-Allow-Origin response header correctly.
CorsPreflightResponse corsPreflightResponse =
result.getCorsPreflightResponse().get();
String expectedHeader = corsOrigin;
Integer actualHeader = corsPreflightResponse.getAccessControlAllowOrigin();
assertEquals(expectedHeader, actualHeader, "Wrong CORS preflight header");
});
// Specify an origin to whitelist for CORS...
String corsOrigin = "https://example.soklet.com";
// ...and tell Soklet about it.
SokletConfig config = obtainMySokletConfig().copy()
.corsAuthorizer(CorsAuthorizer.withWhitelistedOrigins(
Set.of(corsOrigin)
))
.finish();
Soklet.runSimulator(configuration, simulator -> {
// Make a CORS preflight request...
Request request = Request.withPath(HttpMethod.OPTIONS, "/hello")
.headers(Map.of(
"Origin", Set.of(corsOrigin),
"Access-Control-Request-Method", Set.of("GET")
))
.build()
RequestResult result = simulator.performRequest(request);
// ...and verify that Soklet has set the
// Access-Control-Allow-Origin response header correctly.
CorsPreflightResponse corsPreflightResponse =
result.getCorsPreflightResponse().get();
String expectedHeader = corsOrigin;
Integer actualHeader = corsPreflightResponse.getAccessControlAllowOrigin();
assertEquals(expectedHeader, actualHeader, "Wrong CORS preflight header");
});
Server-Sent Events
If your system uses Server-Sent Events, you can use Soklet's Simulator::performServerSentEventRequest to perform an SSE "handshake" and then broadcast events as usual. You can also act as a client, listening for Server-Sent Event and Comment payloads to verify they are transmitted as expected.
To demonstrate, let's assume we have an Event Source Method available at resource path /chats/{chatId}/event-source which lets clients listen for chat messages...
public class ChatResource {
@ServerSentEventSource("/chats/{chatId}/event-source")
public HandshakeResult chatEventSource(@PathParameter Long chatId) {
// Accept everyone, no special behavior
return HandshakeResult.accept();
}
}
public class ChatResource {
@ServerSentEventSource("/chats/{chatId}/event-source")
public HandshakeResult chatEventSource(@PathParameter Long chatId) {
// Accept everyone, no special behavior
return HandshakeResult.accept();
}
}
...and a ChatService backend which broadcasts new messages to the chat:
public class ChatService {
private ServerSentEventServer sseServer;
// Accepts a reference to ServerSentEventServer,
// which provides SSE broadcasters on-demand
public ChatService(ServerSentEventServer sseServer) {
this.sseServer = sseServer;
}
public createChatMessage(Long chatId, String message) {
// Construct a path for the appropriate Event Source...
String eventSourcePath = String.format(
"/chats/%s/event-source", chatId
);
ResourcePath resourcePath = ResourcePath.withPath(eventSourcePath);
// ...get a handle to a broadcaster for it...
ServerSentEventBroadcaster broadcaster =
sseServer.acquireBroadcaster(resourcePath).get();
// ...construct the chat message payload...
ServerSentEvent event = ServerSentEvent.withEvent("chat-message")
.data(message)
.build();
// ...and send it to all connected clients.
broadcaster.broadcastEvent(event);
}
}
public class ChatService {
private ServerSentEventServer sseServer;
// Accepts a reference to ServerSentEventServer,
// which provides SSE broadcasters on-demand
public ChatService(ServerSentEventServer sseServer) {
this.sseServer = sseServer;
}
public createChatMessage(Long chatId, String message) {
// Construct a path for the appropriate Event Source...
String eventSourcePath = String.format(
"/chats/%s/event-source", chatId
);
ResourcePath resourcePath = ResourcePath.withPath(eventSourcePath);
// ...get a handle to a broadcaster for it...
ServerSentEventBroadcaster broadcaster =
sseServer.acquireBroadcaster(resourcePath).get();
// ...construct the chat message payload...
ServerSentEvent event = ServerSentEvent.withEvent("chat-message")
.data(message)
.build();
// ...and send it to all connected clients.
broadcaster.broadcastEvent(event);
}
}
We can now write an integration test that exercises Server-Sent Events from both client and server perspectives.
// Get a reference to your app's configuration
SokletConfig config = obtainMySokletConfig();
// Provide our ChatService with the ServerSentEventServer
// so it can acquire broadcasters to send SSE payloads
ChatService chatService = new ChatService(
config.getServerSentEventServer().get()
);
Soklet.runSimulator(config, (simulator -> {
// Create a request for an SSE Event Source...
final Long CHAT_ID = 123L;
String eventSourcePath = String.format("/chats/%s/event-source", CHAT_ID);
Request request = Request.withPath(HttpMethod.GET, eventSourcePath).build();
// ...and perform it and get a handle to the result.
ServerSentEventRequestResult result =
simulator.performServerSentEventRequest(eventSourceRequest);
// The Simulator provides 3 logical outcomes for SSE connections:
// * Accepted handshake (connection stays open)
// * Rejected handshake (explicit rejection - connection closed)
// * Request failed (implicit rejection, e.g. uncaught exception - connection closed)
switch (result) {
// Happy path: handshake was accepted
case HandshakeAccepted handshakeAccepted -> {
// Single-shot latch; we'll wait until a Server-Sent Event comes through
CountDownLatch countDownLatch = new CountDownLatch(1);
// Listen for Server-Sent Events
handshakeAccepted.registerEventConsumer((serverSentEvent) -> {
// Once we see an event come through, unlatch and end the test
countDownLatch.countDown();
});
// On a separate thread, broadcast a Server-Sent Event
new Thread(() -> {
chatService.createChatMessage(CHAT_ID, "test message");
}).start();
// Wait a bit for unlatch
try {
countDownLatch.await(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
fail("Didn't receive a Server-Sent Event in time");
}
}
// Handshake was rejected (should not happen here)
case HandshakeRejected handshakeRejected -> {
fail(String.format("SSE handshake rejected: %s", handshakeRejected));
}
// Request failed for some other reason (should not happen here)
case RequestFailed requestFailed -> {
fail(String.format("SSE request failed: %s", requestFailed));
}
}
}));
// Get a reference to your app's configuration
SokletConfig config = obtainMySokletConfig();
// Provide our ChatService with the ServerSentEventServer
// so it can acquire broadcasters to send SSE payloads
ChatService chatService = new ChatService(
config.getServerSentEventServer().get()
);
Soklet.runSimulator(config, (simulator -> {
// Create a request for an SSE Event Source...
final Long CHAT_ID = 123L;
String eventSourcePath = String.format("/chats/%s/event-source", CHAT_ID);
Request request = Request.withPath(HttpMethod.GET, eventSourcePath).build();
// ...and perform it and get a handle to the result.
ServerSentEventRequestResult result =
simulator.performServerSentEventRequest(eventSourceRequest);
// The Simulator provides 3 logical outcomes for SSE connections:
// * Accepted handshake (connection stays open)
// * Rejected handshake (explicit rejection - connection closed)
// * Request failed (implicit rejection, e.g. uncaught exception - connection closed)
switch (result) {
// Happy path: handshake was accepted
case HandshakeAccepted handshakeAccepted -> {
// Single-shot latch; we'll wait until a Server-Sent Event comes through
CountDownLatch countDownLatch = new CountDownLatch(1);
// Listen for Server-Sent Events
handshakeAccepted.registerEventConsumer((serverSentEvent) -> {
// Once we see an event come through, unlatch and end the test
countDownLatch.countDown();
});
// On a separate thread, broadcast a Server-Sent Event
new Thread(() -> {
chatService.createChatMessage(CHAT_ID, "test message");
}).start();
// Wait a bit for unlatch
try {
countDownLatch.await(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
fail("Didn't receive a Server-Sent Event in time");
}
}
// Handshake was rejected (should not happen here)
case HandshakeRejected handshakeRejected -> {
fail(String.format("SSE handshake rejected: %s", handshakeRejected));
}
// Request failed for some other reason (should not happen here)
case RequestFailed requestFailed -> {
fail(String.format("SSE request failed: %s", requestFailed));
}
}
}));
The Simulator can also listen for SSE comment broadcasts:
handshakeAccepted.registerCommentConsumer((comment) -> {
System.out.printf("SSE comment: %s\n", comment);
});
handshakeAccepted.registerCommentConsumer((comment) -> {
System.out.printf("SSE comment: %s\n", comment);
});
...and access the MarshaledResponse data as well:
// You might examine + verify response headers here...
MarshaledResponse marshaledResponse =
handshakeAccepted.getRequestResult().getMarshaledResponse();
// ...e.g. make sure this was set by Soklet
Set<String> xAccelBufferingValues =
marshaledResponse.getHeaders().getOrDefault("X-Accel-Buffering", Set.of());
assertTrue(xAccelBufferingValues.contains("no"));
// You might examine + verify response headers here...
MarshaledResponse marshaledResponse =
handshakeAccepted.getRequestResult().getMarshaledResponse();
// ...e.g. make sure this was set by Soklet
Set<String> xAccelBufferingValues =
marshaledResponse.getHeaders().getOrDefault("X-Accel-Buffering", Set.of());
assertTrue(xAccelBufferingValues.contains("no"));

