SolPan follows comprehensive testing practices ensuring code quality, reliability, and maintainability. This document outlines the testing strategy, frameworks, and best practices used throughout the project.
▲
╱ ╲
╱ ╲ E2E Tests (5%)
╱ ╲ - User journeys
╱───────╲- Critical paths
╱ ╲
╱ ╱ ╱ ╱ ╱ ╱ ╲ Integration Tests (15%)
╱ ╱ ╱ ╱ ╱ ╱ ╲ - Component interactions
╱ ╱ ╱ ╱ ╱ ╱ ╲- API contracts
╱───────────────────╲
╱ Unit Tests (80%) ╲
╱ - Business logic ╲
╱ - Utilities ╲
╱ - ViewModels ╲
Test Files: 6 Test Cases: 34+ Coverage Target: 85%+ for domain logic
Test Files Location:
app/src/test/java/app/mobilemobile/solpan/
├── SolPanViewModelTest.kt (Core ViewModel)
├── TutorialFlowTest.kt (Tutorial logic)
├── model/AlignmentStateTest.kt (Data model)
├── util/FormattingExtensionsTest.kt (String utilities)
└── solar/SolarCalculatorTest.kt (Solar calculations)
@RunWith(RobolectricTestRunner::class)
class SolPanViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private lateinit var viewModel: SolPanViewModel
private val fakeLocationRepository = FakeLocationRepository()
private val fakePreferences = FakeUserPreferencesRepository()
private val fakeAnalytics = FakeAnalyticsTracker()
@Before
fun setup() {
viewModel = SolPanViewModel(
mode = TiltMode.REALTIME,
userPreferencesRepository = fakePreferences,
locationRepository = fakeLocationRepository,
analyticsTracker = fakeAnalytics,
)
}
@Test
fun optimalParametersUpdatesWhenLocationChanges() = runTest {
// Arrange
val testLocation = LocationData(51.5074, -0.1278, 0.0)
// Act
fakeLocationRepository.setLocation(testLocation)
advanceUntilIdle()
// Assert
val parameters = viewModel.optimalPanelParameters.first()
assertNotNull(parameters)
assertEquals(51.5074, parameters!!.latitude, 0.0001)
}
@Test
fun optimalParametersNullWhenLocationDenied() = runTest {
// Arrange: No location provided
// Act
advanceUntilIdle()
// Assert
val parameters = viewModel.optimalPanelParameters.first()
assertNull(parameters)
}
}✅ Do:
// Use descriptive test names
@Test
fun `optimalParameters updates when location changes`()
// Test one behavior per test
@Test
fun `viewModel handles null location gracefully`()
// Use fake implementations for dependencies
val fakeLocationRepository = FakeLocationRepository()
// Test the public API contract
assertEquals(expected, viewModel.optimalPanelParameters.first())
// Use coroutine testing utilities
runTest { ... }
advanceUntilIdle()❌ Don't:
// Avoid vague names
@Test
fun testViewModel()
// Don't test multiple behaviors
@Test
fun `location and orientation and tilt mode all update`()
// Don't use mocking when fakes work
val mockRepository = mock<LocationRepository>()
// Don't test private implementation details
viewModel.internalCalculatePosition()Not fully implemented yet. Future roadmap includes:
@RunWith(AndroidTestRunner::class)
class SolPanScreenIntegrationTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun navigationBetweenModesPreservesState() {
// Create ViewModel
val viewModel = SolPanViewModel(...)
// Render with test harness
composeTestRule.setContent {
SolPanTheme {
SolPanApp(viewModel)
}
}
// Click on different mode
composeTestRule.onNodeWithTag("SUMMER_MODE_BUTTON").performClick()
// Verify navigation
composeTestRule.onNodeWithText("Summer Mode Selected").assertExists()
}
}Current Status: Tests compile but framework discovery issue
Location: app/src/screenshotTest/kotlin/
@Composable
fun CardScreenshotTest() {
// Preview for screenshot testing
SolPanTheme {
Surface {
AzimuthAwareBubbleLevel(
currentPitch = 45.0,
currentRoll = 10.0,
targetPitch = 35.0,
currentAzimuth = 120.0,
targetAzimuth = 135.0,
)
}
}
}Update Reference Images:
./gradlew :app:updateDebugScreenshotTestValidate Changes:
./gradlew :app:validateDebugScreenshotTest// In app/src/test/
class FakeMagneticDeclinationProvider : MagneticDeclinationProvider {
override fun getDeclination(latitude: Double, longitude: Double, altitude: Double): Float? {
return 10f // Fixed value for testing
}
}
class FakeLocationRepository : LocationRepository {
private var testLocation: LocationData? = null
override val locationFlow: Flow<LocationData?> = flow {
emit(testLocation)
}
fun setLocation(location: LocationData) {
testLocation = location
}
}
class FakeUserPreferencesRepository : UserPreferencesRepository {
override val userPreferencesFlow: Flow<UserPreferences> = flow {
emit(UserPreferences(selectedTiltMode = TiltMode.REALTIME))
}
}// In test files
fun createTestLocationData(
latitude: Double = 51.5074,
longitude: Double = -0.1278,
altitude: Double = 0.0,
): LocationData {
return LocationData(latitude, longitude, altitude)
}
fun createTestOptimalPanelParameters(
azimuth: Double = 180.0,
pitch: Double = 51.0,
): OptimalPanelParameters {
return OptimalPanelParameters(azimuth, pitch)
}./gradlew :app:testDebugUnitTest./gradlew :app:testDebugUnitTest --tests "app.mobilemobile.solpan.SolPanViewModelTest"./gradlew :app:testDebugUnitTest --tests "..." --coverage
# Report: build/coverage/index.html./gradlew :app:testDebugUnitTest --watch
# Re-runs on every source change| Tool | Version | Purpose |
|---|---|---|
| JUnit | 4.13.2 | Unit testing framework |
| Robolectric | Implicit | Android framework simulation |
| Coroutines Test | 1.10.2 | Coroutine testing utilities |
| Truth | Latest | Fluent assertions |
- Turbine (Flow testing):
app.cash.turbine:turbine:1.x - MockK (Kotlin mocking):
io.mockk:mockk:1.x - Kotest (More assertions):
io.kotest:kotest-*:5.x
Future implementation:
@Test
fun allUIElementsHaveContentDescriptions() {
composeTestRule.setContent {
SolPanApp()
}
// Verify no unlabeled elements
composeTestRule.onRoot().printToLog("A11Y_CHECK")
// Parse output for missing descriptions
}- Enable TalkBack (Settings → Accessibility → TalkBack)
- Navigate app with gestures
- Verify all information is accessible
- Test text scaling (up to 200%)
- Verify color contrast (WCAG AAA: 7:1 ratio)
Measure solar calculation:
@Test
fun solarCalculationPerformance() {
val startNs = System.nanoTime()
repeat(1000) {
SolarCalculator.getSolarPosition(
latitude = 51.5074,
longitude = -0.1278,
date = Calendar.getInstance()
)
}
val elapsedNs = System.nanoTime() - startNs
val avgMs = elapsedNs / 1_000_000 / 1000
// Expect < 5ms per calculation
assertTrue(avgMs < 5)
}Future: Jetpack Benchmark:
dependencies {
androidTestImplementation("androidx.benchmark:benchmark-junit4:1.2.0")
}- name: Run tests
run: ./gradlew :app:testDebugUnitTest
- name: Generate coverage report
run: ./gradlew :app:testDebugUnitTest --coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3Future: Multi-device testing
strategy:
matrix:
api-level: [26, 30, 33, 37]
arch: [x86, x86_64]
include:
- api-level: 37
arch: arm64-v8aTotal Test Cases: 34
Pass Rate: 100%
Execution Time: ~2 seconds
Code Coverage: TBD (add coverage plugin)
| Area | Target | Current |
|---|---|---|
| ViewModel | 90% | TBD |
| Domain Logic | 85% | TBD |
| Utilities | 80% | TBD |
| UI Components | 60% | TBD (screenshots) |
- Avoid Timing Dependencies: Use
advanceUntilIdle()instead ofThread.sleep() - Isolate External Services: Mock Fused Location Provider, Firebase, etc.
- Deterministic Randomness: Use fixed seeds for random data generation
- Resource Cleanup: Use
@Before/@Afterfor setup/teardown
// ❌ Flaky: Timing dependent
@Test
fun locationUpdatesUI() {
Thread.sleep(100)
assertEquals(expectedLocation, uiState.location)
}
// ✅ Reliable: Uses coroutine testing utils
@Test
fun locationUpdatesUI() = runTest {
fakeLocationRepository.emitLocation(testLocation)
advanceUntilIdle() // Wait for all pending work
assertEquals(expectedLocation, uiState.location)
}"Test the contract, not the implementation"
- Focus on public API behavior
- Avoid testing private methods
- Test error cases and edge cases
- Use real dependencies where possible (not all mocks)
"Every feature starts with a test"
- Write test first (TDD)
- Then implement feature
- Then refactor with confidence
"Tests are documentation"
- Descriptive test names explain expected behavior
- Test code shows how to use public API
- Comments explain "why", not "what"
Priority order:
- ✅ Unit tests (complete for core logic)
- ⏳ Integration tests (ViewModel + Repository interactions)
- ⏳ UI tests (Navigation, permission flow)
- ⏳ Screenshot regression tests (Visual consistency)
- ⏳ Performance benchmarks (TTID, calculation speed)
- ⏳ Accessibility tests (Automated a11y checks)
- ⏳ E2E tests (Critical user journeys)
- Android Testing Documentation
- Jetpack Compose Testing
- Kotlin Coroutines Testing
- Unit Testing Best Practices
- Testing Kotlin Code
SolPan implements comprehensive testing:
- 34+ unit tests covering core logic
- 100% pass rate with reliable tests
- ~2 second execution time
- Dependency injection for testability
- Fake implementations for isolation
- Ready for expansion with integration/E2E tests
Tests ensure reliability, enable refactoring with confidence, and serve as living documentation of expected behavior.