Skip to content

Commit c444220

Browse files
committed
[ECO-5338] Created tests package for liveobjects
1. Added test specific dependencies, updated junit, added mockk 2. Added test utils for mocking private classes, fields etc 3. Added IntegrationTest parameterized class along with sample test 4. Added sample unit test along with mocked realtime channel
1 parent e00af51 commit c444220

10 files changed

Lines changed: 404 additions & 5 deletions

File tree

gradle/libs.versions.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[versions]
22
agp = "8.5.2"
3-
junit = "4.12"
3+
junit = "4.13.2"
44
gson = "2.9.0"
55
msgpack = "0.8.11"
66
java-websocket = "1.5.3"
@@ -21,7 +21,9 @@ okhttp = "4.12.0"
2121
test-retry = "1.6.0"
2222
kotlin = "2.1.10"
2323
coroutine = "1.9.0"
24+
mockk = "1.14.2"
2425
turbine = "1.2.0"
26+
ktor = "3.1.0"
2527
jetbrains-annoations = "26.0.2"
2628

2729
[libraries]
@@ -47,12 +49,16 @@ android-retrostreams = { group = "net.sourceforge.streamsupport", name = "androi
4749
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
4850
coroutine-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" }
4951
coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine" }
52+
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
5053
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
54+
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
55+
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
5156
jetbrains = { group = "org.jetbrains", name = "annotations", version.ref = "jetbrains-annoations" }
5257

5358
[bundles]
5459
common = ["msgpack", "vcdiff-core"]
5560
tests = ["junit", "hamcrest-all", "nanohttpd", "nanohttpd-nanolets", "nanohttpd-websocket", "mockito-core", "concurrentunit", "slf4j-simple"]
61+
kotlin-tests = ["junit", "mockk", "coroutine-test", "nanohttpd", "turbine", "ktor-client-cio", "ktor-client-core"]
5662
instrumental-android = ["android-test-runner", "android-test-rules", "dexmaker", "dexmaker-dx", "dexmaker-mockito", "android-retrostreams"]
5763

5864
[plugins]

live-objects/build.gradle.kts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
2+
13
plugins {
24
`java-library`
35
alias(libs.plugins.kotlin.jvm)
@@ -9,14 +11,37 @@ repositories {
911

1012
dependencies {
1113
implementation(project(":java"))
12-
testImplementation(kotlin("test"))
1314
implementation(libs.coroutine.core)
1415

15-
testImplementation(libs.coroutine.test)
16+
testImplementation(kotlin("test"))
17+
testImplementation(libs.bundles.kotlin.tests)
18+
}
19+
20+
tasks.withType<Test>().configureEach {
21+
testLogging {
22+
exceptionFormat = TestExceptionFormat.FULL
23+
}
24+
jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED")
25+
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
26+
beforeTest(closureOf<TestDescriptor> { logger.lifecycle("-> $this") })
27+
outputs.upToDateWhen { false }
28+
// Skip tests for the "release" build type so we don't run tests twice
29+
if (name.lowercase().contains("release")) {
30+
enabled = false
31+
}
32+
}
33+
34+
tasks.register<Test>("runLiveObjectUnitTests") {
35+
filter {
36+
includeTestsMatching("io.ably.lib.objects.unit.*")
37+
}
1638
}
1739

18-
tasks.test {
19-
useJUnitPlatform()
40+
tasks.register<Test>("runLiveObjectIntegrationTests") {
41+
filter {
42+
includeTestsMatching("io.ably.lib.objects.integration.*")
43+
exclude("**/IntegrationTest.class") // Exclude the base integration test class
44+
}
2045
}
2146

2247
kotlin {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package io.ably.lib.objects
2+
3+
internal enum class ErrorCode(public val code: Int) {
4+
BadRequest(40_000),
5+
InternalError(50_000),
6+
}
7+
8+
internal enum class HttpStatusCode(public val code: Int) {
9+
BadRequest(400),
10+
InternalServerError(500),
11+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package io.ably.lib.objects
2+
3+
import io.ably.lib.types.AblyException
4+
import io.ably.lib.types.ErrorInfo
5+
6+
internal fun ablyException(
7+
errorMessage: String,
8+
errorCode: ErrorCode,
9+
statusCode: HttpStatusCode = HttpStatusCode.BadRequest,
10+
cause: Throwable? = null,
11+
): AblyException {
12+
val errorInfo = createErrorInfo(errorMessage, errorCode, statusCode)
13+
return createAblyException(errorInfo, cause)
14+
}
15+
16+
internal fun ablyException(
17+
errorInfo: ErrorInfo,
18+
cause: Throwable? = null,
19+
): AblyException = createAblyException(errorInfo, cause)
20+
21+
private fun createErrorInfo(
22+
errorMessage: String,
23+
errorCode: ErrorCode,
24+
statusCode: HttpStatusCode,
25+
) = ErrorInfo(errorMessage, statusCode.code, errorCode.code)
26+
27+
private fun createAblyException(
28+
errorInfo: ErrorInfo,
29+
cause: Throwable?,
30+
) = cause?.let { AblyException.fromErrorInfo(it, errorInfo) }
31+
?: AblyException.fromErrorInfo(errorInfo)
32+
33+
internal fun clientError(errorMessage: String) = ablyException(errorMessage, ErrorCode.BadRequest, HttpStatusCode.BadRequest)
34+
35+
internal fun serverError(errorMessage: String) = ablyException(errorMessage, ErrorCode.InternalError, HttpStatusCode.InternalServerError)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package io.ably.lib.objects
2+
3+
import java.lang.reflect.Field
4+
import kotlinx.coroutines.Dispatchers
5+
import kotlinx.coroutines.delay
6+
import kotlinx.coroutines.suspendCancellableCoroutine
7+
import kotlinx.coroutines.withContext
8+
import kotlinx.coroutines.withTimeout
9+
10+
suspend fun assertWaiter(timeoutInMs: Long = 10_000, block: suspend () -> Boolean) {
11+
withContext(Dispatchers.Default) {
12+
withTimeout(timeoutInMs) {
13+
do {
14+
val success = block()
15+
delay(100)
16+
} while (!success)
17+
}
18+
}
19+
}
20+
21+
fun Any.setPrivateField(name: String, value: Any?) {
22+
val valueField = javaClass.findField(name)
23+
valueField.isAccessible = true
24+
valueField.set(this, value)
25+
}
26+
27+
fun <T>Any.getPrivateField(name: String): T {
28+
val valueField = javaClass.findField(name)
29+
valueField.isAccessible = true
30+
@Suppress("UNCHECKED_CAST")
31+
return valueField.get(this) as T
32+
}
33+
34+
private fun Class<*>.findField(name: String): Field {
35+
var result = kotlin.runCatching { getDeclaredField(name) }
36+
var currentClass = this
37+
while (result.isFailure && currentClass.superclass != null) // stop when we got field or reached top of class hierarchy
38+
{
39+
currentClass = currentClass.superclass!!
40+
result = kotlin.runCatching { currentClass.getDeclaredField(name) }
41+
}
42+
if (result.isFailure) {
43+
throw result.exceptionOrNull() as Exception
44+
}
45+
return result.getOrNull() as Field
46+
}
47+
48+
suspend fun <T> Any.invokePrivateSuspendMethod(methodName: String, vararg args: Any?): T = suspendCancellableCoroutine { cont ->
49+
val suspendMethod = javaClass.declaredMethods.find { it.name == methodName }
50+
?: error("Method '$methodName' not found")
51+
suspendMethod.isAccessible = true
52+
suspendMethod.invoke(this, *args, cont)
53+
}
54+
55+
fun <T> Any.invokePrivateMethod(methodName: String, vararg args: Any?): T {
56+
val method = javaClass.declaredMethods.find { it.name == methodName }
57+
method?.isAccessible = true
58+
@Suppress("UNCHECKED_CAST")
59+
return method?.invoke(this, *args) as T
60+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package io.ably.lib.objects.integration
2+
3+
import io.ably.lib.objects.integration.setup.IntegrationTest
4+
import kotlinx.coroutines.test.runTest
5+
import org.junit.Test
6+
import kotlin.test.assertNotNull
7+
8+
class LiveObjectTest : IntegrationTest() {
9+
10+
@Test
11+
fun testChannelObjectGetterTest() = runTest {
12+
val channelName = generateChannelName()
13+
val channel = getRealtimeChannel(channelName)
14+
val objects = channel.objects
15+
assertNotNull(objects)
16+
}
17+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package io.ably.lib.objects.integration.setup
2+
3+
import io.ably.lib.realtime.AblyRealtime
4+
import io.ably.lib.realtime.Channel
5+
import kotlinx.coroutines.runBlocking
6+
import org.junit.After
7+
import org.junit.AfterClass
8+
import org.junit.BeforeClass
9+
import org.junit.Rule
10+
import org.junit.rules.Timeout
11+
import org.junit.runner.RunWith
12+
import org.junit.runners.Parameterized
13+
import java.util.UUID
14+
15+
@RunWith(Parameterized::class)
16+
abstract class IntegrationTest {
17+
@Parameterized.Parameter
18+
lateinit var testParams: String
19+
20+
@JvmField
21+
@Rule
22+
val timeout: Timeout = Timeout.seconds(10)
23+
24+
private val realtimeClients = mutableMapOf<String, AblyRealtime>()
25+
26+
/**
27+
* Retrieves a realtime channel for the specified channel name and client ID
28+
* If a client with the given clientID does not exist, a new client is created using the provided options.
29+
* The channel is attached and ensured to be in the attached state before returning.
30+
*
31+
* @param channelName Name of the channel
32+
* @param clientId The ID of the client to use or create. Defaults to "client1".
33+
* @return The attached realtime channel.
34+
* @throws Exception If the channel fails to attach or the client fails to connect.
35+
*/
36+
internal suspend fun getRealtimeChannel(channelName: String, clientId: String = "client1"): Channel {
37+
val client = realtimeClients.getOrPut(clientId) {
38+
sandbox.createRealtimeClient {
39+
this.clientId = clientId
40+
useBinaryProtocol = testParams == "msgpack_protocol"
41+
}. apply { ensureConnected() }
42+
}
43+
return client.channels.get(channelName).apply {
44+
attach()
45+
ensureAttached()
46+
}
47+
}
48+
49+
/**
50+
* Generates a unique channel name for testing purposes.
51+
* This is mainly to avoid channel name/state/history collisions across tests in same file.
52+
*/
53+
internal fun generateChannelName(): String {
54+
return "test-channel-${UUID.randomUUID()}"
55+
}
56+
57+
@After
58+
fun afterEach() {
59+
for (ablyRealtime in realtimeClients.values) {
60+
for ((channelName, channel) in ablyRealtime.channels.entrySet()) {
61+
channel.off()
62+
ablyRealtime.channels.release(channelName)
63+
}
64+
ablyRealtime.close()
65+
}
66+
realtimeClients.clear()
67+
}
68+
69+
companion object {
70+
private lateinit var sandbox: Sandbox
71+
72+
@JvmStatic
73+
@Parameterized.Parameters(name = "{0}")
74+
fun data(): Iterable<String> {
75+
return listOf("msgpack_protocol", "json_protocol")
76+
}
77+
78+
@JvmStatic
79+
@BeforeClass
80+
@Throws(Exception::class)
81+
fun setUpBeforeClass() {
82+
runBlocking {
83+
sandbox = Sandbox.createInstance()
84+
}
85+
}
86+
87+
@JvmStatic
88+
@AfterClass
89+
@Throws(Exception::class)
90+
fun tearDownAfterClass() {
91+
}
92+
}
93+
}

0 commit comments

Comments
 (0)