-
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.
| 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) | ||
|
|
Copilot
AI
Jan 20, 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.
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 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) | ||
|
|
Copilot
AI
Jan 20, 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.
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.
| // 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) | ||
|
|
Copilot
AI
Jan 20, 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.
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 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) | ||
|
|
Copilot
AI
Jan 20, 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.
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.
| // 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) | ||
|
|
Copilot
AI
Jan 20, 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.
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("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) | ||
|
|
Copilot
AI
Jan 20, 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.
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("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) | ||
|
|
Copilot
AI
Jan 20, 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.
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.
| ) | ||
| 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) | ||
|
|
Copilot
AI
Jan 20, 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.
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 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) | ||
|
|
Copilot
AI
Jan 20, 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.
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 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) | ||
|
|
Copilot
AI
Jan 20, 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.
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.
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