Initial commit
This commit is contained in:
523
skills/sleeptrack-android/references/testing_guide.md
Normal file
523
skills/sleeptrack-android/references/testing_guide.md
Normal file
@@ -0,0 +1,523 @@
|
||||
# Testing Guide for Android Sleep Tracking
|
||||
|
||||
This guide provides comprehensive testing patterns for Android sleep tracking applications.
|
||||
|
||||
## Unit Testing
|
||||
|
||||
### ViewModel Testing Setup
|
||||
|
||||
```kotlin
|
||||
@ExperimentalCoroutinesApi
|
||||
class SleepTrackingViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val instantExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
private lateinit var viewModel: SleepTrackingViewModel
|
||||
private lateinit var mockContext: Application
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
mockContext = mock(Application::class.java)
|
||||
viewModel = SleepTrackingViewModel(mockContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initializeSDK should transition to INITIALIZED on success`() = runTest {
|
||||
// Given
|
||||
val userId = "test_user_123"
|
||||
|
||||
// When
|
||||
viewModel.initializeSDK(userId)
|
||||
|
||||
// Simulate success callback
|
||||
// (requires mocking Asleep SDK)
|
||||
|
||||
// Then
|
||||
assertEquals(AsleepState.STATE_INITIALIZED, viewModel.trackingState.value)
|
||||
assertEquals(userId, viewModel.userId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startTracking should fail if not initialized`() = runTest {
|
||||
// When
|
||||
viewModel.startTracking()
|
||||
|
||||
// Then
|
||||
assertNotEquals(AsleepState.STATE_TRACKING_STARTED, viewModel.trackingState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stopTracking should transition state correctly`() = runTest {
|
||||
// Given
|
||||
viewModel.initializeSDK("test_user")
|
||||
// Simulate initialization success
|
||||
viewModel.startTracking()
|
||||
// Simulate tracking started
|
||||
|
||||
// When
|
||||
viewModel.stopTracking()
|
||||
|
||||
// Then
|
||||
assertEquals(AsleepState.STATE_TRACKING_STOPPING, viewModel.trackingState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error handling should classify warnings correctly`() = runTest {
|
||||
// Given
|
||||
val warningError = AsleepError(
|
||||
AsleepErrorCode.ERR_AUDIO_SILENCED,
|
||||
"Microphone temporarily unavailable"
|
||||
)
|
||||
|
||||
// When
|
||||
val isWarning = isWarning(warningError.code)
|
||||
|
||||
// Then
|
||||
assertTrue(isWarning)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MainDispatcherRule for Coroutines
|
||||
|
||||
```kotlin
|
||||
@ExperimentalCoroutinesApi
|
||||
class MainDispatcherRule(
|
||||
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
|
||||
) : TestWatcher() {
|
||||
|
||||
override fun starting(description: Description) {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
override fun finished(description: Description) {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing StateFlow
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `trackingState should emit correct states during tracking lifecycle`() = runTest {
|
||||
// Given
|
||||
val states = mutableListOf<AsleepState>()
|
||||
val job = launch {
|
||||
viewModel.trackingState.collect { state ->
|
||||
states.add(state)
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
viewModel.initializeSDK("test_user")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertTrue(states.contains(AsleepState.STATE_INITIALIZING))
|
||||
assertTrue(states.contains(AsleepState.STATE_INITIALIZED))
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Testing
|
||||
|
||||
### Activity Testing
|
||||
|
||||
```kotlin
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SleepTrackingIntegrationTest {
|
||||
|
||||
@get:Rule
|
||||
val activityRule = ActivityScenarioRule(MainActivity::class.java)
|
||||
|
||||
@Test
|
||||
fun trackingFlow_complete() {
|
||||
// Check permissions granted
|
||||
onView(withId(R.id.btn_start_stop))
|
||||
.check(matches(isEnabled()))
|
||||
|
||||
// Start tracking
|
||||
onView(withId(R.id.btn_start_stop))
|
||||
.perform(click())
|
||||
|
||||
// Verify tracking started
|
||||
onView(withId(R.id.tracking_indicator))
|
||||
.check(matches(isDisplayed()))
|
||||
|
||||
// Wait for sequences
|
||||
Thread.sleep(60000) // 1 minute
|
||||
|
||||
// Stop tracking
|
||||
onView(withId(R.id.btn_start_stop))
|
||||
.perform(click())
|
||||
|
||||
// Verify stopped
|
||||
onView(withId(R.id.tracking_indicator))
|
||||
.check(matches(not(isDisplayed())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun errorDisplay_showsCorrectMessage() {
|
||||
// Simulate error state
|
||||
// Verify error message displayed
|
||||
onView(withId(R.id.error_text))
|
||||
.check(matches(isDisplayed()))
|
||||
.check(matches(withText(containsString("Microphone permission"))))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fragment Testing
|
||||
|
||||
```kotlin
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TrackingFragmentTest {
|
||||
|
||||
@Test
|
||||
fun fragmentLaunch_displaysCorrectUI() {
|
||||
// Launch fragment in container
|
||||
launchFragmentInContainer<TrackingFragment>(
|
||||
themeResId = R.style.Theme_SleepTracking
|
||||
)
|
||||
|
||||
// Verify initial UI state
|
||||
onView(withId(R.id.btnTrack))
|
||||
.check(matches(isDisplayed()))
|
||||
.check(matches(withText("Start Tracking")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clickStartButton_requestsPermissions() {
|
||||
launchFragmentInContainer<TrackingFragment>()
|
||||
|
||||
// Click start button
|
||||
onView(withId(R.id.btnTrack))
|
||||
.perform(click())
|
||||
|
||||
// Verify permission dialog or state change
|
||||
// This depends on permission state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Compose UI Testing
|
||||
|
||||
### Basic Compose Testing
|
||||
|
||||
```kotlin
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SleepTrackingScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun initialState_showsStartButton() {
|
||||
// Given
|
||||
val viewModel = mockViewModel(AsleepState.STATE_INITIALIZED)
|
||||
|
||||
// When
|
||||
composeTestRule.setContent {
|
||||
SleepTrackingScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Then
|
||||
composeTestRule.onNodeWithText("Start Sleep Tracking")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trackingActive_showsStopButton() {
|
||||
// Given
|
||||
val viewModel = mockViewModel(AsleepState.STATE_TRACKING_STARTED)
|
||||
|
||||
// When
|
||||
composeTestRule.setContent {
|
||||
SleepTrackingScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Then
|
||||
composeTestRule.onNodeWithText("Stop Tracking")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Tracking in progress")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun errorState_displaysErrorMessage() {
|
||||
// Given
|
||||
val error = AsleepError(AsleepErrorCode.ERR_MIC_PERMISSION, "Permission denied")
|
||||
val viewModel = mockViewModel(AsleepState.STATE_ERROR(error))
|
||||
|
||||
// When
|
||||
composeTestRule.setContent {
|
||||
SleepTrackingScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Then
|
||||
composeTestRule.onNodeWithText("Microphone permission is required")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun mockViewModel(state: AsleepState): SleepTrackingViewModel {
|
||||
return mock(SleepTrackingViewModel::class.java).apply {
|
||||
whenever(trackingState).thenReturn(MutableStateFlow(state))
|
||||
whenever(sequence).thenReturn(MutableLiveData(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing User Interactions
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun clickStartButton_startsTracking() {
|
||||
// Given
|
||||
val viewModel = SleepTrackingViewModel(mockContext)
|
||||
composeTestRule.setContent {
|
||||
SleepTrackingScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithText("Start Sleep Tracking")
|
||||
.performClick()
|
||||
|
||||
// Then
|
||||
verify(viewModel).startTracking()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clickStopButton_stopsTracking() {
|
||||
// Given
|
||||
val viewModel = mockViewModel(AsleepState.STATE_TRACKING_STARTED)
|
||||
composeTestRule.setContent {
|
||||
SleepTrackingScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithText("Stop Tracking")
|
||||
.performClick()
|
||||
|
||||
// Then
|
||||
verify(viewModel).stopTracking()
|
||||
}
|
||||
```
|
||||
|
||||
## Permission Testing
|
||||
|
||||
### Testing Permission Manager
|
||||
|
||||
```kotlin
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PermissionManagerTest {
|
||||
|
||||
private lateinit var activity: AppCompatActivity
|
||||
private lateinit var permissionManager: PermissionManager
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
val scenario = ActivityScenario.launch(TestActivity::class.java)
|
||||
scenario.onActivity { act ->
|
||||
activity = act
|
||||
permissionManager = PermissionManager(activity)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestPermissions_checksAllPermissions() {
|
||||
// When
|
||||
permissionManager.requestPermissions()
|
||||
|
||||
// Then
|
||||
// Verify permission checks called
|
||||
verify(permissionManager).isBatteryOptimizationIgnored()
|
||||
verify(permissionManager).hasMicrophonePermission()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allPermissionsGranted_updatesLiveData() {
|
||||
// Given
|
||||
// Mock all permissions as granted
|
||||
|
||||
// When
|
||||
permissionManager.checkAndRequestNext()
|
||||
|
||||
// Then
|
||||
assertTrue(permissionManager.allPermissionsGranted.value == true)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Instrumentation Testing
|
||||
|
||||
### Testing Foreground Service
|
||||
|
||||
```kotlin
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ForegroundServiceTest {
|
||||
|
||||
@get:Rule
|
||||
val serviceRule = ServiceTestRule()
|
||||
|
||||
@Test
|
||||
fun trackingService_startsForeground() {
|
||||
// Given
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
// When
|
||||
val serviceIntent = Intent(context, SleepTrackingService::class.java)
|
||||
serviceRule.startService(serviceIntent)
|
||||
|
||||
// Then
|
||||
// Verify service is in foreground
|
||||
assertTrue(isServiceForeground(context, SleepTrackingService::class.java))
|
||||
}
|
||||
|
||||
private fun isServiceForeground(context: Context, serviceClass: Class<*>): Boolean {
|
||||
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
return manager.getRunningServices(Integer.MAX_VALUE)
|
||||
.any { it.service.className == serviceClass.name && it.foreground }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Mock SDK Testing
|
||||
|
||||
### Creating SDK Mocks
|
||||
|
||||
```kotlin
|
||||
class MockAsleepSDK {
|
||||
companion object {
|
||||
fun mockInitSuccess(userId: String, listener: Asleep.AsleepConfigListener) {
|
||||
val config = mock(AsleepConfig::class.java)
|
||||
listener.onSuccess(userId, config)
|
||||
}
|
||||
|
||||
fun mockInitFail(errorCode: Int, listener: Asleep.AsleepConfigListener) {
|
||||
listener.onFail(errorCode, "Test error")
|
||||
}
|
||||
|
||||
fun mockTrackingStart(sessionId: String, listener: Asleep.AsleepTrackingListener) {
|
||||
listener.onStart(sessionId)
|
||||
}
|
||||
|
||||
fun mockTrackingPerform(sequence: Int, listener: Asleep.AsleepTrackingListener) {
|
||||
listener.onPerform(sequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Mocks in Tests
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun initializeSDK_successFlow() = runTest {
|
||||
// Given
|
||||
val userId = "test_user"
|
||||
|
||||
// Mock Asleep SDK
|
||||
MockAsleepSDK.mockInitSuccess(userId, any())
|
||||
|
||||
// When
|
||||
viewModel.initializeSDK(userId)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertEquals(AsleepState.STATE_INITIALIZED, viewModel.trackingState.value)
|
||||
}
|
||||
```
|
||||
|
||||
## Test Dependencies
|
||||
|
||||
Add to your `build.gradle`:
|
||||
|
||||
```gradle
|
||||
dependencies {
|
||||
// Unit testing
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.mockito:mockito-core:5.3.1'
|
||||
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.0.0'
|
||||
testImplementation 'androidx.arch.core:core-testing:2.2.0'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||
|
||||
// Android instrumentation testing
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
|
||||
// Fragment testing
|
||||
debugImplementation 'androidx.fragment:fragment-testing:1.6.1'
|
||||
|
||||
// Compose testing
|
||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.5.4'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest:1.5.4'
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
1. **Unit Tests**: Test ViewModels, business logic, and state management
|
||||
2. **Integration Tests**: Test UI interactions and component integration
|
||||
3. **Use Test Rules**: Leverage JUnit rules for setup/teardown
|
||||
4. **Mock External Dependencies**: Mock Asleep SDK calls for predictable tests
|
||||
5. **Test Coroutines**: Use `runTest` and `TestDispatcher` for coroutine testing
|
||||
6. **Test Permissions**: Verify permission flows but avoid actual permission dialogs
|
||||
7. **Compose Tests**: Use semantic properties and avoid hardcoded strings
|
||||
8. **CI/CD**: Run tests in continuous integration pipeline
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
```kotlin
|
||||
@Before
|
||||
fun setup() {
|
||||
// Enable debug logging for tests
|
||||
Log.setDebug(true)
|
||||
}
|
||||
```
|
||||
|
||||
### Capture Screenshots on Failure
|
||||
|
||||
```kotlin
|
||||
@Rule
|
||||
fun testRule = TestRule { base, description ->
|
||||
object : Statement() {
|
||||
override fun evaluate() {
|
||||
try {
|
||||
base.evaluate()
|
||||
} catch (t: Throwable) {
|
||||
// Capture screenshot
|
||||
Screenshot.capture()
|
||||
throw t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Aim for:
|
||||
- **Unit Tests**: 80%+ coverage of ViewModels and business logic
|
||||
- **Integration Tests**: Key user flows and state transitions
|
||||
- **UI Tests**: Critical user interactions
|
||||
|
||||
Use JaCoCo for coverage reporting:
|
||||
|
||||
```gradle
|
||||
apply plugin: 'jacoco'
|
||||
|
||||
jacoco {
|
||||
toolVersion = "0.8.10"
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user