Comprehensive guide for testing KMP TaskManager - from unit tests to integration testing.
- Test Structure
- Running Tests
- Unit Testing
- Integration Testing
- Platform-Specific Testing
- Test Coverage
- Best Practices
- Troubleshooting
kmptaskmanager/
├── src/
│ ├── commonTest/ # Shared unit tests
│ │ └── io/kmp/taskmanager/
│ │ ├── ContractsTest.kt # TaskTrigger, Constraints, enums
│ │ ├── TaskChainTest.kt # TaskChain, TaskRequest
│ │ ├── TaskEventTest.kt # EventBus, events
│ │ ├── UtilsTest.kt # Logger, LogTags, TaskIds
│ │ ├── TaskTriggerHelperTest.kt # Helper functions
│ │ ├── SerializationTest.kt # JSON serialization
│ │ └── EdgeCasesTest.kt # Boundary conditions
│ │
│ ├── androidUnitTest/ # Android unit tests (JVM)
│ │ └── [Future: Android-specific unit tests]
│ │
│ ├── androidTest/ # Android instrumentation tests
│ │ └── [Future: WorkManager integration tests]
│ │
│ └── iosTest/ # iOS tests
│ └── [Future: BGTaskScheduler tests]
# Run all tests
./gradlew test
# Run with detailed output
./gradlew test --info
# Run specific module
./gradlew :kmptaskmanager:test
./gradlew :composeApp:test# Run single test file
./gradlew test --tests "io.kmp.taskmanager.ContractsTest"
# Run specific test method
./gradlew test --tests "io.kmp.taskmanager.ContractsTest.TaskTrigger*"
# Run multiple test files
./gradlew test --tests "io.kmp.taskmanager.*Test"# Android unit tests
./gradlew :kmptaskmanager:testDebugUnitTest
./gradlew :kmptaskmanager:testReleaseUnitTest
# iOS tests (requires macOS)
./gradlew :kmptaskmanager:iosX64Test
./gradlew :kmptaskmanager:iosSimulatorArm64Test
./gradlew :kmptaskmanager:iosArm64Test# Watch mode (re-run on changes)
./gradlew test --continuousclass FeatureTest {
// 1. Setup (optional)
private lateinit var subject: Subject
@BeforeTest
fun setup() {
subject = Subject()
}
@AfterTest
fun teardown() {
// Cleanup if needed
}
// 2. Test cases
@Test
fun `descriptive test name using backticks`() {
// Given (Arrange)
val input = createTestInput()
// When (Act)
val result = subject.performAction(input)
// Then (Assert)
assertEquals(expected, result)
}
}@Test
fun `OneTime trigger with custom delay should preserve value`() {
// Given
val delayMs = 5000L
// When
val trigger = TaskTrigger.OneTime(initialDelayMs = delayMs)
// Then
assertEquals(delayMs, trigger.initialDelayMs)
}@Test
fun `TaskRequest with empty workerClassName should accept value`() {
// Given
val emptyName = ""
// When
val request = TaskRequest(workerClassName = emptyName)
// Then
assertEquals("", request.workerClassName)
}
@Test
fun `Constraints with negative backoffDelayMs should accept value`() {
// Given
val negativeDelay = -1000L
// When
val constraints = Constraints(backoffDelayMs = negativeDelay)
// Then
assertEquals(negativeDelay, constraints.backoffDelayMs)
}@Test
fun `TaskChain with empty list should throw IllegalArgumentException`() {
// Given
val scheduler = MockScheduler()
val chain = scheduler.beginWith(TaskRequest("Worker1"))
// When/Then
assertFailsWith<IllegalArgumentException> {
chain.then(emptyList())
}
}@Test
fun `EventBus should emit events successfully`() = runTest {
// Given
val event = TaskCompletionEvent("Task", true, "Success")
val receivedEvents = mutableListOf<TaskCompletionEvent>()
val job = launch {
TaskEventBus.events.collect {
receivedEvents.add(it)
}
}
// When
TaskEventBus.emit(event)
delay(100) // Give time for collection
// Then
assertEquals(1, receivedEvents.size)
assertEquals(event, receivedEvents[0])
job.cancel()
}Integration tests for Android require an emulator or device.
android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
dependencies {
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test:runner:1.5.2")
androidTestImplementation("androidx.work:work-testing:2.11.0")
}@RunWith(AndroidJUnit4::class)
class NativeTaskSchedulerAndroidTest {
private lateinit var context: Context
private lateinit var scheduler: NativeTaskScheduler
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
WorkManagerTestInitHelper.initializeTestWorkManager(context)
scheduler = NativeTaskScheduler(context)
}
@Test
fun testOneTimeTaskScheduling() = runTest {
// Given
val taskId = "test-task-${System.currentTimeMillis()}"
val trigger = TaskTrigger.OneTime(initialDelayMs = 0)
// When
val result = scheduler.enqueue(
id = taskId,
trigger = trigger,
workerClassName = "TestWorker"
)
// Then
assertEquals(ScheduleResult.ACCEPTED, result)
// Verify task is scheduled
val workManager = WorkManager.getInstance(context)
val workInfo = workManager.getWorkInfosForUniqueWork(taskId).await()
assertTrue(workInfo.isNotEmpty())
}
@Test
fun testTaskWithConstraints() = runTest {
// Given
val taskId = "constrained-task"
val constraints = Constraints(requiresNetwork = true)
// When
val result = scheduler.enqueue(
id = taskId,
trigger = TaskTrigger.OneTime(),
workerClassName = "NetworkWorker",
constraints = constraints
)
// Then
assertEquals(ScheduleResult.ACCEPTED, result)
// Verify constraints are applied
val workManager = WorkManager.getInstance(context)
val workInfo = workManager.getWorkInfosForUniqueWork(taskId).await().first()
assertTrue(workInfo.constraints.requiredNetworkType != NetworkType.NOT_REQUIRED)
}
}# Install and run tests on connected device
./gradlew :kmptaskmanager:connectedAndroidTest
# Run on specific device
./gradlew :kmptaskmanager:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.device=emulator-5554iOS integration tests can be run in Xcode or using xcodebuild.
import XCTest
import KMPTaskManager
class NativeTaskSchedulerIOSTest: XCTestCase {
var scheduler: NativeTaskScheduler!
var workerFactory: TestWorkerFactory!
override func setUp() {
super.setUp()
workerFactory = TestWorkerFactory()
scheduler = NativeTaskScheduler(
workerFactory: workerFactory,
taskIds: ["test-task"]
)
}
func testTaskScheduling() async throws {
// Given
let taskId = "test-task"
let trigger = TaskTriggerOneTime(initialDelayMs: 0)
// When
let result = try await scheduler.enqueue(
id: taskId,
trigger: trigger,
workerClassName: "TestWorker"
)
// Then
XCTAssertEqual(result, ScheduleResult.accepted)
}
func testInvalidTaskIdRejection() async throws {
// Given
let invalidTaskId = "not-in-plist"
let trigger = TaskTriggerOneTime(initialDelayMs: 0)
// When
let result = try await scheduler.enqueue(
id: invalidTaskId,
trigger: trigger,
workerClassName: "TestWorker"
)
// Then
XCTAssertEqual(result, ScheduleResult.rejectedOsPolicy)
}
}For unit testing code that depends on schedulers:
class MockBackgroundTaskScheduler : BackgroundTaskScheduler {
val scheduledTasks = mutableListOf<ScheduledTask>()
override suspend fun enqueue(
id: String,
trigger: TaskTrigger,
workerClassName: String,
constraints: Constraints,
inputJson: String?,
policy: ExistingPolicy
): ScheduleResult {
scheduledTasks.add(
ScheduledTask(id, trigger, workerClassName, constraints, inputJson, policy)
)
return ScheduleResult.ACCEPTED
}
override fun cancel(id: String) {
scheduledTasks.removeIf { it.id == id }
}
override fun cancelAll() {
scheduledTasks.clear()
}
override fun beginWith(task: TaskRequest): TaskChain {
return TaskChain(this, listOf(task))
}
override fun beginWith(tasks: List<TaskRequest>): TaskChain {
return TaskChain(this, tasks)
}
override fun enqueueChain(chain: TaskChain) {
// Store chain for verification
}
data class ScheduledTask(
val id: String,
val trigger: TaskTrigger,
val workerClassName: String,
val constraints: Constraints,
val inputJson: String?,
val policy: ExistingPolicy
)
}@Test
fun `ViewModel should schedule task on button click`() = runTest {
// Given
val mockScheduler = MockBackgroundTaskScheduler()
val viewModel = MyViewModel(mockScheduler)
// When
viewModel.onScheduleButtonClicked()
// Then
assertEquals(1, mockScheduler.scheduledTasks.size)
assertEquals("sync-task", mockScheduler.scheduledTasks[0].id)
}# Run tests with coverage
./gradlew test jacocoTestReport
# View HTML report
open kmptaskmanager/build/reports/jacoco/test/html/index.htmlVersion 2.2.0:
- Total test cases: 101
- Common code coverage: 85%+
- Test files: 7
- Test lines: ~2000+
Coverage breakdown:
- Contracts (TaskTrigger, Constraints, enums): 100%
- TaskChain: 95%
- Utils (Logger, LogTags): 100%
- TaskEvent: 90%
- Serialization: 100%
- Edge cases: 100%
- Common code: 85%+ ✅ (achieved)
- Critical paths: 100% (scheduling, execution)
- Public APIs: 100% ✅ (achieved)
- Platform-specific: Integration tests (manual)
Use descriptive names with backticks:
// ✅ Good
@Test
fun `TaskChain with empty list should throw IllegalArgumentException`()
// ❌ Bad
@Test
fun test1()@Test
fun `example test`() {
// Arrange (Given)
val input = createInput()
// Act (When)
val result = performAction(input)
// Assert (Then)
assertEquals(expected, result)
}// ✅ Good - Tests one aspect
@Test
fun `Constraints with requiresNetwork should set flag`() {
val constraints = Constraints(requiresNetwork = true)
assertTrue(constraints.requiresNetwork)
}
// ❌ Bad - Tests multiple things
@Test
fun `Constraints should work`() {
val constraints = Constraints(requiresNetwork = true, requiresCharging = true)
assertTrue(constraints.requiresNetwork)
assertTrue(constraints.requiresCharging)
assertEquals(Qos.Background, constraints.qos)
// Testing too many things at once
}Tests should not depend on each other:
// ✅ Good - Each test is independent
@Test
fun `test A`() {
val scheduler = MockScheduler()
// Test A logic
}
@Test
fun `test B`() {
val scheduler = MockScheduler()
// Test B logic
}
// ❌ Bad - Tests depend on execution order
var sharedScheduler: MockScheduler? = null
@Test
fun `test A creates scheduler`() {
sharedScheduler = MockScheduler()
}
@Test
fun `test B uses scheduler from A`() {
sharedScheduler!!.schedule(...) // Fails if A doesn't run first
}Always test boundary conditions:
@Test
fun `handles zero value`()
@Test
fun `handles negative value`()
@Test
fun `handles max value`()
@Test
fun `handles empty string`()
@Test
fun `handles null value`()
@Test
fun `handles very large input`()// ✅ Good - Clear assertion
assertEquals(ScheduleResult.ACCEPTED, result, "Task should be accepted")
// ❌ Bad - Generic assertion
assertTrue(result == ScheduleResult.ACCEPTED)-
Run clean build:
./gradlew clean test -
Check for flaky tests:
./gradlew test --rerun-tasks -
Run specific failing test:
./gradlew test --tests "FailingTest" --info
-
Parallel execution:
./gradlew test --parallel --max-workers=4 -
Skip unrelated tests:
./gradlew :kmptaskmanager:test # Only library tests
# Increase Gradle memory
export GRADLE_OPTS="-Xmx4096m"
./gradlew testAndroid:
# Clear WorkManager database
adb shell pm clear com.example.appiOS:
# Reset simulator
xcrun simctl shutdown all
xcrun simctl erase allHappy Testing! 🎉
For questions, see CONTRIBUTING.md or open a discussion.
Last Updated: December 2025