Skip to content

Commit c6ed9f7

Browse files
authored
test: use mock server for tests using GitHub API (#1972)
Part of #1884. Removes a dependency on ktor in the tests of GithubApi module. It's needed because we're going to move away of using GitHub's API directly via ktor, and use a library instead. This change focuses only on refactoring the tests to use another mocking mechanism - instead of using ktor's API, it exposes a real mock HTTP server.
1 parent 7a7abfc commit c6ed9f7

File tree

3 files changed

+141
-150
lines changed

3 files changed

+141
-150
lines changed

shared-internal/build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,6 @@ dependencies {
3131
// Here's a ticket to remember to remove this workaround: https://github.com/typesafegithub/github-workflows-kt/issues/1832
3232
runtimeOnly("org.jetbrains.kotlinx:kotlinx-io-core:0.7.0")
3333

34-
testImplementation("io.ktor:ktor-client-mock:3.1.3")
34+
testImplementation("io.kotest.extensions:kotest-extensions-mockserver:1.3.0")
35+
testImplementation("org.slf4j:slf4j-simple:2.0.12")
3536
}

shared-internal/src/main/kotlin/io/github/typesafegithub/workflows/shared/internal/GithubApi.kt

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging.logger
77
import io.github.typesafegithub.workflows.shared.internal.model.Version
88
import io.ktor.client.HttpClient
99
import io.ktor.client.call.body
10-
import io.ktor.client.engine.HttpClientEngineFactory
11-
import io.ktor.client.engine.cio.CIO
1210
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
1311
import io.ktor.client.plugins.logging.LogLevel.ALL
1412
import io.ktor.client.plugins.logging.Logger
@@ -28,28 +26,25 @@ suspend fun fetchAvailableVersions(
2826
owner: String,
2927
name: String,
3028
githubAuthToken: String?,
31-
httpClientEngineFactory: HttpClientEngineFactory<*> = CIO,
29+
githubEndpoint: String = "https://api.github.com",
3230
): Either<String, List<Version>> =
3331
either {
34-
buildHttpClient(engineFactory = httpClientEngineFactory).use { httpClient ->
32+
buildHttpClient().use { httpClient ->
3533
return listOf(
36-
apiTagsUrl(owner = owner, name = name),
37-
apiBranchesUrl(owner = owner, name = name),
34+
apiTagsUrl(githubEndpoint = githubEndpoint, owner = owner, name = name),
35+
apiBranchesUrl(githubEndpoint = githubEndpoint, owner = owner, name = name),
3836
).flatMap { url -> fetchGithubRefs(url, githubAuthToken, httpClient).bind() }
39-
.versions(githubAuthToken, httpClientEngineFactory)
37+
.versions(githubAuthToken)
4038
}
4139
}
4240

43-
private fun List<GithubRef>.versions(
44-
githubAuthToken: String?,
45-
httpClientEngineFactory: HttpClientEngineFactory<*>,
46-
): Either<String, List<Version>> =
41+
private fun List<GithubRef>.versions(githubAuthToken: String?): Either<String, List<Version>> =
4742
either {
4843
this@versions.map { githubRef ->
4944
val version = githubRef.ref.substringAfterLast("/")
5045
Version(version) {
5146
val response =
52-
buildHttpClient(engineFactory = httpClientEngineFactory).use { httpClient ->
47+
buildHttpClient().use { httpClient ->
5348
httpClient
5449
.get(urlString = githubRef.`object`.url) {
5550
if (githubAuthToken != null) {
@@ -89,14 +84,16 @@ private suspend fun fetchGithubRefs(
8984
}
9085

9186
private fun apiTagsUrl(
87+
githubEndpoint: String,
9288
owner: String,
9389
name: String,
94-
): String = "https://api.github.com/repos/$owner/$name/git/matching-refs/tags/v"
90+
): String = "$githubEndpoint/repos/$owner/$name/git/matching-refs/tags/v"
9591

9692
private fun apiBranchesUrl(
93+
githubEndpoint: String,
9794
owner: String,
9895
name: String,
99-
): String = "https://api.github.com/repos/$owner/$name/git/matching-refs/heads/v"
96+
): String = "$githubEndpoint/repos/$owner/$name/git/matching-refs/heads/v"
10097

10198
@Serializable
10299
private data class GithubRef(
@@ -125,8 +122,8 @@ private data class Person(
125122
val date: String,
126123
)
127124

128-
private fun buildHttpClient(engineFactory: HttpClientEngineFactory<*>) =
129-
HttpClient(engineFactory) {
125+
private fun buildHttpClient() =
126+
HttpClient {
130127
val klogger = logger
131128
install(Logging) {
132129
logger =

shared-internal/src/test/kotlin/io/github/typesafegithub/workflows/shared/internal/model/GithubApiTest.kt

Lines changed: 126 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -3,148 +3,141 @@ package io.github.typesafegithub.workflows.shared.internal.model
33
import arrow.core.left
44
import arrow.core.right
55
import io.github.typesafegithub.workflows.shared.internal.fetchAvailableVersions
6+
import io.kotest.core.extensions.install
67
import io.kotest.core.spec.style.FunSpec
8+
import io.kotest.extensions.mockserver.MockServerExtension
79
import io.kotest.matchers.shouldBe
8-
import io.ktor.client.engine.HttpClientEngineFactory
9-
import io.ktor.client.engine.mock.MockEngine
10-
import io.ktor.client.engine.mock.MockEngineConfig
11-
import io.ktor.client.engine.mock.respond
12-
import io.ktor.http.HttpHeaders
13-
import io.ktor.http.HttpStatusCode
14-
import io.ktor.http.fullPath
15-
import io.ktor.http.headersOf
16-
import io.ktor.utils.io.ByteReadChannel
10+
import org.mockserver.model.HttpRequest.request
11+
import org.mockserver.model.HttpResponse.response
1712

1813
class GithubApiTest :
19-
FunSpec({
20-
test("branches with major versions and tags with other versions") {
21-
// Given
22-
val tagsResponse =
23-
"""
24-
[
25-
{
26-
"ref":"refs/tags/v1.0.0",
27-
"node_id":"MDM6UmVmMTk3ODE0NjI5OnJlZnMvdGFncy92MQ==",
28-
"url":"https://api.github.com/repos/some-owner/some-name/git/refs/tags/v1",
29-
"object": {
30-
"sha":"544eadc6bf3d226fd7a7a9f0dc5b5bf7ca0675b9",
31-
"type":"tag",
32-
"url":"https://api.github.com/repos/actions/some-name/git/tags/544eadc6bf3d226fd7a7a9f0dc5b5bf7ca0675b9"
33-
}
34-
},
35-
{
36-
"ref":"refs/tags/v1.0.1",
37-
"node_id":"MDM6UmVmMTk3ODE0NjI5OnJlZnMvdGFncy92MQ==",
38-
"url":"https://api.github.com/repos/some-owner/some-name/git/refs/tags/v1.0.1",
39-
"object": {
40-
"sha":"af513c7a016048ae468971c52ed77d9562c7c819",
41-
"type":"tag",
42-
"url":"https://api.github.com/repos/actions/some-name/git/tags/af513c7a016048ae468971c52ed77d9562c7c819"
43-
}
44-
}
45-
]
46-
""".trimIndent()
47-
val headsResponse =
48-
"""
49-
[
50-
{
51-
"ref":"refs/heads/v1",
52-
"node_id":"MDM6UmVmMTk3ODE0NjI5OnJlZnMvaGVhZHMvdm1qb3NlcGgvc2lsZW50LXJldi1wYXJzZQ==",
53-
"url":"https://api.github.com/repos/some-owner/some-name/git/refs/heads/v1",
54-
"object": {
55-
"sha":"af5130cb8882054eda385840657dcbd1e19ab8f4",
56-
"type":"commit",
57-
"url":"https://api.github.com/repos/some-owner/some-name/git/commits/af5130cb8882054eda385840657dcbd1e19ab8f4"
58-
}
59-
},
60-
{
61-
"ref":"refs/heads/v2",
62-
"node_id":"MDM6UmVmMTk3ODE0NjI5OnJlZnMvaGVhZHMvdm1qb3NlcGgvdG9vbGtpdC13aW5kb3dzLWV4ZWM=",
63-
"url":"https://api.github.com/repos/some-owner/some-name/git/refs/heads/v2",
64-
"object": {
65-
"sha":"c22ccee38a13e34cb01a103c324adb1db665821e",
66-
"type":"commit",
67-
"url":"https://api.github.com/repos/some-owner/some-name/git/commits/c22ccee38a13e34cb01a103c324adb1db665821e"
14+
FunSpec(
15+
{
16+
val mockServer = install(MockServerExtension())
17+
18+
beforeTest {
19+
mockServer.reset()
20+
}
21+
22+
val owner = "some-owner"
23+
val name = "some-name"
24+
25+
test("branches with major versions and tags with other versions") {
26+
// Given
27+
val tagsResponse =
28+
"""
29+
[
30+
{
31+
"ref":"refs/tags/v1.0.0",
32+
"node_id":"MDM6UmVmMTk3ODE0NjI5OnJlZnMvdGFncy92MQ==",
33+
"url":"https://api.github.com/repos/some-owner/some-name/git/refs/tags/v1",
34+
"object": {
35+
"sha":"544eadc6bf3d226fd7a7a9f0dc5b5bf7ca0675b9",
36+
"type":"tag",
37+
"url":"https://api.github.com/repos/actions/some-name/git/tags/544eadc6bf3d226fd7a7a9f0dc5b5bf7ca0675b9"
38+
}
39+
},
40+
{
41+
"ref":"refs/tags/v1.0.1",
42+
"node_id":"MDM6UmVmMTk3ODE0NjI5OnJlZnMvdGFncy92MQ==",
43+
"url":"https://api.github.com/repos/some-owner/some-name/git/refs/tags/v1.0.1",
44+
"object": {
45+
"sha":"af513c7a016048ae468971c52ed77d9562c7c819",
46+
"type":"tag",
47+
"url":"https://api.github.com/repos/actions/some-name/git/tags/af513c7a016048ae468971c52ed77d9562c7c819"
48+
}
6849
}
69-
}
70-
]
71-
""".trimIndent()
72-
val mockEngineFactory =
73-
object : HttpClientEngineFactory<MockEngineConfig> {
74-
override fun create(block: MockEngineConfig.() -> Unit) =
75-
MockEngine { request ->
76-
if ("matching-refs/tags" in request.url.fullPath) {
77-
respond(
78-
// language=json
79-
content = ByteReadChannel(tagsResponse),
80-
status = HttpStatusCode.OK,
81-
headers = headersOf(HttpHeaders.ContentType, "application/json"),
82-
)
83-
} else if ("matching-refs/heads" in request.url.fullPath) {
84-
respond(
85-
// language=json
86-
content = ByteReadChannel(headsResponse),
87-
status = HttpStatusCode.OK,
88-
headers = headersOf(HttpHeaders.ContentType, "application/json"),
89-
)
90-
} else {
91-
respond(
92-
content = ByteReadChannel("The mock client wasn't prepared for this request"),
93-
status = HttpStatusCode.NotFound,
94-
)
50+
]
51+
""".trimIndent()
52+
val headsResponse =
53+
"""
54+
[
55+
{
56+
"ref":"refs/heads/v1",
57+
"node_id":"MDM6UmVmMTk3ODE0NjI5OnJlZnMvaGVhZHMvdm1qb3NlcGgvc2lsZW50LXJldi1wYXJzZQ==",
58+
"url":"https://api.github.com/repos/some-owner/some-name/git/refs/heads/v1",
59+
"object": {
60+
"sha":"af5130cb8882054eda385840657dcbd1e19ab8f4",
61+
"type":"commit",
62+
"url":"https://api.github.com/repos/some-owner/some-name/git/commits/af5130cb8882054eda385840657dcbd1e19ab8f4"
63+
}
64+
},
65+
{
66+
"ref":"refs/heads/v2",
67+
"node_id":"MDM6UmVmMTk3ODE0NjI5OnJlZnMvaGVhZHMvdm1qb3NlcGgvdG9vbGtpdC13aW5kb3dzLWV4ZWM=",
68+
"url":"https://api.github.com/repos/some-owner/some-name/git/refs/heads/v2",
69+
"object": {
70+
"sha":"c22ccee38a13e34cb01a103c324adb1db665821e",
71+
"type":"commit",
72+
"url":"https://api.github.com/repos/some-owner/some-name/git/commits/c22ccee38a13e34cb01a103c324adb1db665821e"
9573
}
9674
}
97-
}
75+
]
76+
""".trimIndent()
77+
mockServer
78+
.`when`(request().withPath("/repos/$owner/$name/git/matching-refs/tags/v"))
79+
.respond(
80+
response()
81+
.withStatusCode(200)
82+
.withHeader("Content-Type", "application/json")
83+
.withBody(tagsResponse),
84+
)
85+
mockServer
86+
.`when`(request().withPath("/repos/$owner/$name/git/matching-refs/heads/v"))
87+
.respond(
88+
response()
89+
.withStatusCode(200)
90+
.withHeader("Content-Type", "application/json")
91+
.withBody(headsResponse),
92+
)
9893

99-
// When
100-
val versionsOrError =
101-
fetchAvailableVersions(
102-
owner = "some-owner",
103-
name = "some-name",
104-
githubAuthToken = "token",
105-
httpClientEngineFactory = mockEngineFactory,
106-
)
94+
// When
95+
val versionsOrError =
96+
fetchAvailableVersions(
97+
owner = owner,
98+
name = name,
99+
githubAuthToken = "token",
100+
githubEndpoint = "http://localhost:${mockServer.port}",
101+
)
107102

108-
// Then
109-
versionsOrError shouldBe
110-
listOf(
111-
Version("v1.0.0"),
112-
Version("v1.0.1"),
113-
Version("v1"),
114-
Version("v2"),
115-
).right()
116-
}
103+
// Then
104+
versionsOrError shouldBe
105+
listOf(
106+
Version("v1.0.0"),
107+
Version("v1.0.1"),
108+
Version("v1"),
109+
Version("v2"),
110+
).right()
111+
}
117112

118-
test("error occurs when fetching branches and tags") {
119-
// Given
120-
val mockEngineFactory =
121-
object : HttpClientEngineFactory<MockEngineConfig> {
122-
override fun create(block: MockEngineConfig.() -> Unit) =
123-
MockEngine { request ->
124-
respond(
125-
// language=json
126-
content = ByteReadChannel("""{"message": "There was a problem!"}"""),
127-
status = HttpStatusCode.Forbidden,
128-
headers = headersOf(HttpHeaders.ContentType, "application/json"),
129-
)
130-
}
131-
}
113+
test("error occurs when fetching branches and tags") {
114+
// Given
115+
mockServer
116+
.`when`(request())
117+
.respond(
118+
response()
119+
.withStatusCode(403)
120+
.withHeader("Content-Type", "application/json")
121+
.withBody("""{"message": "There was a problem!"}"""),
122+
)
132123

133-
// When
134-
val versionsOrError =
135-
fetchAvailableVersions(
136-
owner = "some-owner",
137-
name = "some-name",
138-
githubAuthToken = "token",
139-
httpClientEngineFactory = mockEngineFactory,
140-
)
124+
// When
125+
val versionOrError =
126+
fetchAvailableVersions(
127+
owner = owner,
128+
name = name,
129+
githubAuthToken = "token",
130+
githubEndpoint = "http://localhost:${mockServer.port}",
131+
)
141132

142-
// Then
143-
versionsOrError shouldBe
144-
(
145-
"Unexpected response when fetching refs from " +
146-
"https://api.github.com/repos/some-owner/some-name/git/matching-refs/tags/v. " +
147-
"Status: 403 Forbidden, response: {\"message\": \"There was a problem!\"}"
148-
).left()
149-
}
150-
})
133+
// Then
134+
versionOrError shouldBe
135+
(
136+
"Unexpected response when fetching refs from " +
137+
"http://localhost:${mockServer.port}/" +
138+
"repos/some-owner/some-name/git/matching-refs/tags/v. " +
139+
"Status: 403 Forbidden, response: {\"message\": \"There was a problem!\"}"
140+
).left()
141+
}
142+
},
143+
)

0 commit comments

Comments
 (0)