Skip to main content
The project includes comprehensive testing infrastructure for unit tests, instrumented tests, and Compose UI tests across all modules.

Testing setup

Test dependencies

The project uses industry-standard testing libraries defined in gradle/libs.versions.toml:99:
testImplementation(libs.junit)                           // JUnit 4.13.2

Test runner configuration

The test instrumentation runner is configured in buildSrc/src/main/java/es/mobiledev/buildsrc/AppConfig.kt:13:
const val testRunner = "androidx.test.runner.AndroidJUnitRunner"
This runner is applied to all modules through the centralized AppConfig.

Unit tests

Unit tests run on the JVM and don’t require an Android device. They execute quickly and are ideal for testing business logic.

Writing unit tests

Unit tests are located in src/test/java/. Here’s the example from app/src/test/java/es/mobiledev/cpt/ExampleUnitTest.kt:1:
package es.mobiledev.cpt

import org.junit.Assert.assertEquals
import org.junit.Test

class ExampleUnitTest {
    @Test
    fun addition_isCorrect() {
        assertEquals(4, 2 + 2)
    }
}

Running unit tests

./gradlew test

Test reports

After running tests, view HTML reports at:
app/build/reports/tests/testDebugUnitTest/index.html
Unit tests are automatically run when executing ./gradlew check before builds.

Instrumented tests

Instrumented tests run on an Android device or emulator. They have access to Android framework APIs and are ideal for testing UI and platform-specific functionality.

Writing instrumented tests

Instrumented tests are located in src/androidTest/java/. Here’s the example from app/src/androidTest/java/es/mobiledev/cpt/ExampleInstrumentedTest.kt:1:
package es.mobiledev.cpt

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
    @Test
    fun useAppContext() {
        // Context of the app under test.
        val appContext = InstrumentationRegistry.getInstrumentation().targetContext
        assertEquals("es.mobiledev.cpt", appContext.packageName)
    }
}

Running instrumented tests

1

Connect device or start emulator

Ensure an Android device is connected or an emulator is running:
adb devices
2

Run tests

Execute instrumented tests:
./gradlew connectedAndroidTest
3

View results

Check test reports at:
app/build/reports/androidTests/connected/index.html
Instrumented tests require an API level 26+ device or emulator, matching the project’s minSdk = 26.

Testing specific devices

Run tests on a specific device:
adb -s DEVICE_SERIAL_NUMBER install app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
adb -s DEVICE_SERIAL_NUMBER shell am instrument -w es.mobiledev.cpt.test/androidx.test.runner.AndroidJUnitRunner

Compose UI testing

The project includes Compose UI testing dependencies for testing composable functions and UI interactions.

Setup for Compose tests

Add the Compose test rule to your instrumented test:
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class HomeScreenTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun myTest() {
        composeTestRule.setContent {
            // Your composable here
        }
        
        // Perform assertions
        composeTestRule.onNodeWithText("Hello").assertExists()
    }
}

Compose test patterns

// By text
composeTestRule.onNodeWithText("Click me")

// By content description
composeTestRule.onNodeWithContentDescription("Profile")

// By test tag
composeTestRule.onNodeWithTag("LoginButton")

// Multiple nodes
composeTestRule.onAllNodesWithText("Item")
// Click
composeTestRule.onNodeWithText("Submit").performClick()

// Text input
composeTestRule.onNodeWithTag("EmailField")
    .performTextInput("[email protected]")

// Scroll
composeTestRule.onNodeWithTag("LazyColumn")
    .performScrollToIndex(10)

// Gestures
composeTestRule.onNodeWithTag("Canvas")
    .performTouchInput { swipeLeft() }
// Existence
composeTestRule.onNodeWithText("Welcome").assertExists()

// Display
composeTestRule.onNodeWithTag("LoadingSpinner")
    .assertIsDisplayed()

// Enabled state
composeTestRule.onNodeWithText("Submit")
    .assertIsEnabled()

// Text content
composeTestRule.onNodeWithTag("Title")
    .assertTextEquals("Hello World")

Testing with Hilt

For testing components that use Hilt dependency injection:
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test

@HiltAndroidTest
class HiltInstrumentedTest {
    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @Before
    fun init() {
        hiltRule.inject()
    }

    @Test
    fun testWithInjection() {
        // Your test here
    }
}
Add @HiltAndroidTest annotation and inject Hilt before running Compose tests that require dependency injection.

Test organization

Module structure

Each module follows the standard Android test structure:
module/
├── src/
│   ├── main/                    # Production code
│   ├── test/                    # Unit tests (JVM)
│   │   └── java/
│   └── androidTest/             # Instrumented tests (Android)
│       └── java/

Naming conventions

Follow these naming conventions for test classes:
  • Unit tests: [ClassName]Test.kt (e.g., ArticleRepositoryTest.kt)
  • Instrumented tests: [ClassName]AndroidTest.kt or [Feature]Test.kt
  • Compose UI tests: [Screen]ScreenTest.kt (e.g., HomeScreenTest.kt)

Test method naming

Use descriptive names that explain the test scenario:
@Test
fun `when user clicks submit button, form is validated`() {
    // Test implementation
}

@Test
fun getUserById_returnsUser_whenUserExists() {
    // Test implementation
}

Testing best practices

Use test fixtures

Create reusable test data:
object TestFixtures {
    fun createMockArticle(id: Int = 1) = Article(
        id = id,
        title = "Test Article $id",
        content = "Test content"
    )
}

Isolate dependencies

Use test doubles for external dependencies:
class FakeArticleRepository : ArticleRepository {
    private val articles = mutableListOf<Article>()
    
    override suspend fun getArticles() = articles.toList()
    
    fun addArticle(article: Article) {
        articles.add(article)
    }
}

Test ViewModels

Test ViewModel logic with coroutine test dispatchers:
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test

class ArticleViewModelTest {
    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun `loadArticles updates state correctly`() = runTest {
        val viewModel = ArticleViewModel(fakeRepository)
        
        viewModel.loadArticles()
        
        assertEquals(LoadingState.Success, viewModel.state.value)
    }
}

Continuous integration

Running all tests

Run all tests (unit + instrumented) in CI:
./gradlew check connectedAndroidTest

Test task execution

The check task runs:
  • All unit tests
  • Lint checks
  • KtLint checks (see Code style)
The pre-build hook ensures code quality checks run before compilation, catching issues early.

Debugging tests

Enable test logging

Add to module’s build.gradle.kts:
tasks.withType<Test> {
    testLogging {
        events("passed", "skipped", "failed")
        showStandardStreams = true
    }
}

Run single test with debugging

./gradlew :app:testDebugUnitTest --tests="*.ExampleUnitTest" --debug-jvm
Then attach a debugger to port 5005.

Next steps

Code style

Configure KtLint and maintain consistent code formatting

Building

Learn about build configuration and Gradle tasks

Build docs developers (and LLMs) love