-
Notifications
You must be signed in to change notification settings - Fork 376
chore: improve testing with injected coroutine dispatchers #2524
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this 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
CoroutineDispatcherProviderinterface and implementations for production (DefaultDispatcherProvider) and testing (TestDispatcherProvider) - Injected dispatcher provider into
OneSignalImp,StartupService,NotificationRepository, andOutcomeEventsRepositorywith default values for backward compatibility - Updated tests to use
TestDispatcherProviderfor 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.
...l/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt
Outdated
Show resolved
Hide resolved
...l/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt
Outdated
Show resolved
Hide resolved
...l/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt
Outdated
Show resolved
Hide resolved
...l/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt
Outdated
Show resolved
Hide resolved
...l/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt
Outdated
Show resolved
Hide resolved
...l/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt
Outdated
Show resolved
Hide resolved
...l/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt
Show resolved
Hide resolved
...l/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt
Outdated
Show resolved
Hide resolved
...l/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt
Show resolved
Hide resolved
...l/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt
Outdated
Show resolved
Hide resolved
dc6b67c to
2d6ca0c
Compare
📊 Diff Coverage ReportDiff Coverage ReportThreshold: 80% Changed Files Coverage
Overall Coverage9/569 lines covered (1.6%) ❌ Coverage Check FailedFiles below 80% threshold:
|
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks good to me
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| val os = OneSignalImp() | ||
| runTest(testDispatcher.scheduler) { | ||
| // Given | ||
| val os = OneSignalImp() |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test case creates OneSignalImp() without passing the test dispatcher, while all other test cases in this file consistently pass dispatcherProvider.io. For consistency and proper test isolation, this should also use the test dispatcher: OneSignalImp(dispatcherProvider.io).
| } | ||
| } | ||
|
|
||
| test("save unique outcome should insert 1 row for each unique influence when direct notification and indiract iam") { |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typo in test name: "indiract" should be "indirect".
| test("save unique outcome should insert 1 row for each unique influence when direct notification and indiract iam") { | |
| test("save unique outcome should insert 1 row for each unique influence when direct notification and indirect iam") { |
| } | ||
| } | ||
|
|
||
| test("save unique outcome should insert 1 row for each unique influence when direct iam and indiract notifications") { |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typo in test name: "indiract" should be "indirect".
| private val _databaseProvider: IDatabaseProvider, | ||
| private val _time: ITime, | ||
| private val _badgeCountUpdater: IBadgeCountUpdater, | ||
| private val dispatchers: CoroutineDispatcherProvider = DefaultDispatcherProvider(), |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The NotificationRepository accepts a CoroutineDispatcherProvider but only uses the io dispatcher (dispatchers.io). For consistency with OutcomeEventsRepository and to avoid over-injection, consider changing the parameter type to CoroutineDispatcher and accepting only the IO dispatcher directly. This would make the API clearer about what's actually needed.
| val os = OneSignalImp() | ||
| runTest(testDispatcher.scheduler) { | ||
| // Given | ||
| val os = OneSignalImp() |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These test cases create OneSignalImp() without passing the test dispatcher, while all other test cases in this file consistently pass dispatcherProvider.io. For consistency and proper test isolation, these should also use the test dispatcher: OneSignalImp(dispatcherProvider.io).
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
Manual testing
Not needed
Affected code checklist
Checklist
Overview
Testing
Final pass
This change is