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)
Library Version Purpose JUnit 4.13.2 Test framework MockK 1.13.8 Kotlin-first mocking library MockWebServer 4.11.0 HTTP server for integration tests Coroutines Test 1.7.3 Testing coroutines and flows
Running Tests
Using Android Studio
Run All Tests
Right-click on the test directory and select: Run ‘Tests in ‘tecmeli.test”
Run a Single Test Class
Open a test file and click the green arrow icon next to the class name
Run a Single Test Method
Click the green arrow icon next to the test method
Using Command Line
Run All Tests
Run Debug Tests Only
Run Tests with Coverage
Run Specific Test Class
Run Tests in Continuous Mode
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:
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:
Basic Mocking
Relaxed Mocks
Mock Android Log
// 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:
Use runTest
Wrap test bodies with runTest { } for proper coroutine handling
Set Main Dispatcher
Use Dispatchers.setMain(testDispatcher) for ViewModel tests
Advance Time
Use testDispatcher.scheduler.advanceUntilIdle() to complete all pending coroutines
Clean Up
Always reset the main dispatcher in @After
Code Coverage with JaCoCo
The project includes JaCoCo for measuring test coverage:
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
Write Descriptive Test Names
Use backticks for readable test names that describe the scenario: @Test
fun `searchProducts returns success when api returns successful response` () = runTest {
// Test implementation
}
Follow Arrange-Act-Assert
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 ())
}
Test Both Success and Failure Paths
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