Skip to content

Commit 58b4884

Browse files
authored
refactor(rt): relocate and extend test utils (#489)
1 parent 637e0f7 commit 58b4884

File tree

22 files changed

+902
-40
lines changed

22 files changed

+902
-40
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
description = "Utilities for testing HTTP requests"
6+
extra["displayName"] = "Smithy :: Kotlin :: HTTP Test"
7+
extra["moduleName"] = "aws.smithy.kotlin.runtime.httptest"
8+
9+
val kotlinVersion: String by project
10+
val ktorVersion: String by project
11+
12+
kotlin {
13+
sourceSets {
14+
commonMain {
15+
dependencies {
16+
api(project(":runtime:protocol:http"))
17+
18+
implementation(project(":runtime:logging"))
19+
implementation("org.jetbrains.kotlin:kotlin-test-common:$kotlinVersion")
20+
implementation("org.jetbrains.kotlin:kotlin-test-annotations-common:$kotlinVersion")
21+
}
22+
}
23+
commonTest {
24+
dependencies {
25+
implementation(project(":runtime:testing"))
26+
}
27+
}
28+
jvmMain {
29+
dependencies {
30+
implementation("org.jetbrains.kotlin:kotlin-test-junit5:$kotlinVersion")
31+
api("io.ktor:ktor-server-cio:$ktorVersion")
32+
}
33+
}
34+
}
35+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.smithy.kotlin.runtime.httptest
7+
8+
import aws.smithy.kotlin.runtime.http.Headers
9+
import aws.smithy.kotlin.runtime.http.HttpBody
10+
import aws.smithy.kotlin.runtime.http.HttpStatusCode
11+
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineBase
12+
import aws.smithy.kotlin.runtime.http.engine.callContext
13+
import aws.smithy.kotlin.runtime.http.readAll
14+
import aws.smithy.kotlin.runtime.http.request.HttpRequest
15+
import aws.smithy.kotlin.runtime.http.request.HttpRequestBuilder
16+
import aws.smithy.kotlin.runtime.http.response.HttpCall
17+
import aws.smithy.kotlin.runtime.http.response.HttpResponse
18+
import aws.smithy.kotlin.runtime.time.Instant
19+
import kotlin.test.assertEquals
20+
import kotlin.test.assertTrue
21+
22+
/**
23+
* An expected HttpRequest with the response that should be returned by the engine
24+
* @param expected the expected request. If null no assertions are made on the request
25+
* @param respondWith the response to return for this request. If null it defaults to an empty 200-OK response
26+
*/
27+
data class MockRoundTrip(val expected: HttpRequest?, val respondWith: HttpResponse? = null)
28+
29+
/**
30+
* Actual and expected [HttpRequest] pair
31+
*/
32+
data class CallAssertion(val expected: HttpRequest?, val actual: HttpRequest) {
33+
/**
34+
* Assert that all of the components set on [expected] are also the same on [actual]. The actual request
35+
* may have additional headers, only the ones set in [expected] are compared.
36+
*/
37+
internal suspend fun assertRequest(idx: Int) {
38+
if (expected == null) return
39+
assertEquals(expected.url.toString(), actual.url.toString(), "[request#$idx]: URL mismatch")
40+
expected.headers.forEach { name, values ->
41+
values.forEach {
42+
assertTrue(actual.headers.contains(name, it), "[request#$idx]: header `$name` missing value `$it`")
43+
}
44+
}
45+
46+
val expectedBody = expected.body.readAll()?.decodeToString()
47+
val actualBody = actual.body.readAll()?.decodeToString()
48+
assertEquals(expectedBody, actualBody, "[request#$idx]: body mismatch")
49+
}
50+
}
51+
52+
/**
53+
* TestConnection implements [aws.smithy.kotlin.runtime.http.engine.HttpClientEngine] with canned responses.
54+
* For each expected request it will capture the actual and respond with the pre-configured response (or a basic 200-OK
55+
* with an empty body if none was configured).
56+
*
57+
* After all requests/responses have been made use [assertRequests] to test that the actual requests captured match
58+
* the expected.
59+
*
60+
* NOTE: This engine is only capable of modeling request/response pairs. More complicated interactions such as duplex
61+
* streaming are not implemented.
62+
*/
63+
class TestConnection(private val expected: List<MockRoundTrip> = emptyList()) : HttpClientEngineBase("TestConnection") {
64+
// expected is mutated in-flight, store original size
65+
private val iter = expected.iterator()
66+
private var calls = mutableListOf<CallAssertion>()
67+
68+
override suspend fun roundTrip(request: HttpRequest): HttpCall {
69+
check(iter.hasNext()) { "TestConnection has no remaining expected requests" }
70+
val next = iter.next()
71+
calls.add(CallAssertion(next.expected, request))
72+
73+
val response = next.respondWith ?: HttpResponse(HttpStatusCode.OK, Headers.Empty, HttpBody.Empty)
74+
val now = Instant.now()
75+
return HttpCall(request, response, now, now, callContext())
76+
}
77+
78+
/**
79+
* Get the list of captured HTTP requests so far
80+
*/
81+
fun requests(): List<CallAssertion> = calls
82+
83+
/**
84+
* Assert that each captured request matches the expected
85+
*/
86+
suspend fun assertRequests() {
87+
assertEquals(expected.size, calls.size)
88+
calls.forEachIndexed { idx, captured ->
89+
captured.assertRequest(idx)
90+
}
91+
}
92+
}
93+
94+
/**
95+
* DSL builder for [TestConnection]
96+
*/
97+
class HttpTestConnectionBuilder {
98+
val requests = mutableListOf<MockRoundTrip>()
99+
100+
class HttpRequestResponsePairBuilder {
101+
internal val requestBuilder = HttpRequestBuilder()
102+
var response: HttpResponse? = null
103+
fun request(block: HttpRequestBuilder.() -> Unit) = requestBuilder.apply(block)
104+
}
105+
106+
fun expect(block: HttpRequestResponsePairBuilder.() -> Unit) {
107+
val builder = HttpRequestResponsePairBuilder().apply(block)
108+
requests.add(MockRoundTrip(builder.requestBuilder.build(), builder.response))
109+
}
110+
111+
fun expect(request: HttpRequest, response: HttpResponse? = null) {
112+
requests.add(MockRoundTrip(request, response))
113+
}
114+
115+
fun expect(response: HttpResponse? = null) {
116+
requests.add(MockRoundTrip(null, response))
117+
}
118+
}
119+
120+
/**
121+
* Invoke [block] with the given builder and construct a new [TestConnection]
122+
*
123+
* Example:
124+
* ```kotlin
125+
* val testEngine = buildTestConnection {
126+
* expect {
127+
* request {
128+
* url.host = "myhost"
129+
* headers.append("x-foo", "bar")
130+
* }
131+
* response = HttpResponse(HttpStatusCode.OK, Headers.Empty, HttpBody.Empty)
132+
* }
133+
* }
134+
* ```
135+
*/
136+
fun buildTestConnection(block: HttpTestConnectionBuilder.() -> Unit): TestConnection {
137+
val builder = HttpTestConnectionBuilder().apply(block)
138+
return TestConnection(builder.requests)
139+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.smithy.kotlin.runtime.httptest
7+
8+
import aws.smithy.kotlin.runtime.http.content.ByteArrayContent
9+
import aws.smithy.kotlin.runtime.http.request.HttpRequestBuilder
10+
import aws.smithy.kotlin.runtime.http.response.complete
11+
import aws.smithy.kotlin.runtime.http.sdkHttpClient
12+
import aws.smithy.kotlin.runtime.testing.runSuspendTest
13+
import io.kotest.matchers.string.shouldContain
14+
import kotlin.test.Test
15+
import kotlin.test.assertFails
16+
17+
class TestConnectionTest {
18+
@Test
19+
fun testAssertRequestsSuccess(): Unit = runSuspendTest {
20+
val engine = buildTestConnection {
21+
expect {
22+
request {
23+
url.host = "test.com"
24+
url.path = "/turtles-all-the-way-down"
25+
headers.append("x-foo", "bar")
26+
body = ByteArrayContent("tests for your tests".encodeToByteArray())
27+
}
28+
}
29+
}
30+
31+
val client = sdkHttpClient(engine)
32+
33+
val req = HttpRequestBuilder().apply {
34+
url.host = "test.com"
35+
url.path = "/turtles-all-the-way-down"
36+
headers.append("x-foo", "bar")
37+
headers.append("x-qux", "quux")
38+
body = ByteArrayContent("tests for your tests".encodeToByteArray())
39+
}
40+
client.call(req).complete()
41+
42+
engine.assertRequests()
43+
}
44+
45+
@Test
46+
fun testAssertRequestsUrlDifferent(): Unit = runSuspendTest {
47+
val engine = buildTestConnection {
48+
expect {
49+
request {
50+
url.host = "test.com"
51+
url.path = "/turtles-all-the-way-down"
52+
headers.append("x-foo", "bar")
53+
body = ByteArrayContent("tests for your tests".encodeToByteArray())
54+
}
55+
}
56+
}
57+
58+
val client = sdkHttpClient(engine)
59+
60+
val req = HttpRequestBuilder().apply {
61+
url.host = "test.com"
62+
url.path = "/tests-for-your-tests"
63+
headers.append("x-foo", "bar")
64+
}
65+
client.call(req).complete()
66+
67+
assertFails {
68+
engine.assertRequests()
69+
}.message.shouldContain("URL mismatch")
70+
}
71+
72+
@Test
73+
fun testAssertRequestsMissingHeader(): Unit = runSuspendTest {
74+
val engine = buildTestConnection {
75+
expect {
76+
request {
77+
url.host = "test.com"
78+
url.path = "/turtles-all-the-way-down"
79+
headers.append("x-foo", "bar")
80+
headers.append("x-baz", "qux")
81+
}
82+
}
83+
}
84+
85+
val client = sdkHttpClient(engine)
86+
87+
val req = HttpRequestBuilder().apply {
88+
url.host = "test.com"
89+
url.path = "/turtles-all-the-way-down"
90+
headers.append("x-foo", "bar")
91+
}
92+
client.call(req).complete()
93+
94+
assertFails {
95+
engine.assertRequests()
96+
}.message.shouldContain("header `x-baz` missing value `qux`")
97+
}
98+
99+
@Test
100+
fun testAssertRequestsBodyDifferent(): Unit = runSuspendTest {
101+
val engine = buildTestConnection {
102+
expect {
103+
request {
104+
url.host = "test.com"
105+
url.path = "/turtles-all-the-way-down"
106+
headers.append("x-foo", "bar")
107+
body = ByteArrayContent("tests for your tests".encodeToByteArray())
108+
}
109+
}
110+
}
111+
112+
val client = sdkHttpClient(engine)
113+
114+
val req = HttpRequestBuilder().apply {
115+
url.host = "test.com"
116+
url.path = "/turtles-all-the-way-down"
117+
headers.append("x-foo", "bar")
118+
body = ByteArrayContent("tests are good".encodeToByteArray())
119+
}
120+
client.call(req).complete()
121+
122+
assertFails {
123+
engine.assertRequests()
124+
}.message.shouldContain("body mismatch")
125+
}
126+
127+
@Test
128+
fun testAssertRequestsAny(): Unit = runSuspendTest {
129+
val engine = buildTestConnection {
130+
expect {
131+
request {
132+
url.host = "test.com"
133+
url.path = "/turtles-all-the-way-down"
134+
headers.append("x-foo", "bar")
135+
body = ByteArrayContent("tests for your tests".encodeToByteArray())
136+
}
137+
}
138+
// ANY request
139+
expect()
140+
}
141+
142+
val client = sdkHttpClient(engine)
143+
144+
val req = HttpRequestBuilder().apply {
145+
url.host = "test.com"
146+
url.path = "/turtles-all-the-way-down"
147+
headers.append("x-foo", "bar")
148+
headers.append("x-qux", "quux")
149+
body = ByteArrayContent("tests for your tests".encodeToByteArray())
150+
}
151+
client.call(req).complete()
152+
client.call(
153+
HttpRequestBuilder().apply {
154+
url.host = "test-anything.com"
155+
}
156+
)
157+
158+
engine.assertRequests()
159+
}
160+
}

0 commit comments

Comments
 (0)