Skip to content

Commit 5e86c9f

Browse files
authored
Improve test robustness (#4369)
* Fix testFutureIsDoneAfterChildrenCompleted flakiness `invokeOnCompletion` can run in parallel to the code that has already detected a coroutine's completion. This has happened on the CI once. The assumption that this can't happen has been removed from the test. * Improve println() detection in tests Now, the printed messages are also preserved, and also, failing to remove a println capturer from a test no longer affects the unrelated tests. * Move robust test cleanup
1 parent 7be64e7 commit 5e86c9f

File tree

5 files changed

+101
-62
lines changed

5 files changed

+101
-62
lines changed

Diff for: integration/kotlinx-coroutines-guava/test/ListenableFutureTest.kt

+4-6
Original file line numberDiff line numberDiff line change
@@ -627,26 +627,24 @@ class ListenableFutureTest : TestBase() {
627627
fun testFutureIsDoneAfterChildrenCompleted() = runTest {
628628
expect(1)
629629
val testException = TestException()
630-
val latch = CountDownLatch(1)
630+
val futureIsAllowedToFinish = CountDownLatch(1)
631631
// Don't propagate exception to the test and use different dispatchers as we are going to block test thread.
632632
val future = future(context = NonCancellable + Dispatchers.Default) {
633633
val foo = async(start = CoroutineStart.UNDISPATCHED) {
634634
try {
635635
delay(Long.MAX_VALUE)
636636
42
637637
} finally {
638-
latch.await()
638+
futureIsAllowedToFinish.await()
639+
expect(3)
639640
}
640641
}
641-
foo.invokeOnCompletion {
642-
expect(3)
643-
}
644642
val bar = async<Int> { throw testException }
645643
foo.await() + bar.await()
646644
}
647645
yield()
648646
expect(2)
649-
latch.countDown()
647+
futureIsAllowedToFinish.countDown()
650648
// Blocking get should succeed after internal coroutine completes.
651649
val thrown = assertFailsWith<ExecutionException> { future.get() }
652650
expect(4)

Diff for: kotlinx-coroutines-debug/test/DebugTestBase.kt

+1-6
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,13 @@ open class DebugTestBase : TestBase() {
1414

1515
@Before
1616
open fun setUp() {
17-
before()
1817
DebugProbes.sanitizeStackTraces = false
1918
DebugProbes.enableCreationStackTraces = false
2019
DebugProbes.install()
2120
}
2221

2322
@After
2423
fun tearDown() {
25-
try {
26-
DebugProbes.uninstall()
27-
} finally {
28-
onCompletion()
29-
}
24+
DebugProbes.uninstall()
3025
}
3126
}

Diff for: kotlinx-coroutines-debug/test/ToStringTest.kt

+1-18
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,7 @@ import java.io.*
99
import kotlin.coroutines.*
1010
import kotlin.test.*
1111

12-
class ToStringTest : TestBase() {
13-
14-
@Before
15-
fun setUp() {
16-
before()
17-
DebugProbes.sanitizeStackTraces = false
18-
DebugProbes.install()
19-
}
20-
21-
@After
22-
fun tearDown() {
23-
try {
24-
DebugProbes.uninstall()
25-
} finally {
26-
onCompletion()
27-
}
28-
}
29-
12+
class ToStringTest : DebugTestBase() {
3013

3114
private suspend fun CoroutineScope.launchNestedScopes(): Job {
3215
return launch {

Diff for: test-utils/common/src/TestBase.common.kt

+3-1
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ interface ErrorCatching {
133133
fun close() {
134134
synchronized(lock) {
135135
if (closed) {
136-
lastResortReportException(IllegalStateException("ErrorCatching closed more than once"))
136+
val error = IllegalStateException("ErrorCatching closed more than once")
137+
lastResortReportException(error)
138+
errors.add(error)
137139
}
138140
closed = true
139141
errors.firstOrNull()?.let {

Diff for: test-utils/jvm/src/TestBase.kt

+92-31
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import java.io.*
55
import java.util.*
66
import kotlin.coroutines.*
77
import kotlinx.coroutines.*
8+
import java.util.concurrent.atomic.AtomicReference
89
import kotlin.test.*
910

1011
actual val VERBOSE = try {
@@ -68,23 +69,9 @@ actual open class TestBase(
6869
private lateinit var threadsBefore: Set<Thread>
6970
private val uncaughtExceptions = Collections.synchronizedList(ArrayList<Throwable>())
7071
private var originalUncaughtExceptionHandler: Thread.UncaughtExceptionHandler? = null
71-
/*
72-
* System.out that we redefine in order to catch any debugging/diagnostics
73-
* 'println' from main source set.
74-
* NB: We do rely on the name 'previousOut' in the FieldWalker in order to skip its
75-
* processing
76-
*/
77-
private lateinit var previousOut: PrintStream
78-
79-
private object TestOutputStream : PrintStream(object : OutputStream() {
80-
override fun write(b: Int) {
81-
error("Detected unexpected call to 'println' from source code")
82-
}
83-
})
8472

8573
actual fun println(message: Any?) {
86-
if (disableOutCheck) kotlin.io.println(message)
87-
else previousOut.println(message)
74+
PrintlnStrategy.actualSystemOut.println(message)
8875
}
8976

9077
@BeforeTest
@@ -97,34 +84,33 @@ actual open class TestBase(
9784
e.printStackTrace()
9885
uncaughtExceptions.add(e)
9986
}
100-
if (!disableOutCheck) {
101-
previousOut = System.out
102-
System.setOut(TestOutputStream)
103-
}
87+
PrintlnStrategy.configure(disableOutCheck)
10488
}
10589

10690
@AfterTest
10791
fun onCompletion() {
10892
// onCompletion should not throw exceptions before it finishes all cleanup, so that other tests always
109-
// start in a clear, restored state
110-
checkFinishCall()
111-
if (!disableOutCheck) { // Restore global System.out first
112-
System.setOut(previousOut)
93+
// start in a clear, restored state, so we postpone throwing the observed errors.
94+
fun cleanupStep(block: () -> Unit) {
95+
try {
96+
block()
97+
} catch (e: Throwable) {
98+
reportError(e)
99+
}
113100
}
101+
cleanupStep { checkFinishCall() }
102+
// Reset the output stream first
103+
cleanupStep { PrintlnStrategy.reset() }
114104
// Shutdown all thread pools
115-
shutdownPoolsAfterTest()
105+
cleanupStep { shutdownPoolsAfterTest() }
116106
// Check that are now leftover threads
117-
runCatching {
118-
checkTestThreads(threadsBefore)
119-
}.onFailure {
120-
reportError(it)
121-
}
107+
cleanupStep { checkTestThreads(threadsBefore) }
122108
// Restore original uncaught exception handler after the main shutdown sequence
123109
Thread.setDefaultUncaughtExceptionHandler(originalUncaughtExceptionHandler)
124110
if (uncaughtExceptions.isNotEmpty()) {
125-
error("Expected no uncaught exceptions, but got $uncaughtExceptions")
111+
reportError(IllegalStateException("Expected no uncaught exceptions, but got $uncaughtExceptions"))
126112
}
127-
// The very last action -- throw error if any was detected
113+
// The very last action -- throw all the detected errors
128114
errorCatching.close()
129115
}
130116

@@ -164,6 +150,81 @@ actual open class TestBase(
164150
protected suspend fun currentDispatcher() = coroutineContext[ContinuationInterceptor]!!
165151
}
166152

153+
private object PrintlnStrategy {
154+
/**
155+
* Installs a custom [PrintStream] instead of [System.out] to capture all the output and throw an exception if
156+
* any was detected.
157+
*
158+
* Removes the previously set println handler and throws the exceptions detected by it.
159+
* If [disableOutCheck] is set, this is the only effect.
160+
*/
161+
fun configure(disableOutCheck: Boolean) {
162+
val systemOut = System.out
163+
if (systemOut is TestOutputStream) {
164+
try {
165+
systemOut.remove()
166+
} catch (e: AssertionError) {
167+
throw AssertionError("The previous TestOutputStream contained ", e)
168+
}
169+
}
170+
if (!disableOutCheck) {
171+
// Invariant: at most one indirection level in `TestOutputStream`.
172+
System.setOut(TestOutputStream(actualSystemOut))
173+
}
174+
}
175+
176+
/**
177+
* Removes the custom [PrintStream] and throws an exception if any output was detected.
178+
*/
179+
fun reset() {
180+
(System.out as? TestOutputStream)?.remove()
181+
}
182+
183+
/**
184+
* The [PrintStream] representing the actual stdout, ignoring the replacement [TestOutputStream].
185+
*/
186+
val actualSystemOut: PrintStream get() = when (val out = System.out) {
187+
is TestOutputStream -> out.previousOut
188+
else -> out
189+
}
190+
191+
private class TestOutputStream(
192+
/*
193+
* System.out that we redefine in order to catch any debugging/diagnostics
194+
* 'println' from main source set.
195+
* NB: We do rely on the name 'previousOut' in the FieldWalker in order to skip its
196+
* processing
197+
*/
198+
val previousOut: PrintStream,
199+
private val myOutputStream: MyOutputStream = MyOutputStream(),
200+
) : PrintStream(myOutputStream) {
201+
202+
fun remove() {
203+
System.setOut(previousOut)
204+
if (myOutputStream.firstPrintStacktace.get() != null) {
205+
throw AssertionError(
206+
"Detected a println. The captured output is: <<<${myOutputStream.capturedOutput}>>>",
207+
myOutputStream.firstPrintStacktace.get()
208+
)
209+
}
210+
}
211+
212+
private class MyOutputStream(): OutputStream() {
213+
val capturedOutput = ByteArrayOutputStream()
214+
215+
val firstPrintStacktace = AtomicReference<Throwable?>(null)
216+
217+
override fun write(b: Int) {
218+
if (firstPrintStacktace.get() == null) {
219+
firstPrintStacktace.compareAndSet(null, IllegalStateException())
220+
}
221+
capturedOutput.write(b)
222+
}
223+
}
224+
225+
}
226+
}
227+
167228
@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
168229
fun initPoolsBeforeTest() {
169230
DefaultScheduler.usePrivateScheduler()

0 commit comments

Comments
 (0)