Skip to content

Exceptions being swallowed during tests / UncaughtExceptionsBeforeTest #4141

Open
@SnyersK

Description

@SnyersK

Describe the bug

When replacing the Main Dispatcher with a TestDispatcher, tests are not failing as expected.
I've provided an example below which showcases the issue.

When the Main Dispatcher is replaced, and I don't use runTest, as it doesn't seem necessary, exceptions during that test get swallowed, and the test completes successfully.

There's some inconsistent behaviour though, and I'm not sure why:

  • Sometimes the exception seems to be swallowed completely, and even a subsequent test does not fail. (1 & 2)
  • Sometimes a subsequent test which does use runTest fails with an UncaughtExceptionsBeforeTest (4 & 5)

I'm not sure if this is intended behaviour.
If it is, I suppose the main issue I have, is that it's not clear that runTest should be used for all tests when the Main Dispatcher is replaced with a TestDispatcher;
Even if we're not actively interacting with Coroutines.
(no time control / not launching any jobs / not calling suspending functions ...)

One way this can become an issue without it being noticed:
Given a class with corresponding tests which already replaces the Main Scheduler via setup/teardown or a test rule

if a method of this class, which was not launching any coroutine job before, is changed to launch a coroutine,
the existing test(s) won't be using runTest.
This may result in exceptions being thrown, but the test not failing.
Best case scenario, a later test fails, giving enough information to fix the actual cause.
Worst case scenario, this exception is not thrown anywhere at all, potentially leaving a bug in the code which should have gotten caught by the tests.

Provide a Reproducer

interface Dependency {
    fun getSomething(): Boolean
}

private class Example(
    private val dependency: Dependency
) {

    private val coroutineScope by lazy {
        CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    }

    fun doSomething(fail: Boolean) {
        coroutineScope.launch {
            if (fail) throw Exception("fail")
        }
    }

    fun doSomethingWithDependency() {
        coroutineScope.launch {
            dependency.getSomething()
        }
    }
}

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class ExampleTest {

    @get:Rule
    val mockkRule = MockKRule(this)

    private val dependencyMock: Dependency = mockk()
    private val sut = Example(dependencyMock)

    @Before
    fun setUp() {
        Dispatchers.setMain(UnconfinedTestDispatcher())
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `1 should fail but does not`() {
        sut.doSomething(fail = true)
    }

    @Test
    fun `2 succeeds - so above error is fully swallowed`() = runTest {
        sut.doSomething(fail = false)
    }

    @Test
    fun `3 fails as expected`() = runTest {
        sut.doSomething(fail = true)
    }

    @Test
    fun `4 should fail but does not`() {
        sut.doSomethingWithDependency()

        verify { dependencyMock.getSomething() }
    }

    @Test
    fun `5 should succeed but does not - due to above error`() = runTest {
        // kotlinx.coroutines.test.UncaughtExceptionsBeforeTest: There were uncaught exceptions before the test started. Please avoid this, as such exceptions are also reported in a platform-dependent manner so that they are not lost.
        every { dependencyMock.getSomething() } returns true

        sut.doSomethingWithDependency()

        verify { dependencyMock.getSomething() }
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    docsKDoc and API referencetest

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions