android-unit-test
Эксперт Android тестирования. Используй для JUnit, Espresso и Android test patterns.
When & Why to Use This Skill
This Claude skill serves as an expert assistant for Android application testing, providing comprehensive guidance on JUnit 5, Mockito, and Espresso. It helps developers implement robust testing strategies using the AAA (Arrange, Act, Assert) pattern, covering everything from unit tests for ViewModels and Repositories to complex UI automation and asynchronous Flow testing.
Use Cases
- Configuring testing dependencies and environments for modern Android projects using Kotlin and Gradle.
- Implementing unit tests for ViewModels and Repositories to verify business logic and state transitions.
- Testing asynchronous data streams and Kotlin Flows using the Turbine library and custom Coroutine rules.
- Creating automated UI tests with Espresso to validate user interactions, view visibility, and navigation.
- Writing parameterized tests and custom assertions to efficiently handle edge cases and improve test suite maintainability.
| name | android-unit-test |
|---|---|
| description | Эксперт Android тестирования. Используй для JUnit, Espresso и Android test patterns. |
Android Unit Testing Expert
Эксперт по тестированию Android приложений с использованием JUnit 5, Mockito и Kotlin.
Настройка зависимостей
// build.gradle (app)
dependencies {
// Unit testing
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.junit.jupiter:junit-jupiter:5.9.3'
testImplementation 'org.mockito:mockito-core:5.3.1'
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.0.0'
testImplementation 'io.mockk:mockk:1.13.5'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
testImplementation 'app.cash.turbine:turbine:1.0.0'
// 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'
}
AAA Pattern (Arrange, Act, Assert)
class UserRepositoryTest {
@Test
fun `getUser returns user when found`() {
// Arrange
val userId = "user_123"
val expectedUser = User(userId, "John Doe", "john@example.com")
val mockDataSource = mock<UserDataSource> {
on { getUser(userId) } doReturn expectedUser
}
val repository = UserRepository(mockDataSource)
// Act
val result = repository.getUser(userId)
// Assert
assertEquals(expectedUser, result)
verify(mockDataSource).getUser(userId)
}
}
ViewModel Testing
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: UserViewModel
private lateinit var repository: UserRepository
@Before
fun setup() {
repository = mock()
viewModel = UserViewModel(repository)
}
@Test
fun `loadUser updates state to success when repository returns user`() = runTest {
// Arrange
val user = User("1", "John", "john@example.com")
whenever(repository.getUser("1")).thenReturn(Result.success(user))
// Act
viewModel.loadUser("1")
// Assert
val state = viewModel.uiState.value
assertTrue(state is UserUiState.Success)
assertEquals(user, (state as UserUiState.Success).user)
}
@Test
fun `loadUser updates state to error when repository fails`() = runTest {
// Arrange
val exception = IOException("Network error")
whenever(repository.getUser("1")).thenReturn(Result.failure(exception))
// Act
viewModel.loadUser("1")
// Assert
val state = viewModel.uiState.value
assertTrue(state is UserUiState.Error)
assertEquals("Network error", (state as UserUiState.Error).message)
}
}
MainDispatcherRule
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
Repository Testing
class UserRepositoryTest {
private lateinit var repository: UserRepository
private lateinit var remoteDataSource: UserRemoteDataSource
private lateinit var localDataSource: UserLocalDataSource
@Before
fun setup() {
remoteDataSource = mock()
localDataSource = mock()
repository = UserRepository(remoteDataSource, localDataSource)
}
@Test
fun `getUsers returns cached data when available`() = runTest {
// Arrange
val cachedUsers = listOf(User("1", "John", "john@example.com"))
whenever(localDataSource.getUsers()).thenReturn(cachedUsers)
// Act
val result = repository.getUsers()
// Assert
assertEquals(cachedUsers, result)
verify(localDataSource).getUsers()
verifyNoInteractions(remoteDataSource)
}
@Test
fun `getUsers fetches from remote when cache is empty`() = runTest {
// Arrange
val remoteUsers = listOf(User("1", "John", "john@example.com"))
whenever(localDataSource.getUsers()).thenReturn(emptyList())
whenever(remoteDataSource.getUsers()).thenReturn(remoteUsers)
// Act
val result = repository.getUsers()
// Assert
assertEquals(remoteUsers, result)
verify(localDataSource).saveUsers(remoteUsers)
}
}
Flow Testing с Turbine
@OptIn(ExperimentalCoroutinesApi::class)
class FlowTestExample {
@Test
fun `userFlow emits expected values`() = runTest {
val repository = UserRepository()
repository.userFlow.test {
// Initial state
assertEquals(UserState.Loading, awaitItem())
// After loading
assertEquals(UserState.Success(user), awaitItem())
// Cancel and ensure no more emissions
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `searchFlow debounces and emits results`() = runTest {
val viewModel = SearchViewModel()
viewModel.searchResults.test {
viewModel.onSearchQueryChanged("test")
advanceTimeBy(300) // Debounce time
val result = awaitItem()
assertTrue(result.isNotEmpty())
cancelAndIgnoreRemainingEvents()
}
}
}
Параметризованные тесты
class CalculatorTest {
@ParameterizedTest
@CsvSource(
"1, 1, 2",
"2, 3, 5",
"10, -5, 5",
"0, 0, 0"
)
fun `add returns correct sum`(a: Int, b: Int, expected: Int) {
val calculator = Calculator()
assertEquals(expected, calculator.add(a, b))
}
@ParameterizedTest
@MethodSource("divisionTestData")
fun `divide handles edge cases`(a: Int, b: Int, expected: Result<Int>) {
val calculator = Calculator()
assertEquals(expected, calculator.divide(a, b))
}
companion object {
@JvmStatic
fun divisionTestData() = listOf(
Arguments.of(10, 2, Result.success(5)),
Arguments.of(9, 3, Result.success(3)),
Arguments.of(5, 0, Result.failure<Int>(ArithmeticException()))
)
}
}
MockK для Kotlin
class UserServiceTest {
@MockK
private lateinit var api: UserApi
@MockK
private lateinit var cache: UserCache
private lateinit var service: UserService
@Before
fun setup() {
MockKAnnotations.init(this)
service = UserService(api, cache)
}
@Test
fun `getUser uses coEvery for suspend functions`() = runTest {
// Arrange
val user = User("1", "John", "john@example.com")
coEvery { api.getUser("1") } returns user
coEvery { cache.save(any()) } just Runs
// Act
val result = service.getUser("1")
// Assert
assertEquals(user, result)
coVerify { cache.save(user) }
}
@Test
fun `verify call order`() = runTest {
val user = User("1", "John", "john@example.com")
coEvery { api.getUser("1") } returns user
coEvery { cache.save(any()) } just Runs
service.getUser("1")
coVerifyOrder {
api.getUser("1")
cache.save(user)
}
}
}
Test Data Factories
object UserFactory {
fun create(
id: String = "user_${UUID.randomUUID()}",
name: String = "Test User",
email: String = "test@example.com",
isActive: Boolean = true
) = User(id, name, email, isActive)
fun createList(count: Int = 5) = (1..count).map {
create(id = "user_$it", name = "User $it")
}
}
// Usage in tests
@Test
fun `test with factory`() {
val user = UserFactory.create(name = "Custom Name")
val users = UserFactory.createList(10)
}
Custom Assertions
fun <T> Result<T>.shouldBeSuccess(): T {
assertTrue(this.isSuccess, "Expected success but was failure: ${this.exceptionOrNull()}")
return this.getOrThrow()
}
fun <T> Result<T>.shouldBeFailure(): Throwable {
assertTrue(this.isFailure, "Expected failure but was success: ${this.getOrNull()}")
return this.exceptionOrNull()!!
}
// Usage
@Test
fun `repository returns success`() {
val result = repository.getUser("1")
val user = result.shouldBeSuccess()
assertEquals("John", user.name)
}
Espresso UI Testing
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Test
fun loginButton_isDisabled_whenEmailIsEmpty() {
onView(withId(R.id.emailInput)).perform(clearText())
onView(withId(R.id.passwordInput)).perform(typeText("password123"))
onView(withId(R.id.loginButton)).check(matches(not(isEnabled())))
}
@Test
fun successfulLogin_navigatesToHome() {
onView(withId(R.id.emailInput)).perform(typeText("test@example.com"))
onView(withId(R.id.passwordInput)).perform(typeText("password123"))
onView(withId(R.id.loginButton)).perform(click())
onView(withId(R.id.homeScreen)).check(matches(isDisplayed()))
}
}
Лучшие практики
- Один тест = одно поведение — каждый тест проверяет одну вещь
- Описательные имена — используйте backticks для читаемых названий
- AAA паттерн — Arrange, Act, Assert в каждом тесте
- Изолированность — тесты не зависят друг от друга
- Быстрота — unit тесты должны выполняться мгновенно
- Тестируйте edge cases — null, пустые списки, ошибки