Building a weather app is a great way to demonstrate your proficiency in Android development, particularly with modern tools and architecture patterns. In this guide, we’ll walk through building a weather app using MVVM Clean Architecture while leveraging tools like Jetpack Compose, Retrofit, Coroutines, Flow, and Hilt. We'll also include comprehensive testing practices with JUnit, Espresso, and Mockito.
Project Overview
Goal
Create a weather app where users can:
- Search for the weather in any U.S. city.
- Auto-load the last searched city on launch.
- Display weather icons alongside weather details.
- Access weather data based on location permissions.
Key Technologies
- Kotlin
- MVVM Clean Architecture
- Retrofit for API calls
- Jetpack Compose for UI
- Hilt for dependency injection
- JUnit, Espresso, Mockito for testing
- Coroutines and Flow for asynchronous programming
- Arrow Library for functional programming
Here's an outline and project structure for the requested weather-based Android application using MVVM Clean Architecture. The app will integrate the OpenWeatherMap API, Geocoder API, and fulfill all other requirements.
Project Setup
- Gradle dependencies:
Add the following to your
build.gradle
files:// Module-level (app) plugins { id 'com.android.application' id 'kotlin-kapt' id 'dagger.hilt.android.plugin' } android { compileSdk 34 defaultConfig { applicationId "com.example.weatherapp" minSdk 21 targetSdk 34 versionCode 1 versionName "1.0" buildConfigField "String", "BASE_URL", '"https://api.openweathermap.org/data/2.5/"' buildConfigField "String", "API_KEY", '"YOUR_API_KEY_HERE"' } } dependencies { // Core implementation "androidx.core:core-ktx:1.12.0" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1" implementation "androidx.activity:activity-compose:1.8.0" implementation "androidx.compose.ui:ui:1.6.0" implementation "androidx.compose.material3:material3:1.2.0" // Hilt implementation "com.google.dagger:hilt-android:2.48" kapt "com.google.dagger:hilt-android-compiler:2.48" // Retrofit implementation "com.squareup.retrofit2:retrofit:2.9.0" implementation "com.squareup.retrofit2:converter-gson:2.9.0" // Coroutines and Flow implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" // Testing testImplementation "junit:junit:4.13.2" androidTestImplementation "androidx.test.ext:junit:1.1.5" androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1" // Arrow for functional programming implementation "io.arrow-kt:arrow-core:1.2.0" // Unit Testing testImplementation "org.mockito:mockito-core:4.11.0" testImplementation "org.mockito.kotlin:mockito-kotlin:4.1.0" // Coroutines Testing testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3" // Arrow Testing testImplementation "io.arrow-kt:arrow-core:1.2.0" // Jetpack ViewModel Testing testImplementation "androidx.arch.core:core-testing:2.2.0" }
Project Structure
com.example.weatherapp
│
├── di # Hilt-related classes
│ └── AppModule.kt
│
├── data # Data layer
│ ├── api
│ │ ├── WeatherApiService.kt
│ │ └── WeatherResponse.kt
│ ├── repository
│ ├── WeatherRepository.kt
│ └── WeatherRepositoryImpl.kt
│
├── domain # Domain layer
│ ├── model
│ │ └── Weather.kt
│ ├── repository
│ │ └── WeatherRepository.kt
│ ├── usecase
│ └── GetWeatherByCityNameUseCase.kt
│
├── presentation # UI layer
│ ├── viewmodel
│ │ └── WeatherViewModel.kt
│ └── ui
│ ├── screens
│ └── WeatherScreen.kt
│ └── components
│ └── LoadingState.kt
│
└── utils # Utility classes
└── Resource.kt # For handling state (Loading, Success, Error)
Implementation
1. AppModule.kt
(Dependency Injection)
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideWeatherApiService(retrofit: Retrofit): WeatherApiService {
return retrofit.create(WeatherApiService::class.java)
}
@Provides
@Singleton
fun provideWeatherRepository(api: WeatherApiService): WeatherRepository {
return WeatherRepositoryImpl(api)
}
}
2. WeatherApiService.kt
interface WeatherApiService {
@GET("weather")
suspend fun getWeatherByCityName(
@Query("q") cityName: String,
@Query("appid") apiKey: String = BuildConfig.API_KEY,
@Query("units") units: String = "metric"
): Response<WeatherResponse>
}
3. WeatherRepositoryImpl.kt
class WeatherRepositoryImpl(private val api: WeatherApiService) : WeatherRepository {
override suspend fun getWeatherByCity(cityName: String): Resource<Weather> {
return try {
val response = api.getWeatherByCityName(cityName)
if (response.isSuccessful) {
val weatherData = response.body()?.toDomainModel()
Resource.Success(weatherData)
} else {
Resource.Error(response.message())
}
} catch (e: Exception) {
Resource.Error(e.localizedMessage ?: "An unexpected error occurred")
}
}
}
4. WeatherViewModel.kt
@HiltViewModel
class WeatherViewModel @Inject constructor(
private val getWeatherByCityNameUseCase: GetWeatherByCityNameUseCase
) : ViewModel() {
private val _weatherState = MutableStateFlow<Resource<Weather>>(Resource.Loading())
val weatherState: StateFlow<Resource<Weather>> get() = _weatherState
fun fetchWeather(cityName: String) {
viewModelScope.launch {
_weatherState.value = Resource.Loading()
_weatherState.value = getWeatherByCityNameUseCase(cityName)
}
}
}
5. WeatherScreen.kt
@Composable
fun WeatherScreen(viewModel: WeatherViewModel = hiltViewModel()) {
val weatherState by viewModel.weatherState.collectAsState()
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
TextField(
value = cityInput,
onValueChange = { cityInput = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Enter City") }
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.fetchWeather(cityInput) }) {
Text("Get Weather")
}
Spacer(modifier = Modifier.height(16.dp))
when (weatherState) {
is Resource.Loading -> LoadingState()
is Resource.Success -> WeatherDetails(weather = weatherState.data)
is Resource.Error -> ErrorState(message = weatherState.message)
}
}
}
Features Included
- Auto-search with debounce (handled using
StateFlow
andTextField
events in Compose). - Location permission handling (using
ActivityCompat.requestPermissions
). - Weather icon rendering (from OpenWeatherMap's image URLs).
- Proper error and loading states using the
Resource
class.
Testing
1. Unit Test Example: WeatherRepositoryImpl
Test how the repository handles API responses using Mockito
.
@ExperimentalCoroutinesApi
class WeatherRepositoryImplTest {
@Mock
private lateinit var mockApiService: WeatherApiService
private lateinit var repository: WeatherRepository
@get:Rule
val coroutineRule = MainCoroutineRule() // Custom rule to manage coroutine scope
@Before
fun setup() {
MockitoAnnotations.openMocks(this)
repository = WeatherRepositoryImpl(mockApiService)
}
@Test
fun `getWeatherByCity returns success`() = runTest {
// Given
val cityName = "Boston"
val mockResponse = WeatherResponse(/* Populate with mock data */)
Mockito.`when`(mockApiService.getWeatherByCityName(cityName)).thenReturn(Response.success(mockResponse))
// When
val result = repository.getWeatherByCity(cityName)
// Then
assert(result is Resource.Success)
assertEquals(mockResponse.toDomainModel(), (result as Resource.Success).data)
}
@Test
fun `getWeatherByCity returns error on exception`() = runTest {
// Given
val cityName = "Unknown"
Mockito.`when`(mockApiService.getWeatherByCityName(cityName)).thenThrow(RuntimeException("Network error"))
// When
val result = repository.getWeatherByCity(cityName)
// Then
assert(result is Resource.Error)
assertEquals("Network error", (result as Resource.Error).message)
}
}
2. Unit Test Example: WeatherViewModel
Test how the ViewModel handles states.
@ExperimentalCoroutinesApi
class WeatherViewModelTest {
@Mock
private lateinit var mockUseCase: GetWeatherByCityNameUseCase
private lateinit var viewModel: WeatherViewModel
@get:Rule
val coroutineRule = MainCoroutineRule()
@Before
fun setup() {
MockitoAnnotations.openMocks(this)
viewModel = WeatherViewModel(mockUseCase)
}
@Test
fun `fetchWeather sets loading and success states`() = runTest {
// Given
val cityName = "Los Angeles"
val mockWeather = Weather(/* Populate with mock data */)
Mockito.`when`(mockUseCase(cityName)).thenReturn(Resource.Success(mockWeather))
// When
viewModel.fetchWeather(cityName)
// Then
val states = viewModel.weatherState.take(2).toList()
assert(states[0] is Resource.Loading)
assert(states[1] is Resource.Success && states[1].data == mockWeather)
}
@Test
fun `fetchWeather sets error state`() = runTest {
// Given
val cityName = "InvalidCity"
val errorMessage = "City not found"
Mockito.`when`(mockUseCase(cityName)).thenReturn(Resource.Error(errorMessage))
// When
viewModel.fetchWeather(cityName)
// Then
val states = viewModel.weatherState.take(2).toList()
assert(states[0] is Resource.Loading)
assert(states[1] is Resource.Error && states[1].message == errorMessage)
}
}
UI Testing
1. Setup Dependencies
In build.gradle
:
// UI Testing
androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
androidTestImplementation "androidx.test.ext:junit:1.1.5"
// Jetpack Compose Testing
androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.6.0"
debugImplementation "androidx.compose.ui:ui-test-manifest:1.6.0"
2. UI Test Example: WeatherScreen
Use Compose's testing APIs to check interactions and states.
@HiltAndroidTest
@UninstallModules(AppModule::class) // Mock dependencies if needed
class WeatherScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>() // Replace with the app's main activity
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun enterCityAndFetchWeather_displaysWeatherInfo() {
// Given
val cityName = "San Francisco"
// When
composeRule.onNodeWithTag("CityInputField").performTextInput(cityName)
composeRule.onNodeWithTag("FetchWeatherButton").performClick()
// Then
composeRule.onNodeWithText("Weather Info").assertExists()
}
@Test
fun fetchWeather_displaysLoadingAndErrorState() {
// Simulate API response delay and error
composeRule.onNodeWithTag("CityInputField").performTextInput("InvalidCity")
composeRule.onNodeWithTag("FetchWeatherButton").performClick()
// Check loading state
composeRule.onNodeWithTag("LoadingIndicator").assertExists()
// After API call fails
composeRule.waitUntil { composeRule.onNodeWithTag("ErrorText").fetchSemanticsNode() != null }
composeRule.onNodeWithTag("ErrorText").assertTextContains("City not found")
}
}
3. Testing Tips
- Use mock responses for network requests during UI tests (e.g., using
MockWebServer
). - Add tags (
Modifier.testTag()
) to Composable elements for easier identification in tests. - Use coroutines test dispatchers for consistent timing in tests.
Conclusion
This weather app demonstrates clean architecture principles and incorporates modern Android tools and practices. By focusing on separation of concerns, defensive coding, and robust testing, you can create a scalable and maintainable app.
Sample Output
When a user enters "New York" and clicks the search button:
- A loading spinner appears.
- The app fetches weather details via the API.
- The UI updates with temperature, humidity, and a weather icon.
Happy coding!
If you faced any kind of error or problems? Share in the comments below!