Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 5e86c9f

Browse files
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

‎integration/kotlinx-coroutines-guava/test/ListenableFutureTest.kt

Lines changed: 4 additions & 6 deletions
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)

‎kotlinx-coroutines-debug/test/DebugTestBase.kt

Lines changed: 1 addition & 6 deletions
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
}

‎kotlinx-coroutines-debug/test/ToStringTest.kt

Lines changed: 1 addition & 18 deletions
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 {

‎test-utils/common/src/TestBase.common.kt

Lines changed: 3 additions & 1 deletion
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 {

‎test-utils/jvm/src/TestBase.kt

Lines changed: 92 additions & 31 deletions
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)

AltStyle によって変換されたページ (->オリジナル) /