UI Tests (Instrumentation)
This page covers the UI testing infrastructure for the Visla GPS Android app. For the overall testing strategy and design decisions behind these choices, see the Testing Strategy overview.
Hilt Test Runnerβ
The app uses a custom HiltTestRunner configured in build.gradle.kts:
// build.gradle.kts
testInstrumentationRunner = "com.visla.vislagps.HiltTestRunner"
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
This replaces the real Application with HiltTestApplication, enabling @TestInstallIn module overrides.
Test DI Module Overridesβ
Two production modules are replaced in tests:
TestTokenModule β replaces real token storage with an in-memory fake:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [TokenManagerModule::class]
)
object TestTokenModule {
@Provides
@Singleton
fun provideFakeTokenManager(@ApplicationContext context: Context): TokenManager =
FakeTokenManager(context)
}
FakeTokenManager stores tokens in memory and exposes setLoggedIn() / reset() for test control. A static startLoggedIn flag controls whether tests begin in logged-in or logged-out state.
TestBaseUrlModule β replaces production API URL with MockWebServer URL:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [BaseUrlModule::class]
)
object TestBaseUrlModule {
@Provides
@Singleton
fun provideBaseUrlProvider(): BaseUrlProvider = TestBaseUrlProvider
}
object TestBaseUrlProvider : BaseUrlProvider {
var url: String = "http://localhost:8080/"
override fun getBaseUrl(): String = url
}
MockWebServerβ
UI tests use OkHttp's MockWebServer via a custom JUnit TestRule:
class MockServerRule : TestRule {
lateinit var server: MockWebServer
override fun apply(base: Statement, description: Description) = object : Statement() {
override fun evaluate() {
server = MockWebServer()
server.dispatcher = createDispatcher()
server.start()
// Point the app's API calls to the mock server
TestBaseUrlProvider.url = server.url("/").toString()
try { base.evaluate() } finally { server.shutdown() }
}
}
}
Predefined responses are defined in MockResponses:
object MockResponses {
fun loginSuccess() = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""
{
"accessToken": "test-access-token-123",
"refreshToken": "test-refresh-token-456",
"expiresIn": 3600,
"user": { "id": 1, "name": "Test User", ... }
}
""")
fun loginInvalid() = MockResponse().setResponseCode(401).setBody("""{"error": "Invalid credentials"}""")
fun registerSuccess() = MockResponse().setResponseCode(201).setBody(/* ... */)
}
Robot Patternβ
UI tests use the Robot pattern for readable, maintainable test code. Each screen has a Robot class extending BaseRobot:
open class BaseRobot(protected val composeRule: ComposeTestRule) {
fun assertTextVisible(text: String) { /* ... */ }
fun clickTag(tag: String) { /* ... */ }
fun inputTextByTag(tag: String, text: String) { /* ... */ }
fun waitUntilTagExists(tag: String, timeoutMillis: Long = 5000) { /* ... */ }
}
class LoginRobot(composeRule: ComposeTestRule) : BaseRobot(composeRule) {
fun enterEmail(email: String) = apply { inputTextByTag("login_email_field", email) }
fun enterPassword(password: String) = apply { inputTextByTag("login_password_field", password) }
fun clickSignIn() = apply { clickTag("login_sign_in_button") }
infix fun verify(block: LoginVerification.() -> Unit): LoginVerification =
LoginVerification(composeRule).apply(block)
}
// DSL entry point
fun ComposeTestRule.loginRobot(block: LoginRobot.() -> Unit): LoginRobot =
LoginRobot(this).apply(block)
Tests read as fluent DSLs:
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class LoginTest {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) val mockServer = MockServerRule()
@get:Rule(order = 2) val composeRule = createAndroidComposeRule<MainActivity>()
@Test
fun test05_login_withValidCredentials_thenLogout() {
composeRule.loginRobot {
enterEmail("test@example.com")
enterPassword("password123")
clickSignIn()
} verify {
isOnMainScreen()
}
composeRule.mainRobot { navigateToSettings() }
composeRule.settingsRobot {
clickLogout()
confirmLogout()
}
composeRule.loginRobot { assertOnLoginScreen() }
}
}
Available robots: LoginRobot, RegisterRobot, MainRobot, SettingsRobot.
Permission Handlingβ
PermissionHelper and TestUtils handle system permission dialogs using UiAutomator:
object PermissionHelper {
fun grantEssentialPermissions() {
device.executeShellCommand("pm grant $packageName android.permission.ACCESS_FINE_LOCATION")
device.executeShellCommand("pm grant $packageName android.permission.ACCESS_COARSE_LOCATION")
}
fun dismissNotificationPermissionIfNeeded() {
val button = device.wait(Until.findObject(By.text("Allow").clickable(true)), 2000)
button?.click()
}
}
Permissions are granted once in @BeforeClass, and notification dialogs are dismissed in @Before.