Android Architecture series · Part 8 of 9
Testing, TDD and the Proof: All Architectures Are Alike
The finale. First, how testing (and TDD) rewards a clean architecture. Then the proof: one feature written four ways, with the same core and only one step that differs.
- TDD: red, green, refactor; hard-to-test code is a design smell.
- Proof: MVP, MVVM, VIPER and Redux share one core and differ only in how results reach the view.
- Unchanged: dependency direction, logic without Android, passive view.
- Checklist: ten habits to take to your own codebase.
1. TDD: let tests shape the design
Test-driven development is a loop of three small steps:
TDD pushes you toward good architecture without effort. If a test is hard to write, the code has too many hidden dependencies, which is a design smell. Everything in this series (interfaces, repositories, schedulers, ViewProxy, DI) exists partly to make this easy: the logic box has no Android code, so a test is a few lines on the plain JVM.
class FakeProfileRepository(var result: Result<Profile>) : ProfileRepository {
override suspend fun getProfile(id: String) = result.getOrThrow()
}
class ProfileViewModelTest {
private val dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher())
@Test fun `shows content when load succeeds`() = runTest {
val repo = FakeProfileRepository(Result.success(Profile("1", "Ana", true)))
val vm = ProfileViewModel(repo, dispatchers)
vm.load("1")
assertEquals(ProfileUiState.Content(Profile("1", "Ana", true)), vm.state.value)
}
@Test fun `shows error when network fails`() = runTest {
val repo = FakeProfileRepository(Result.failure(IOException()))
val vm = ProfileViewModel(repo, dispatchers)
vm.load("1")
assertEquals(ProfileUiState.Error("No connection"), vm.state.value)
}
}
The test pyramid
- Many unit tests (JVM, milliseconds): presenters, ViewModels, interactors, reducers, mappers.
- Some integration tests: repository with an in-memory Room database or a mock web server.
- Few UI tests (Espresso or Compose tests): the main user journeys only.
Because the view is passive (a ViewProxy or a render(state) function), you hardly need UI tests to check logic.
2. The proof: they are all the same thing
Here is the claim from Part 1: MVC, MVP, MVVM, VIPER and Redux are the same three boxes with different names. Let us prove it with code. Take one feature, “load a profile”, and keep the part that never changes:
// The core. Identical in every architecture below.
class LoadProfile(private val repo: ProfileRepository) {
suspend operator fun invoke(id: String): Result<Profile> = runCatching { repo.getProfile(id) }
}
Now the four “different” architectures, each reduced to its logic role:
// MVP: logic CALLS the view through an interface
class ProfilePresenter(private val view: ProfileView, private val loadProfile: LoadProfile, private val scope: CoroutineScope) {
fun onLoad(id: String) = scope.launch {
view.showLoading()
loadProfile(id).onSuccess(view::showProfile).onFailure { view.showError("No connection") }
}
}
// MVVM: logic PUBLISHES state, the view observes
class ProfileViewModel(private val loadProfile: LoadProfile) : ViewModel() {
val state = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
fun onLoad(id: String) = viewModelScope.launch {
state.value = ProfileUiState.Loading
state.value = loadProfile(id).fold({ ProfileUiState.Content(it) }, { ProfileUiState.Error("No connection") })
}
}
// VIPER: same as MVP; the Interactor IS LoadProfile, the Router handles navigation
class ViperPresenter(private val view: ProfileView, private val interactor: LoadProfile, private val router: ProfileRouter, private val scope: CoroutineScope) {
fun onLoad(id: String) = scope.launch { /* identical to the MVP body */ }
}
// Redux: logic is the reducer + one effect, state goes through a store
fun reduce(s: ProfileState, a: ProfileAction): ProfileState = when (a) { /* Load, Loaded, Failed */ }
suspend fun effect(a: ProfileAction.Load, loadProfile: LoadProfile) =
loadProfile(a.id).fold({ ProfileAction.Loaded(it) }, { ProfileAction.Failed("No connection") })
Read the four snippets again. In every one of them:
- An event arrives from the view (
onLoad(id)). - The same core (
LoadProfile→ repository) asks the data layer. - The result is turned into something the view can show.
- That result reaches the view. This is the only step that differs.
| Architecture | Logic box is called | Data box is called | How the logic reaches the view |
|---|---|---|---|
| MVC | Controller | Model | Controller updates the view directly |
| MVP | Presenter | Model / repository | Calls a view interface (ViewProxy) |
| MVVM | ViewModel | Model / repository | Exposes observable state |
| VIPER | Interactor + Presenter (+ Router) | Entity / repository | Presenter calls a view interface |
| Redux / MVI | Reducer + Store (+ effects) | Repository | Store emits a new state |
Three things that never change
- Direction of dependency: View → Logic → Data, never backwards.
- The logic has no Android framework code, so it is testable on the JVM. The same fake repository and the same
LoadProfiletest serve all four. - The view is passive and replaceable (ViewProxy,
render(state), a Composable).
The small differences, honestly
| Choice | MVP / VIPER | MVVM / Redux |
|---|---|---|
| Logic → view | Push: call a method | Pull: observe state |
| Lifecycle | You attach and detach | The framework helps (ViewModel, lifecycle-aware collection) |
| Rotation | You retain the presenter | ViewModel survives |
| Boilerplate | Contract interfaces | State and action types |
Those differences are real, and they are why the industry moved from MVP to MVVM. But they are differences of delivery, not of structure. Learn one well and you can read all of them.
3. Your ten-point checklist
- Pick one architecture per codebase and write a one-page template for it.
- Three boxes: View, Logic, Data. Dependencies point one way.
- The view is passive. Use a ViewProxy or
render(state). - Logic has no
Context, noView, no framework. - One repository per data source of truth; map DTO and entity to domain models.
- Depend on interfaces and inject them (Hilt or Koin).
- Hide threads behind a scheduler/dispatcher provider.
- Keep state out of the Activity; test rotation, language, font size and window size.
- Map errors once, show friendly messages everywhere.
- Write the test first when you can. Revamp old code one screen at a time. Keep it simple.
A consistent architecture means faster delivery with quality, easy change, low onboarding cost, and room for the team and the app to grow. The label (MVP, MVVM, VIPER, Redux) matters far less than being consistent and keeping the three boxes clean. Adopt the benefits, not the buzzwords.