HttpClient¶
Configure¶
After getting the library from the maven source, while configuring TestSystem you will have access to http
TestSystem()
.with {
http {
HttpClientSystemOptions(
baseUrl = "http://localhost:8080",
)
}
}
.run()
The other options that you can set are:
data class HttpClientSystemOptions(
/**
* Base URL of the HTTP client.
*/
val baseUrl: String,
/**
* Content converter for the HTTP client. Default is JacksonConverter. You can use GsonConverter or any other converter.
* If you want to use your own converter, you can implement ContentConverter interface.
*/
val contentConverter: ContentConverter = JacksonConverter(StoveSerde.jackson.default),
/**
* Timeout for the HTTP client. Default is 30 seconds.
*/
val timeout: Duration = 30.seconds,
/**
* Create client function for the HTTP client. Default is jsonHttpClient.
*/
val createClient: () -> io.ktor.client.HttpClient = { jsonHttpClient(timeout, contentConverter) }
)
Usage¶
GET Requests¶
Making GET requests with various options:
TestSystem.validate {
http {
// Simple GET request with type-safe response
get<UserResponse>("/users/123") { user ->
user.id shouldBe 123
user.name shouldBe "John Doe"
}
// GET with query parameters
get<String>("/api/index", queryParams = mapOf("keyword" to "search-term")) { response ->
response shouldContain "search-term"
}
// GET with headers
get<UserProfile>("/profile", headers = mapOf("X-Custom-Header" to "value")) { profile ->
profile.email shouldNotBe null
}
// GET with authentication token
get<SecureData>("/secure-endpoint", token = "jwt-token".some()) { data ->
data.isAuthorized shouldBe true
}
// GET multiple items (list response)
getMany<ProductResponse>("/products", queryParams = mapOf("page" to "1", "size" to "10")) { products ->
products.size shouldBe 10
products.first().name shouldNotBe null
}
}
}
GET with Full Response Access¶
When you need access to status code and headers:
TestSystem.validate {
http {
getResponse<UserResponse>("/users/123") { response ->
response.status shouldBe 200
response.headers["Content-Type"] shouldContain "application/json"
response.body().id shouldBe 123
}
// Bodiless response (only status and headers)
getResponse("/health") { response ->
response.status shouldBe 200
}
}
}
POST Requests¶
Various POST request patterns:
TestSystem.validate {
http {
// POST with request body and expect JSON response
postAndExpectJson<UserResponse>("/users") {
CreateUserRequest(name = "John", email = "john@example.com")
} { user ->
user.id shouldNotBe null
user.name shouldBe "John"
}
// POST and expect bodiless response (only status)
postAndExpectBodilessResponse(
uri = "/products/activate",
body = ActivateRequest(productId = 123).some()
) { response ->
response.status shouldBe 200
}
// POST with full response access
postAndExpectBody<ProductResponse>(
uri = "/products",
body = CreateProductRequest(name = "Laptop", price = 999.99).some()
) { response ->
response.status shouldBe 201
response.headers["Location"] shouldNotBe null
response.body().id shouldNotBe null
}
// POST with headers and token
postAndExpectJson<OrderResponse>(
uri = "/orders",
body = CreateOrderRequest(items = listOf("item1", "item2")).some(),
headers = mapOf("X-Request-ID" to "12345"),
token = "jwt-token".some()
) { order ->
order.id shouldNotBe null
order.status shouldBe "CREATED"
}
}
}
PUT Requests¶
Update operations with PUT:
TestSystem.validate {
http {
// PUT with response body
putAndExpectJson<UserResponse>("/users/123") {
UpdateUserRequest(name = "Jane Doe", email = "jane@example.com")
} { user ->
user.name shouldBe "Jane Doe"
user.email shouldBe "jane@example.com"
}
// PUT without response body
putAndExpectBodilessResponse(
uri = "/products/123",
body = UpdateProductRequest(name = "Updated Product").some()
) { response ->
response.status shouldBe 200
}
// PUT with full response access
putAndExpectBody<ProductResponse>(
uri = "/products/456",
body = UpdateProductRequest(price = 899.99).some()
) { response ->
response.status shouldBe 200
response.body().price shouldBe 899.99
}
}
}
PATCH Requests¶
Partial updates with PATCH:
TestSystem.validate {
http {
// PATCH with response body
patchAndExpectBody<UserResponse>(
uri = "/users/123",
body = mapOf("email" to "newemail@example.com").some()
) { response ->
response.status shouldBe 200
response.body().email shouldBe "newemail@example.com"
}
}
}
DELETE Requests¶
Delete operations:
TestSystem.validate {
http {
// DELETE without response body
deleteAndExpectBodilessResponse("/users/123") { response ->
response.status shouldBe 204
}
// DELETE with authentication
deleteAndExpectBodilessResponse(
uri = "/products/456",
token = "jwt-token".some()
) { response ->
response.status shouldBe 200
}
}
}
File Upload with Multipart¶
Upload files using multipart form data:
TestSystem.validate {
http {
postMultipartAndExpectResponse<UploadResponse>(
uri = "/products/import",
body = listOf(
StoveMultiPartContent.Text("productName", "Laptop"),
StoveMultiPartContent.Text("description", "A powerful laptop"),
StoveMultiPartContent.File(
param = "file",
fileName = "products.csv",
content = csvBytes,
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE
)
)
) { response ->
response.status shouldBe 200
response.body().uploadedFiles.size shouldBe 1
response.body().message shouldContain "products.csv"
}
}
}
Advanced: Using Ktor Client Directly¶
For advanced scenarios, access the underlying Ktor HttpClient:
TestSystem.validate {
http {
client { baseUrl ->
// Direct access to Ktor HttpClient
val response = get {
url(baseUrl.buildString() + "/custom-endpoint")
header("Custom-Header", "value")
}
println(response.status)
}
}
}
Complete Example¶
Here's a complete CRUD test example:
test("should perform CRUD operations on products") {
TestSystem.validate {
var productId: Long? = null
// CREATE
http {
postAndExpectBody<ProductResponse>(
uri = "/products",
body = CreateProductRequest(name = "Laptop", price = 999.99, categoryId = 1).some()
) { response ->
response.status shouldBe 201
productId = response.body().id
response.body().name shouldBe "Laptop"
}
}
// READ
http {
get<ProductResponse>("/products/$productId") { product ->
product.id shouldBe productId
product.name shouldBe "Laptop"
product.price shouldBe 999.99
}
}
// UPDATE
http {
putAndExpectJson<ProductResponse>("/products/$productId") {
UpdateProductRequest(price = 899.99)
} { product ->
product.price shouldBe 899.99
}
}
// DELETE
http {
deleteAndExpectBodilessResponse("/products/$productId") { response ->
response.status shouldBe 204
}
}
// Verify deletion
http {
getResponse<ErrorResponse>("/products/$productId") { response ->
response.status shouldBe 404
}
}
}
}
Integration with Other Components¶
HTTP + Database¶
TestSystem.validate {
// Create via API and capture user ID
var userId: Long = 0
http {
postAndExpectBody<UserResponse>("/users", body = CreateUserRequest(name = "John").some()) { response ->
userId = response.body().id
}
}
// Verify in database
postgresql {
shouldQuery(
query = "SELECT * FROM users WHERE id = $userId",
mapper = { row -> User(row.long("id"), row.string("name")) }
) { users ->
users.size shouldBe 1
users.first().name shouldBe "John"
}
}
}
HTTP + Kafka¶
TestSystem.validate {
// Trigger event via API
http {
postAndExpectBodilessResponse("/orders", body = CreateOrderRequest(amount = 100.0).some()) { response ->
response.status shouldBe 201
}
}
// Verify event was published
kafka {
shouldBePublished<OrderCreatedEvent>(atLeastIn = 10.seconds) {
actual.amount == 100.0
}
}
}
HTTP + WireMock¶
TestSystem.validate {
// Mock external service
wiremock {
mockGet(
url = "/external-api/data",
statusCode = 200,
responseBody = ExternalData(id = 1, value = "test").some()
)
}
// Call your API that depends on external service
http {
get<ResponseData>("/data") { response ->
response.value shouldBe "test"
}
}
}
Error Handling¶
TestSystem.validate {
http {
// Test validation errors
postAndExpectBody<ValidationErrorResponse>("/users", body = InvalidUserRequest().some()) { response ->
response.status shouldBe 400
response.body().errors shouldContain "name is required"
}
// Test authentication errors
getResponse<ErrorResponse>("/secure-endpoint") { response ->
response.status shouldBe 401
}
// Test not found
getResponse<ErrorResponse>("/users/999999") { response ->
response.status shouldBe 404
}
// Test business logic errors
postAndExpectBody<ErrorResponse>("/products", body = InvalidProductRequest().some()) { response ->
response.status shouldBe 409 // Conflict
response.body().message shouldContain "already exists"
}
}
}
WebSocket Support¶
Stove provides built-in support for testing WebSocket endpoints. The WebSocket functionality is integrated into the HTTP system and uses Ktor's WebSocket client under the hood.
Basic WebSocket Usage¶
Send and receive messages through a WebSocket connection:
TestSystem.validate {
http {
webSocket("/chat") {
// Send a text message
send("Hello, WebSocket!")
// Receive a text message
val response = receiveText()
response shouldBe "Echo: Hello, WebSocket!"
}
}
}
Sending Messages¶
Multiple ways to send messages:
TestSystem.validate {
http {
webSocket("/endpoint") {
// Send text message
send("Hello")
// Send binary data
send(byteArrayOf(1, 2, 3, 4, 5))
// Send using sealed class
send(StoveWebSocketMessage.Text("Hello via sealed class"))
send(StoveWebSocketMessage.Binary(byteArrayOf(1, 2, 3)))
}
}
}
Receiving Messages¶
Various methods to receive messages:
TestSystem.validate {
http {
webSocket("/endpoint") {
// Receive text
val text = receiveText()
text shouldBe "expected message"
// Receive binary
val bytes = receiveBinary()
bytes shouldBe byteArrayOf(1, 2, 3)
// Receive as sealed class (auto-detect type)
val message = receive()
when (message) {
is StoveWebSocketMessage.Text -> println(message.content)
is StoveWebSocketMessage.Binary -> println(message.content.size)
null -> println("Connection closed")
}
// Receive with timeout
val response = receiveTextWithTimeout(5.seconds)
response.isSome() shouldBe true
response.getOrNull() shouldBe "expected"
}
}
}
Collecting Multiple Messages¶
Collect a batch of messages:
TestSystem.validate {
http {
webSocket("/broadcast") {
// Collect 5 text messages with a 10 second timeout
val messages = collectTexts(count = 5, timeout = 10.seconds)
messages.size shouldBe 5
messages[0] shouldBe "Message 1"
messages[4] shouldBe "Message 5"
// Collect binary messages
val binaryMessages = collectBinaries(count = 3, timeout = 5.seconds)
binaryMessages.size shouldBe 3
}
}
}
Streaming with Flow¶
Use Kotlin Flow for streaming scenarios:
TestSystem.validate {
http {
webSocket("/events") {
// Stream text messages
val messages = incomingTexts()
.take(10)
.toList()
messages.size shouldBe 10
// Stream binary messages
incomingBinaries()
.take(5)
.collect { bytes ->
println("Received ${bytes.size} bytes")
}
// Stream all message types
incoming()
.take(5)
.collect { message ->
when (message) {
is StoveWebSocketMessage.Text -> println(message.content)
is StoveWebSocketMessage.Binary -> println(message.content.size)
}
}
}
}
}
Authentication and Headers¶
Connect with authentication or custom headers:
TestSystem.validate {
http {
// With bearer token
webSocket(
uri = "/secure-chat",
token = "jwt-token".some()
) {
val response = receiveText()
response shouldBe "Authenticated successfully"
}
// With custom headers
webSocket(
uri = "/chat",
headers = mapOf(
"X-Custom-Header" to "value",
"Authorization" to "Bearer custom-token"
)
) {
send("Hello with custom headers")
receiveText() shouldNotBe null
}
}
}
WebSocket Expect (Assertion Alias)¶
Use webSocketExpect for assertion-focused tests:
TestSystem.validate {
http {
webSocketExpect("/notifications") {
val messages = collectTexts(count = 3)
messages.size shouldBe 3
messages.all { it.startsWith("notification:") } shouldBe true
}
}
}
Raw WebSocket Access¶
For advanced scenarios, access the underlying Ktor WebSocket session:
TestSystem.validate {
http {
webSocketRaw("/advanced") {
// Direct access to Ktor's DefaultClientWebSocketSession
send(Frame.Text("raw frame"))
for (frame in incoming) {
when (frame) {
is Frame.Text -> println(frame.readText())
is Frame.Binary -> println(frame.readBytes().size)
is Frame.Close -> break
else -> {}
}
}
}
}
}
Underlying Session Access¶
Access the underlying session from within StoveWebSocketSession:
TestSystem.validate {
http {
webSocket("/endpoint") {
// Use simplified API first
send("Hello")
// Then access underlying session for advanced operations
underlyingSession {
send(Frame.Text("Advanced operation"))
val frame = incoming.receive()
(frame as Frame.Text).readText() shouldBe "Response"
}
}
}
}
Closing Connections¶
Gracefully close WebSocket connections:
TestSystem.validate {
http {
webSocket("/chat") {
send("Hello")
receiveText()
// Close with custom reason
close("Test completed")
}
}
}
Complete WebSocket Test Example¶
A comprehensive example testing a chat application:
test("should handle chat room operations") {
TestSystem.validate {
http {
// Test echo functionality
webSocket("/chat/echo") {
send("Hello, World!")
receiveText() shouldBe "Echo: Hello, World!"
send("Another message")
receiveText() shouldBe "Echo: Another message"
}
// Test broadcast with authentication
webSocket(
uri = "/chat/room/123",
token = "user-jwt-token".some()
) {
// Verify join notification
val joinMessage = receiveText()
joinMessage shouldContain "joined"
// Send a message
send("Hi everyone!")
// Collect broadcast responses
val messages = collectTexts(count = 2, timeout = 5.seconds)
messages.any { it.contains("Hi everyone!") } shouldBe true
}
// Test binary data (e.g., file sharing)
webSocket("/chat/files") {
val fileData = "Hello".toByteArray()
send(fileData)
val response = receiveBinary()
response shouldNotBe null
}
}
}
}
WebSocket + Kafka Integration¶
Test WebSocket events that trigger Kafka messages: