Testing trong Android
1. Giới thiệu
Testing đảm bảo code hoạt động đúng và giúp refactor an toàn.
2. Testing Pyramid
┌─────────────┐
│ E2E/UI │ ← Slow, expensive
├─────────────┤
│ Integration │
├─────────────┤
│ Unit Tests │ ← Fast, cheap
└─────────────┘3. Setup
// build.gradle.kts
dependencies {
// Unit tests
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
testImplementation("io.mockk:mockk:1.13.10")
// Android/Instrumentation tests
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}4. Unit Tests
Test ViewModel
class UserViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var repository: UserRepository
private lateinit var viewModel: UserViewModel
@Before
fun setup() {
repository = mockk()
viewModel = UserViewModel(repository)
}
@Test
fun `loadUsers success updates state`() = runTest {
// Given
val users = listOf(User(1, "Alice", "alice@email.com"))
coEvery { repository.getUsers() } returns Result.success(users)
// When
viewModel.loadUsers()
// Then
assertEquals(users, viewModel.uiState.value.users)
assertEquals(false, viewModel.uiState.value.isLoading)
}
@Test
fun `loadUsers error shows error message`() = runTest {
// Given
coEvery { repository.getUsers() } returns Result.failure(Exception("Network error"))
// When
viewModel.loadUsers()
// Then
assertEquals("Network error", viewModel.uiState.value.error)
}
}
// MainDispatcherRule for testing
class MainDispatcherRule : TestWatcher() {
val dispatcher = StandardTestDispatcher()
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}Test UseCase
class GetUsersUseCaseTest {
private val repository: UserRepository = mockk()
private val useCase = GetUsersUseCase(repository)
@Test
fun `invoke returns users from repository`() = runTest {
val expected = listOf(User(1, "Alice"))
coEvery { repository.getUsers() } returns Result.success(expected)
val result = useCase()
assertTrue(result.isSuccess)
assertEquals(expected, result.getOrNull())
}
}5. Compose UI Tests
class UserListScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun displayUserList() {
val users = listOf(
User(1, "Alice", "alice@email.com"),
User(2, "Bob", "bob@email.com")
)
composeTestRule.setContent {
UserListContent(users = users, onUserClick = {})
}
composeTestRule.onNodeWithText("Alice").assertExists()
composeTestRule.onNodeWithText("Bob").assertExists()
}
@Test
fun clickUser_triggersCallback() {
var clickedId: Int? = null
composeTestRule.setContent {
UserListContent(
users = listOf(User(1, "Alice")),
onUserClick = { clickedId = it }
)
}
composeTestRule.onNodeWithText("Alice").performClick()
assertEquals(1, clickedId)
}
@Test
fun searchField_filtersUsers() {
composeTestRule.setContent {
UserSearchScreen()
}
composeTestRule.onNodeWithTag("search_field")
.performTextInput("alice")
composeTestRule.onNodeWithText("Alice").assertExists()
composeTestRule.onNodeWithText("Bob").assertDoesNotExist()
}
}6. Test Finders
// By text
onNodeWithText("Hello")
onAllNodesWithText("Item")
// By content description
onNodeWithContentDescription("Back")
// By testTag
onNodeWithTag("button_submit")
// By semantic properties
onNode(hasText("Hello") and hasClickAction())
// All nodes
onAllNodes(hasText("Item")).assertCountEquals(5)7. Test Actions
// Click
onNodeWithText("Submit").performClick()
// Type text
onNodeWithTag("input").performTextInput("Hello")
onNodeWithTag("input").performTextClearance()
// Scroll
onNodeWithTag("list").performScrollToIndex(10)
// Swipe
onNodeWithTag("card").performTouchInput {
swipeLeft()
}8. Test Assertions
onNodeWithText("Hello").assertExists()
onNodeWithText("Hello").assertDoesNotExist()
onNodeWithTag("button").assertIsEnabled()
onNodeWithTag("button").assertIsNotEnabled()
onNodeWithTag("checkbox").assertIsSelected()
onNodeWithText("Title").assertIsDisplayed()9. Testing với Hilt
@HiltAndroidTest
class UserScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Inject
lateinit var repository: UserRepository
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun userScreenLoadsData() {
composeTestRule.onNodeWithTag("user_list").assertExists()
}
}
// Fake module for testing
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
object FakeRepositoryModule {
@Provides
@Singleton
fun provideFakeRepository(): UserRepository = FakeUserRepository()
}10. Testing StateFlow
@Test
fun testStateFlow() = runTest {
val viewModel = UserViewModel(fakeRepository)
viewModel.uiState.test {
assertEquals(UiState.Loading, awaitItem())
viewModel.loadUsers()
val successState = awaitItem()
assertTrue(successState is UiState.Success)
}
}11. Screenshot Testing
@Test
fun userCard_matchesScreenshot() {
composeTestRule.setContent {
UserCard(user = User(1, "Alice", "alice@email.com"))
}
composeTestRule.onNodeWithTag("user_card")
.captureToImage()
.assertAgainstGolden(rule, "user_card")
}📝 Tóm tắt
| Test Type | Location | Speed |
|---|---|---|
| Unit | src/test | Fast |
| Integration | src/test or src/androidTest | Medium |
| UI/Compose | src/androidTest | Slow |
| Framework | Purpose |
|---|---|
| JUnit | Test runner |
| MockK | Mocking |
| Turbine | Flow testing |
| Compose Test | UI testing |
Last updated on