Skip to main content

Testing Strategy

TecMeli implements a comprehensive testing strategy with unit tests and integration tests to ensure code reliability and maintainability.

Test Coverage

The project includes tests for:
  • Repository layer - Data fetching and transformation
  • Domain layer - Use case business logic
  • UI layer - ViewModel state management
  • Network layer - OAuth authentication flow

Testing Dependencies

The project uses modern Android testing libraries configured in app/build.gradle.kts:
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.mockwebserver)
LibraryVersionPurpose
JUnit4.13.2Test framework
MockK1.13.8Kotlin-first mocking library
MockWebServer4.11.0HTTP server for integration tests
Coroutines Test1.7.3Testing coroutines and flows

Running Tests

Using Android Studio

1

Run All Tests

Right-click on the test directory and select:Run ‘Tests in ‘tecmeli.test”
2

Run a Single Test Class

Open a test file and click the green arrow icon next to the class name
3

Run a Single Test Method

Click the green arrow icon next to the test method

Using Command Line

./gradlew test

Test Examples

Repository Tests with MockK

Testing repository implementations with mocked API calls:
ProductRepositoryImplTest.kt
package com.alcalist.tecmeli.data.repository

import com.alcalist.tecmeli.data.remote.api.MeliApi
import com.alcalist.tecmeli.data.remote.dto.ResultsDto
import com.alcalist.tecmeli.data.remote.dto.SearchResponseDto
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Test
import retrofit2.Response

class ProductRepositoryImplTest {

    private val api: MeliApi = mockk()
    private val repository = ProductRepositoryImpl(api)

    @Test
    fun `searchProducts returns success when api returns successful response`() = runTest {
        // Arrange
        val dto = ResultsDto(
            id = "1",
            catalogProductId = "cp1",
            domainId = "d1",
            name = "Title",
            attributes = arrayListOf(),
            shortDescription = null,
            pictures = arrayListOf(),
            lastUpdated = null
        )
        val responseDto = SearchResponseDto(results = arrayListOf(dto))
        coEvery { 
            api.searchProducts(
                status = any(),
                siteId = any(),
                query = "term"
            ) 
        } returns Response.success(responseDto)

        // Act
        val result = repository.searchProducts("term")

        // Assert
        assertTrue(result.isSuccess)
        val products = result.getOrNull()
        assertNotNull(products)
        assertEquals(1, products?.size)
        assertEquals("1", products?.first()?.id)
    }

    @Test
    fun `searchProducts returns failure when api returns error`() = runTest {
        // Arrange
        val errorResponse: Response<SearchResponseDto> =
            Response.error(401, "".toResponseBody(null))
        
        coEvery { 
            api.searchProducts(
                status = any(),
                siteId = any(),
                query = "term"
            ) 
        } returns errorResponse

        // Act
        val result = repository.searchProducts("term")

        // Assert
        assertTrue(result.isFailure)
    }
}
Repository tests verify the data layer correctly transforms DTOs to domain models and handles API errors.

ViewModel Tests with Coroutines

Testing ViewModels with proper coroutine test dispatchers:
HomeViewModelTest.kt
package com.alcalist.tecmeli.ui.screen.home

import com.alcalist.tecmeli.core.util.UiState
import com.alcalist.tecmeli.domain.model.Product
import com.alcalist.tecmeli.domain.usecase.GetProductsUseCase
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class HomeViewModelTest {

    private val testDispatcher = StandardTestDispatcher()
    private val getProductsUseCase: GetProductsUseCase = mockk()
    private lateinit var viewModel: HomeViewModel

    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
        viewModel = HomeViewModel(getProductsUseCase)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `searchProducts emits Loading and Success when repository returns products`() = runTest {
        // Arrange
        val products = listOf(Product("1", "Title"))
        coEvery { getProductsUseCase("term") } returns Result.success(products)

        // Act
        viewModel.searchProducts("term")
        testDispatcher.scheduler.advanceUntilIdle()

        // Assert
        val state = viewModel.uiState.value
        assertTrue(state is UiState.Success)
    }

    @Test
    fun `searchProducts emits Error when repository fails`() = runTest {
        // Arrange
        coEvery { getProductsUseCase("term") } returns Result.failure(Exception("fail"))

        // Act
        viewModel.searchProducts("term")
        testDispatcher.scheduler.advanceUntilIdle()

        // Assert
        val state = viewModel.uiState.value
        assertTrue(state is UiState.Error)
    }
}
Always use Dispatchers.setMain(testDispatcher) in @Before and reset it in @After when testing ViewModels.

Use Case Tests

Testing domain layer business logic:
GetProductsUseCaseTest.kt
package com.alcalist.tecmeli.domain.usecase

import com.alcalist.tecmeli.domain.model.Product
import com.alcalist.tecmeli.domain.repository.ProductRepository
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test

class GetProductsUseCaseTest {

    private val repository: ProductRepository = mockk()
    private val useCase = GetProductsUseCase(repository)

    @Test
    fun `invoke returns empty list when query is blank`() = runTest {
        // Act
        val result = useCase("")

        // Assert
        assert(result.isSuccess)
        assertEquals(emptyList<Product>(), result.getOrNull())
    }

    @Test
    fun `invoke delegates to repository when query is not blank`() = runTest {
        // Arrange
        val products = listOf(Product("1", "Title"))
        coEvery { repository.searchProducts("term") } returns Result.success(products)

        // Act
        val result = useCase("term")

        // Assert
        assert(result.isSuccess)
        assertEquals(products, result.getOrNull())
    }
}

Integration Tests with MockWebServer

Testing the complete OAuth flow with real HTTP interactions:
AuthInterceptorIntegrationTest.kt
package com.alcalist.tecmeli.core.network

import com.alcalist.tecmeli.data.remote.api.AuthApi
import com.alcalist.tecmeli.data.repository.TokenRepositoryImpl
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

class AuthInterceptorIntegrationTest {

    private lateinit var mockWebServer: MockWebServer
    private lateinit var authApi: AuthApi
    private lateinit var tokenRepository: TokenRepositoryImpl
    private lateinit var okHttpClient: OkHttpClient

    @Before
    fun setup() {
        mockWebServer = MockWebServer()
        mockWebServer.start()

        // Setup Auth API
        val authRetrofit = Retrofit.Builder()
            .baseUrl(mockWebServer.url("/"))
            .addConverterFactory(GsonConverterFactory.create())
            .build()
        authApi = authRetrofit.create(AuthApi::class.java)
        
        val apiConfig = ApiConfig(
            clientId = "test_id",
            clientSecret = "test_secret",
            refreshToken = "test_refresh"
        )
        tokenRepository = TokenRepositoryImpl(authApi, apiConfig)

        // Setup OkHttp with interceptors
        val authInterceptor = AuthInterceptor(tokenRepository)
        val tokenAuthenticator = TokenAuthenticator(tokenRepository)

        okHttpClient = OkHttpClient.Builder()
            .addInterceptor(authInterceptor)
            .authenticator(tokenAuthenticator)
            .build()
    }

    @After
    fun tearDown() {
        mockWebServer.shutdown()
    }

    @Test
    fun `AuthInterceptor adds Authorization header with token`() = runTest {
        // Arrange: Get a valid token first
        mockWebServer.enqueue(
            MockResponse()
                .setResponseCode(200)
                .setBody("""{"access_token":"valid_token","token_type":"Bearer","expires_in":3600}""")
        )
        tokenRepository.refreshToken()

        // Enqueue response for protected endpoint
        mockWebServer.enqueue(
            MockResponse()
                .setResponseCode(200)
                .setBody("""{"data":"success"}""")
        )

        // Act: Make a request with the interceptor
        val request = Request.Builder()
            .url(mockWebServer.url("/api/test"))
            .build()

        val response = okHttpClient.newCall(request).execute()

        // Assert
        assertEquals(200, response.code)
        
        val recordedRequest = mockWebServer.takeRequest()
        assertTrue(recordedRequest.getHeader("Authorization") != null)
        assertTrue(recordedRequest.getHeader("Authorization")!!.startsWith("Bearer "))
    }
}
Integration tests with MockWebServer verify the complete network stack, including interceptors, authenticators, and retry logic.

Test Patterns

MockK Setup

Common MockK patterns used in the codebase:
// Create a mock
private val api: MeliApi = mockk()

// Mock a suspend function
coEvery { api.searchProducts(any(), any(), "term") } returns Response.success(dto)

// Mock a regular function
every { tokenProvider.getToken() } returns "token"

Coroutine Testing

Best practices for testing coroutines:
1

Use runTest

Wrap test bodies with runTest { } for proper coroutine handling
2

Set Main Dispatcher

Use Dispatchers.setMain(testDispatcher) for ViewModel tests
3

Advance Time

Use testDispatcher.scheduler.advanceUntilIdle() to complete all pending coroutines
4

Clean Up

Always reset the main dispatcher in @After

Code Coverage with JaCoCo

The project includes JaCoCo for measuring test coverage:
app/build.gradle.kts
plugins {
    jacoco
}

afterEvaluate {
    tasks.register("jacocoTestReport") {
        dependsOn("testDebugUnitTest")
        doLast {
            val jacocoExec = file("build/jacoco/testDebugUnitTest.exec")
            println("\n📊 COVERAGE REPORT")
            // ... coverage report details
        }
    }
}

Generate Coverage Report

./gradlew jacocoTestReport
Output Files:
app/build/
├── jacoco/
│   └── testDebugUnitTest.exec       # Coverage data
├── test-results/
│   └── testDebugUnitTest/           # Test results XML
└── reports/
    └── tests/
        └── testDebugUnitTest/       # HTML test report

View Coverage Report

After running jacocoTestReport, check the console output:
📊 COVERAGE REPORT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✓ Coverage data found:
  Path: app/build/jacoco/testDebugUnitTest.exec
  Size: 12345 bytes

✓ Test execution completed successfully

📁 Coverage files:
  - exec file: app/build/jacoco/testDebugUnitTest.exec
  - test results: app/build/test-results/testDebugUnitTest/
  - test reports: app/build/reports/tests/testDebugUnitTest/
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Testing Best Practices

Use backticks for readable test names that describe the scenario:
@Test
fun `searchProducts returns success when api returns successful response`() = runTest {
    // Test implementation
}
Structure tests with clear sections:
@Test
fun `test scenario`() = runTest {
    // Arrange - Set up test data and mocks
    val products = listOf(Product("1", "Title"))
    coEvery { repository.searchProducts("term") } returns Result.success(products)

    // Act - Execute the code under test
    val result = useCase("term")

    // Assert - Verify the results
    assertTrue(result.isSuccess)
    assertEquals(products, result.getOrNull())
}
Always test happy paths and error scenarios:
@Test
fun `returns success when operation succeeds`() = runTest { /* ... */ }

@Test
fun `returns failure when operation fails`() = runTest { /* ... */ }

@Test
fun `returns empty when input is invalid`() = runTest { /* ... */ }
Mock all dependencies to test units in isolation:
private val repository: ProductRepository = mockk()
private val tokenProvider: TokenProvider = mockk()
// Use mocks, not real implementations
Use @After to clean up resources:
@After
fun tearDown() {
    Dispatchers.resetMain()
    unmockkStatic(Log::class)
    mockWebServer.shutdown()
}

Continuous Integration

For CI/CD pipelines, run tests with coverage:
# Run all tests and generate coverage
./gradlew clean testDebugUnitTest jacocoTestReport

# Check for test failures
if [ $? -ne 0 ]; then
  echo "Tests failed!"
  exit 1
fi
The project currently includes 9 test classes covering repositories, use cases, ViewModels, and network components.

Next Steps

Building

Learn how to build the project

Contributing

Read the contribution guidelines

Build docs developers (and LLMs) love