Skip to content

Conversation

@jinliu9508
Copy link
Contributor

@jinliu9508 jinliu9508 commented Jan 20, 2026

Description

One Line Summary

Inject coroutine dispatcher provider into startup/async services to improve test determinism and reduce flaky coroutine timing issues.

Details

Motivation

Several SDK components launch background work using global/default dispatchers, which makes unit/integration tests timing-dependent and occasionally flaky (especially under CI load). This PR introduces dependency injection for coroutine dispatchers (via a CoroutineDispatcherProvider with a default implementation), allowing tests to supply a controlled dispatcher (e.g., unconfined/test dispatcher) while keeping production behavior unchanged. The goal is to make startup and async flows deterministic, easier to test, and less prone to race conditions.

Scope

Services/classes that previously used global dispatchers (e.g., StartupService) now accept an injected CoroutineDispatcherProvider (defaulting to DefaultDispatcherProvider()), and use the injected dispatchers for coroutine launches.

OPTIONAL - Other

This pattern centralizes dispatcher usage, makes it easier to add structured concurrency in the future, and encourages consistent coroutine threading across modules. It also reduces the need for sleeps/polling in tests by allowing controlled scheduling.

Testing

Unit testing

  • Updated existing tests to inject a test dispatcher provider where startup/initialization work is involved.
  • Verified async startup flows complete deterministically without relying on timing-based polling or delays.

Manual testing

Not needed

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
    • If it is hard to explain how any codes changes are related to each other then it most likely needs to be more than one PR
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
    • Simplify with less code, followed by splitting up code into well named functions and variables, followed by adding comments to the code.
  • I have reviewed this PR myself, ensuring it meets each checklist item
    • WIP (Work In Progress) is ok, but explain what is still in progress and what you would like feedback on. Start the PR title with "WIP" to indicate this.

This change is Reviewable

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces dependency injection for coroutine dispatchers to improve test determinism and reduce flaky tests. A new CoroutineDispatcherProvider interface with DefaultDispatcherProvider (production) and TestDispatcherProvider (testing) implementations allows controlled execution of coroutines in tests.

Changes:

  • Added CoroutineDispatcherProvider interface and implementations for production (DefaultDispatcherProvider) and testing (TestDispatcherProvider)
  • Injected dispatcher provider into OneSignalImp, StartupService, NotificationRepository, and OutcomeEventsRepository with default values for backward compatibility
  • Updated tests to use TestDispatcherProvider for deterministic coroutine execution

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
CoroutineDispatcherProvider.kt Interface defining dispatcher provider contract with IO and Default dispatchers plus launch methods
DefaultDispatcherProvider.kt Production implementation delegating to existing OneSignalDispatchers
TestDispatcherProvider.kt Test implementation using StandardTestDispatcher with comprehensive usage documentation
StartupService.kt Accepts injected dispatcher provider, uses it for launching startable services
OneSignalImp.kt Accepts injected dispatcher provider, uses it for all withContext and runBlocking calls
OutcomeEventsRepository.kt Accepts injected dispatcher provider, uses it for all database operations
NotificationRepository.kt Accepts injected dispatcher provider, uses it for all database operations
StartupServiceTests.kt Updated to use TestDispatcherProvider with proper runTest wrapper and advanceUntilIdle
OneSignalImpTests.kt Updated to create TestDispatcherProvider instances (tests work without runTest as they test non-suspend paths)
OutcomeEventsRepositoryTests.kt Updated to create TestDispatcherProvider instances but missing critical runTest wrappers
Comments suppressed due to low confidence (2)

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt:52

  • Tests using TestDispatcherProvider with StandardTestDispatcher must be wrapped in runTest() to work correctly. Without runTest(), StandardTestDispatcher won't execute any coroutines, and the test assertions will run before the repository methods complete. The test should follow the pattern shown in TestDispatcherProvider's documentation. Wrap the test body in runTest(testDispatcher.scheduler) and call advanceUntilIdle() before assertions.
    test("delete outcome event should use the timestamp to delete row from database") {
        // Given
        val testDispatcher = StandardTestDispatcher()
        val dispatcherProvider = TestDispatcherProvider(testDispatcher)
        val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME)
        val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, dispatcherProvider)

        // When
        outcomeEventsRepository.deleteOldOutcomeEvent(OutcomeEventParams("outcomeId", null, 0f, 0, 1111))

        // Then
        verify(exactly = 1) {
            mockDatabasePair.second.delete(
                OutcomeEventsTable.TABLE_NAME,
                withArg {
                    it.contains(OutcomeEventsTable.COLUMN_NAME_TIMESTAMP)
                },
                withArg { it.contains("1111") },
            )
        }
    }

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt:155

  • Tests using TestDispatcherProvider with StandardTestDispatcher must be wrapped in runTest() to work correctly. Without runTest(), StandardTestDispatcher won't execute any coroutines, and the test assertions will run before the repository methods complete. The test should follow the pattern shown in TestDispatcherProvider's documentation. Wrap the test body in runTest(testDispatcher.scheduler) and call advanceUntilIdle() before assertions.
    test("save outcome event should insert row into database") {
        // Given
        val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME)
        val testDispatcher = StandardTestDispatcher()
        val dispatcherProvider = TestDispatcherProvider(testDispatcher)
        val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, dispatcherProvider)

        // When
        outcomeEventsRepository.saveOutcomeEvent(OutcomeEventParams("outcomeId1", null, 0f, 0, 1111))
        outcomeEventsRepository.saveOutcomeEvent(
            OutcomeEventParams(
                "outcomeId2",
                OutcomeSource(
                    OutcomeSourceBody(JSONArray().put("notificationId1")),
                    OutcomeSourceBody(null, JSONArray().put("iamId1").put("iamId2")),
                ),
                .2f,
                0,
                2222,
            ),
        )
        outcomeEventsRepository.saveOutcomeEvent(
            OutcomeEventParams(
                "outcomeId3",
                OutcomeSource(
                    OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1")),
                    null,
                ),
                .4f,
                0,
                3333,
            ),
        )
        outcomeEventsRepository.saveOutcomeEvent(
            OutcomeEventParams(
                "outcomeId4",
                OutcomeSource(
                    null,
                    OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1").put("iamId2")),
                ),
                .6f,
                0,
                4444,
            ),
        )

        // Then
        verifySequence {
            mockDatabasePair.second.insert(
                OutcomeEventsTable.TABLE_NAME,
                null,
                withArg {
                    it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId1"
                    it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe 0f
                    it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 1111L
                    it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "unattributed"
                    it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray().toString()
                    it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "unattributed"
                    it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray().toString()
                },
            )
            mockDatabasePair.second.insert(
                OutcomeEventsTable.TABLE_NAME,
                null,
                withArg {
                    it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId2"
                    it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .2f
                    it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 2222L
                    it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "direct"
                    it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString()
                    it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "indirect"
                    it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\", \"iamId2\"]").toString()
                },
            )
            mockDatabasePair.second.insert(
                OutcomeEventsTable.TABLE_NAME,
                null,
                withArg {
                    it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId3"
                    it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .4f
                    it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 3333L
                    it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "direct"
                    it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString()
                    it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "direct"
                    it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\"]").toString()
                },
            )
            mockDatabasePair.second.insert(
                OutcomeEventsTable.TABLE_NAME,
                null,
                withArg {
                    it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId4"
                    it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .6f
                    it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 4444L
                    it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "indirect"
                    it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString()
                    it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "indirect"
                    it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\", \"iamId2\"]").toString()
                },
            )
        }
    }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 315 to 321
test("save unique outcome should insert 1 row for each unique influence when direct iam and indiract notifications") {
// Given
val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first)
val testDispatcher = StandardTestDispatcher()
val dispatcherProvider = TestDispatcherProvider(testDispatcher)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, dispatcherProvider)

Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests using TestDispatcherProvider with StandardTestDispatcher must be wrapped in runTest() to work correctly. Without runTest(), StandardTestDispatcher won't execute any coroutines, and the test assertions will run before the repository methods complete. The test should follow the pattern shown in TestDispatcherProvider's documentation. Wrap the test body in runTest(testDispatcher.scheduler) and call advanceUntilIdle() before assertions.

Copilot uses AI. Check for mistakes.
Comment on lines 412 to 418
test("save unique outcome should insert 1 row for each unique influence when indirect notification and iam") {
// Given
val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first)
val testDispatcher = StandardTestDispatcher()
val dispatcherProvider = TestDispatcherProvider(testDispatcher)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, dispatcherProvider)

Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests using TestDispatcherProvider with StandardTestDispatcher must be wrapped in runTest() to work correctly. Without runTest(), StandardTestDispatcher won't execute any coroutines, and the test assertions will run before the repository methods complete. The test should follow the pattern shown in TestDispatcherProvider's documentation. Wrap the test body in runTest(testDispatcher.scheduler) and call advanceUntilIdle() before assertions.

Copilot uses AI. Check for mistakes.
Comment on lines 475 to 481
// Given
val records = listOf<Map<String, Any>>()
val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME, records)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first)
val testDispatcher = StandardTestDispatcher()
val dispatcherProvider = TestDispatcherProvider(testDispatcher)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, dispatcherProvider)

Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests using TestDispatcherProvider with StandardTestDispatcher must be wrapped in runTest() to work correctly. Without runTest(), StandardTestDispatcher won't execute any coroutines, and the test assertions will run before the repository methods complete. The test should follow the pattern shown in TestDispatcherProvider's documentation. Wrap the test body in runTest(testDispatcher.scheduler) and call advanceUntilIdle() before assertions.

Copilot uses AI. Check for mistakes.
Comment on lines 368 to 374
test("save unique outcome should insert 1 row for each unique influence when direct notification and iam") {
// Given
val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first)
val testDispatcher = StandardTestDispatcher()
val dispatcherProvider = TestDispatcherProvider(testDispatcher)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, dispatcherProvider)

Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests using TestDispatcherProvider with StandardTestDispatcher must be wrapped in runTest() to work correctly. Without runTest(), StandardTestDispatcher won't execute any coroutines, and the test assertions will run before the repository methods complete. The test should follow the pattern shown in TestDispatcherProvider's documentation. Wrap the test body in runTest(testDispatcher.scheduler) and call advanceUntilIdle() before assertions.

Copilot uses AI. Check for mistakes.
Comment on lines 497 to 503
// Given
val records = listOf(mapOf(CachedUniqueOutcomeTable.COLUMN_NAME_NAME to "outcomeId1"))
val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME, records)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first)
val testDispatcher = StandardTestDispatcher()
val dispatcherProvider = TestDispatcherProvider(testDispatcher)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, dispatcherProvider)

Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests using TestDispatcherProvider with StandardTestDispatcher must be wrapped in runTest() to work correctly. Without runTest(), StandardTestDispatcher won't execute any coroutines, and the test assertions will run before the repository methods complete. The test should follow the pattern shown in TestDispatcherProvider's documentation. Wrap the test body in runTest(testDispatcher.scheduler) and call advanceUntilIdle() before assertions.

Copilot uses AI. Check for mistakes.
Comment on lines 518 to 524
test("clear unique influence should delete out an influence when there are is a matching influence") {
// Given
val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first)
val testDispatcher = StandardTestDispatcher()
val dispatcherProvider = TestDispatcherProvider(testDispatcher)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, dispatcherProvider)

Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests using TestDispatcherProvider with StandardTestDispatcher must be wrapped in runTest() to work correctly. Without runTest(), StandardTestDispatcher won't execute any coroutines, and the test assertions will run before the repository methods complete. The test should follow the pattern shown in TestDispatcherProvider's documentation. Wrap the test body in runTest(testDispatcher.scheduler) and call advanceUntilIdle() before assertions.

Copilot uses AI. Check for mistakes.
Comment on lines 157 to 163
test("get events should retrieve return empty list when database is empty") {
// Given
val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first)
val testDispatcher = StandardTestDispatcher()
val dispatcherProvider = TestDispatcherProvider(testDispatcher)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, dispatcherProvider)

Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests using TestDispatcherProvider with StandardTestDispatcher must be wrapped in runTest() to work correctly. Without runTest(), StandardTestDispatcher won't execute any coroutines, and the test assertions will run before the repository methods complete. The test should follow the pattern shown in TestDispatcherProvider's documentation. Wrap the test body in runTest(testDispatcher.scheduler) and call advanceUntilIdle() before assertions.

Copilot uses AI. Check for mistakes.
Comment on lines 203 to 209
)
val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME, records)

val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first)
val testDispatcher = StandardTestDispatcher()
val dispatcherProvider = TestDispatcherProvider(testDispatcher)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, dispatcherProvider)

Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests using TestDispatcherProvider with StandardTestDispatcher must be wrapped in runTest() to work correctly. Without runTest(), StandardTestDispatcher won't execute any coroutines, and the test assertions will run before the repository methods complete. The test should follow the pattern shown in TestDispatcherProvider's documentation. Wrap the test body in runTest(testDispatcher.scheduler) and call advanceUntilIdle() before assertions.

Copilot uses AI. Check for mistakes.
Comment on lines 248 to 254
test("save unique outcome should insert no rows when no influences") {
// Given
val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.COLUMN_NAME_NAME)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first)
val testDispatcher = StandardTestDispatcher()
val dispatcherProvider = TestDispatcherProvider(testDispatcher)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, dispatcherProvider)

Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests using TestDispatcherProvider with StandardTestDispatcher must be wrapped in runTest() to work correctly. Without runTest(), StandardTestDispatcher won't execute any coroutines, and the test assertions will run before the repository methods complete. The test should follow the pattern shown in TestDispatcherProvider's documentation. Wrap the test body in runTest(testDispatcher.scheduler) and call advanceUntilIdle() before assertions.

Copilot uses AI. Check for mistakes.
Comment on lines 262 to 268
test("save unique outcome should insert 1 row for each unique influence when direct notification and indiract iam") {
// Given
val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first)
val testDispatcher = StandardTestDispatcher()
val dispatcherProvider = TestDispatcherProvider(testDispatcher)
val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, dispatcherProvider)

Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests using TestDispatcherProvider with StandardTestDispatcher must be wrapped in runTest() to work correctly. Without runTest(), StandardTestDispatcher won't execute any coroutines, and the test assertions will run before the repository methods complete. The test should follow the pattern shown in TestDispatcherProvider's documentation. Wrap the test body in runTest(testDispatcher.scheduler) and call advanceUntilIdle() before assertions.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants