Dev: add simple automated UI test setup (#1027)

## Description
This PR is mostly a proof-of-concept for setting up automated UI
testing. The tests use Roboelectric so they can run without an emulator.
There is also a sample instrumented test for running on emulators or
devices for future tests.

It adds two basic automated UI tests for adding a server. These are sort
integration tests because they use the actual implementations for the
view model and services like `ServerRepository` and `JellyfinServerDao`.

Also, moves the bulk of `MainActivity`'s compose code into a function
for future tests.

### Related issues
N/A

### Testing
Local testing

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-03-03 13:46:46 -05:00 committed by GitHub
parent 8935067c5a
commit 0004701296
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 990 additions and 163 deletions

View file

@ -0,0 +1,7 @@
package com.github.damontecres.wholphin.test
import androidx.activity.ComponentActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class TestActivity : ComponentActivity()

View file

@ -0,0 +1,238 @@
package com.github.damontecres.wholphin.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ScreensaverState
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.test.TestActivity
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchServerViewModel
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.operations.QuickConnectApi
import org.jellyfin.sdk.discovery.DiscoveryService
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import javax.inject.Inject
@HiltAndroidTest
@Config(application = HiltTestApplication::class, sdk = [34])
@RunWith(RobolectricTestRunner::class)
class BasicUiTests {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<TestActivity>()
@Inject
lateinit var jellyfin: Jellyfin
@Inject
lateinit var api: ApiClient
@Inject
lateinit var setupNavigationManager: SetupNavigationManager
lateinit var screensaverService: ScreensaverService
lateinit var switchServerViewModel: SwitchServerViewModel
val discovery: DiscoveryService = mockk()
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setup() {
Dispatchers.setMain(TestModule.testDispatcher)
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns TestModule.testDispatcher
every { Dispatchers.Default } returns TestModule.testDispatcher
hiltRule.inject()
screensaverService = mockk(relaxed = true)
every { screensaverService.state } returns
MutableStateFlow(
ScreensaverState(
false,
false,
false,
false,
),
)
every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api
every { jellyfin.discovery } returns discovery
}
@OptIn(ExperimentalCoroutinesApi::class)
@After
fun tearDown() {
Dispatchers.resetMain()
}
/**
* Tests successfully entering and submitting a server URL
*/
@OptIn(ExperimentalTestApi::class)
@Test
fun test_enter_server_url_success() {
coEvery { discovery.getRecommendedServers("localhost") } returns
listOf(
RecommendedServerInfo(
address = "localhost",
responseTime = 50,
score = RecommendedServerInfoScore.GREAT,
issues = emptyList(),
systemInfo =
Result.success(
PublicSystemInfo(
id = UUID.randomUUID().toString(),
startupWizardCompleted = true,
),
),
),
)
val quickConnectApi = mockk<QuickConnectApi>()
every { api.quickConnectApi } returns quickConnectApi
coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true)
composeTestRule.setContent {
WholphinTheme {
switchServerViewModel = hiltViewModel()
SwitchServerContent(
modifier = Modifier.fillMaxSize(),
viewModel = switchServerViewModel,
)
}
}
TestModule.testDispatcher.scheduler.advanceUntilIdle()
composeTestRule.onNodeWithText("Add Server").assertIsDisplayed()
composeTestRule.onNodeWithTag("add_server").performKeyInput {
pressKey(Key.DirectionDown) // TODO fix focus
}
composeTestRule
.onNodeWithTag("add_server")
.assertIsFocused()
.performClickEnter()
composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed()
composeTestRule.onNodeWithText("Enter server address").performClickEnter()
composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed()
composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost")
composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter()
TestModule.testDispatcher.scheduler.advanceUntilIdle()
switchServerViewModel.addServerState.value.let {
if (it is LoadingState.Error) throw it.exception ?: Exception(it.message)
}
// coVerify(exactly = 1) { discovery.getRecommendedServers("localhost") }
Assert.assertEquals(1, setupNavigationManager.backStack.size)
Assert.assertTrue(setupNavigationManager.backStack.last() is SetupDestination.UserList)
}
/**
* Tests entering and submitting a server URL that returns an error
*/
@OptIn(ExperimentalTestApi::class)
@Test
fun test_enter_server_url_error() {
coEvery { discovery.getRecommendedServers("localhost") } returns
listOf(
RecommendedServerInfo(
address = "localhost",
responseTime = 50,
score = RecommendedServerInfoScore.GREAT,
issues = emptyList(),
systemInfo =
Result.success(
PublicSystemInfo(
id = null, // Invalid
startupWizardCompleted = false,
),
),
),
)
val quickConnectApi = mockk<QuickConnectApi>()
every { api.quickConnectApi } returns quickConnectApi
coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true)
composeTestRule.setContent {
WholphinTheme {
switchServerViewModel = hiltViewModel()
SwitchServerContent(
modifier = Modifier.fillMaxSize(),
viewModel = switchServerViewModel,
)
}
}
TestModule.testDispatcher.scheduler.advanceUntilIdle()
composeTestRule.onNodeWithText("Add Server").assertIsDisplayed()
composeTestRule.onNodeWithTag("add_server").performKeyInput {
pressKey(Key.DirectionDown) // TODO fix focus
}
composeTestRule
.onNodeWithTag("add_server")
.assertIsFocused()
.performClickEnter()
composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed()
composeTestRule.onNodeWithText("Enter server address").performClickEnter()
composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed()
composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost")
composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter()
TestModule.testDispatcher.scheduler.advanceUntilIdle()
Assert.assertTrue(switchServerViewModel.addServerState.value is LoadingState.Error)
composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed()
}
}

View file

@ -0,0 +1,282 @@
package com.github.damontecres.wholphin.ui
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import androidx.room.Room
import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.AppDatabase
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.services.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.hilt.DatabaseModule
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher
import com.github.damontecres.wholphin.services.hilt.DeviceModule
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.test.StandardTestDispatcher
import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.JellyfinOptions
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
import javax.inject.Singleton
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DeviceModule::class, AppModule::class],
)
object TestModule {
val testDispatcher = StandardTestDispatcher()
@Provides
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
): DeviceInfo = DeviceInfo("test_device_id", "test_device")
@Provides
@Singleton
fun clientInfo(
@ApplicationContext context: Context,
): ClientInfo =
ClientInfo(
name = context.getString(R.string.app_name),
version = BuildConfig.VERSION_NAME,
)
@StandardOkHttpClient
@Provides
@Singleton
fun okHttpClient() =
OkHttpClient
.Builder()
.apply {
// TODO user agent, timeouts, logging, etc
}.build()
@AuthOkHttpClient
@Provides
@Singleton
fun authOkHttpClient(
serverRepository: ServerRepository,
@StandardOkHttpClient okHttpClient: OkHttpClient,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
) = okHttpClient
.newBuilder()
.addInterceptor {
val request = it.request()
val newRequest =
serverRepository.currentUser.value?.accessToken?.let { token ->
request
.newBuilder()
.addHeader(
"Authorization",
AuthorizationHeaderBuilder.buildHeader(
clientName = clientInfo.name,
clientVersion = clientInfo.version,
deviceId = deviceInfo.id,
deviceName = deviceInfo.name,
accessToken = token,
),
).build()
}
it.proceed(newRequest ?: request)
}.build()
@Provides
@Singleton
fun okHttpFactory(
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient))
@Provides
@Singleton
fun jellyfin(
okHttpFactory: CoroutineContextApiClientFactory,
@ApplicationContext context: Context,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
): Jellyfin {
val jellyfin: Jellyfin = mockk()
every { jellyfin.clientInfo } returns clientInfo
every { jellyfin.deviceInfo } returns deviceInfo
every { jellyfin.options } returns
JellyfinOptions(
context,
clientInfo,
deviceInfo,
okHttpFactory,
okHttpFactory,
Jellyfin.minimumVersion,
)
every { jellyfin.createApi(any()) } returns apiClient(jellyfin)
every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns apiClient(jellyfin)
return jellyfin
}
@Provides
@Singleton
fun apiClient(jellyfin: Jellyfin): ApiClient {
val api: ApiClient = mockk()
every { api.clientInfo } returns jellyfin.clientInfo!!
every { api.deviceInfo } returns jellyfin.deviceInfo!!
every { api.update(any(), any(), any(), any()) } returns Unit
return api
}
/**
* Implementation of [RememberTabManager] which remembers by server, user, & item
*/
@Provides
@Singleton
fun rememberTabManager(
serverRepository: ServerRepository,
appPreference: DataStore<AppPreferences>,
@IoCoroutineScope scope: CoroutineScope,
): RememberTabManager = mockk()
@Provides
@Singleton
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = testDispatcher
@Provides
@Singleton
@IoCoroutineScope
fun ioCoroutineScope(
@IoDispatcher dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@Provides
@Singleton
@DefaultDispatcher
fun defaultDispatcher(): CoroutineDispatcher = testDispatcher
@Provides
@Singleton
@DefaultCoroutineScope
fun defaultCoroutineScope(
@DefaultDispatcher dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@Provides
@Singleton
fun workManager(
@ApplicationContext context: Context,
): WorkManager = mockk()
@Provides
@Singleton
fun seerrApi(
@StandardOkHttpClient okHttpClient: OkHttpClient,
): SeerrApi = mockk()
}
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DatabaseModule::class],
)
object TestDatabaseModule {
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun database(
@ApplicationContext context: Context,
): AppDatabase =
Room
.inMemoryDatabaseBuilder(
context,
AppDatabase::class.java,
).addMigrations(Migrations.Migrate2to3)
.allowMainThreadQueries()
.setQueryCallback({ sqlQuery, args ->
Timber.v("sqlQuery=$sqlQuery, args=$args")
}, Dispatchers.IO.asExecutor())
.build()
@Provides
@Singleton
fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao()
@Provides
@Singleton
fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao()
@Provides
@Singleton
fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao()
@Provides
@Singleton
fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao()
@Provides
@Singleton
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
@Provides
@Singleton
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
@Provides
@Singleton
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
@Provides
@Singleton
fun userPreferencesDataStore(
@ApplicationContext context: Context,
userPreferencesSerializer: AppPreferencesSerializer,
): DataStore<AppPreferences> =
DataStoreFactory.create(
serializer = userPreferencesSerializer,
produceFile = { context.dataStoreFile("app_preferences.pb") },
corruptionHandler =
ReplaceFileCorruptionHandler(
produceNewData = { AppPreferences.getDefaultInstance() },
),
)
}
}

View file

@ -0,0 +1,16 @@
package com.github.damontecres.wholphin.ui
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import org.jellyfin.sdk.api.client.Response
@OptIn(ExperimentalTestApi::class)
fun SemanticsNodeInteraction.performClickEnter() =
performKeyInput {
pressKey(Key.DirectionCenter)
}
fun <T> successResponse(content: T) = Response(content, 200, emptyMap())