Android Architecture series · Part 8 of 9

Testing, TDD and the Proof: All Architectures Are Alike

Khairul Alam·October 2026·~12 min read
TDDTestingMVPMVVMRedux

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.

In 60 seconds
  • 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.
Contents
  1. TDD: let tests shape the design
  2. The proof: they are all the same
  3. Your ten-point checklist

1. TDD: let tests shape the design

Test-driven development is a loop of three small steps:

Redwrite a failing testGreenmake it pass, simplyRefactorclean up, tests stay green
The TDD loop: red, green, refactor, then repeat.

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

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:

  1. An event arrives from the view (onLoad(id)).
  2. The same core (LoadProfile → repository) asks the data layer.
  3. The result is turned into something the view can show.
  4. That result reaches the view. This is the only step that differs.
ArchitectureLogic box is calledData box is calledHow the logic reaches the view
MVCControllerModelController updates the view directly
MVPPresenterModel / repositoryCalls a view interface (ViewProxy)
MVVMViewModelModel / repositoryExposes observable state
VIPERInteractor + Presenter (+ Router)Entity / repositoryPresenter calls a view interface
Redux / MVIReducer + Store (+ effects)RepositoryStore emits a new state
ViewViewProxy / renderMVP: PresenterMVVM: ViewModelVIPER: PresenterRedux: ReducerCoreLoadProfileDataRepository
Swap the orange box and nothing else moves. That is why migrating between these architectures is mostly mechanical.

Three things that never change

The small differences, honestly

ChoiceMVP / VIPERMVVM / Redux
Logic → viewPush: call a methodPull: observe state
LifecycleYou attach and detachThe framework helps (ViewModel, lifecycle-aware collection)
RotationYou retain the presenterViewModel survives
BoilerplateContract interfacesState 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

  1. Pick one architecture per codebase and write a one-page template for it.
  2. Three boxes: View, Logic, Data. Dependencies point one way.
  3. The view is passive. Use a ViewProxy or render(state).
  4. Logic has no Context, no View, no framework.
  5. One repository per data source of truth; map DTO and entity to domain models.
  6. Depend on interfaces and inject them (Hilt or Koin).
  7. Hide threads behind a scheduler/dispatcher provider.
  8. Keep state out of the Activity; test rotation, language, font size and window size.
  9. Map errors once, show friendly messages everywhere.
  10. Write the test first when you can. Revamp old code one screen at a time. Keep it simple.
The takeaway

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.

← Back to all writing