fix(test): improve timing stability in tests (#506)

This commit is contained in:
Konstantin Pavlov 2026-02-06 12:25:18 +02:00 • committed by GitHub
commit 7eb2a93c46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 66 additions and 35 deletions

View file

@ -1,14 +1,12 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.kotest.assertions.nondeterministic.eventually
import io.kotest.matchers.shouldBe
import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.test.fail
import kotlin.time.Duration.Companion.seconds
@ -23,12 +21,18 @@ abstract class BaseTransportTest {
transport.onClose { didClose = true }
transport.start()
delay(1.seconds)
assertFalse(didClose, "Transport should not be closed immediately after start")
// Verify transport stays open after start (use eventually for cross-platform reliability)
eventually(2.seconds) {
didClose shouldBe false
}
transport.close()
assertTrue(didClose, "Transport should be closed after close() call")
// Verify transport is closed after close() call
eventually(2.seconds) {
didClose shouldBe true
}
}
protected suspend fun testTransportRead(transport: Transport) {
@ -60,7 +64,7 @@ abstract class BaseTransportTest {
finished.await()
assertEquals(messages, readMessages, "Assert messages received")
messages shouldBe readMessages
transport.close()
}

View file

@ -1,12 +1,12 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.kotest.assertions.nondeterministic.eventually
import io.modelcontextprotocol.kotlin.sdk.shared.BaseTransportTest
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.test.utils.createSleepyProcessBuilder
import io.modelcontextprotocol.kotlin.test.utils.createTeeProcessBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.io.asSink
@ -23,7 +23,6 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.test.fail
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Timeout(30, unit = TimeUnit.SECONDS)
@ -89,17 +88,21 @@ class StdioClientTransportTest : BaseTransportTest() {
transport.onClose { didClose.store(true) }
transport.start()
delay(1.seconds)
assertFalse(didClose.load(), "Transport should not be closed immediately after start")
// Verify transport stays open after start (use eventually for cross-platform reliability)
eventually(2.seconds) {
assertFalse(didClose.load(), "Transport should not be closed immediately after start")
}
// Destroy process BEFORE close() to unblock stdin reader
process.destroyForcibly()
delay(100.milliseconds) // Give time for EOF to propagate
transport.close()
assertTrue(didClose.load(), "Transport should be closed after close() call")
// Verify transport is closed after close() call
eventually(2.seconds) {
assertTrue(didClose.load(), "Transport should be closed after close() call")
}
}
@Test

View file

@ -1,6 +1,10 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.kotest.assertions.nondeterministic.eventually
import io.kotest.assertions.withClue
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsResourcesUpdated
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification
@ -9,13 +13,11 @@ import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest
import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.awaitility.kotlin.await
import org.awaitility.kotlin.untilAsserted
import org.junit.jupiter.api.Test
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.ExperimentalUuidApi
@ -115,7 +117,7 @@ class ServerResourcesNotificationSubscribeTest : AbstractServerFeaturesTest() {
}
@Test
fun `notification should not be send when removed resource does not exists`() = runBlocking {
fun `notification should not be send when removed resource does not exists`(): Unit = runBlocking {
println("Thread ${Thread.currentThread().name} test 1")
// Track notifications
val notifications = ConcurrentLinkedQueue<ResourceUpdatedNotification>()
@ -126,13 +128,15 @@ class ServerResourcesNotificationSubscribeTest : AbstractServerFeaturesTest() {
// Try to remove a non-existent resource
val result = server.removeResource("non-existent-resource")
assertFalse(result, "Removing non-existent resource should return false")
withClue("Removing non-existent resource should return false") {
result shouldBe false
}
// Verify the result
delay(1.seconds)
assertTrue(
notifications.isEmpty(),
"No notification should be sent when resource doesn't exist",
)
// Verify no notification is sent (use eventually for cross-platform reliability)
withClue("No notification should be sent when resource doesn't exist") {
eventually(2.seconds) {
notifications.shouldBeEmpty()
}
}
}
}