Before we dive in, here’s a quick update about the newsletter.

Every week I spend around 13 hours building each issue.

2–3 of those go into two AI workflows:

  1. One to help you fix the problem covered in the issue

  2. One to research the Pitch section

I don't have a clear read on whether these workflows are worth that time — so for now I've pulled them.

But I'll bring them back if you want them.

I'll decide based on what you say.

Examples from past editions: here and here.

Brief

The what: A quick snapshot of the context

Recently a friend built a small web app MVP, got seed funding, and launched a startup.

No startup ships without a mobile app in 2026. So he hired a middle Android dev and bought a Claude Max subscription.

No budget for QA. They went no-QA — unit tests only.

Then they vibe-coded like hell and shipped the app in two weeks.

It worked. Until they started adding features.

Three things showed up:

  1. updating a screen took 2x longer

  2. they burned 2x the tokens

  3. they shipped 2x the bugs

So I came into the rescue, and started exploring the codebase =)

Multi-module project, MVVM, Compose - everything follows official guidelines.

But when I opened tests, I realized why this no-QA app is falling down.

I call it the Locked-In State trap...

🐞 The Issue: Fragile tests tied to implementation details.

Breakdown

The when: Reverse engineering the cause and the cost

The key to a no-QA team is tests you can trust.

But trust is abstract. Hard to measure.

So it comes down to one question — does our 90% coverage pass?

Why AI writes fragile tests

AI writes a test the same way it writes code.

Set up the dependencies, drive the state, verify everything it sees.

A typical AI-written test looks like this:

@Test
fun `Should enable submit button When terms accepted`() = runTest {
    //Given
    coEvery { isEmailValidUseCase(any()) } returns true
    coEvery { isPasswordValidUseCase(any()) } returns true

    viewModel = RegisterViewModel(dependencies)

    viewModel.onEmailChanged("[email protected]")
    viewModel.onPasswordChanged("password")
    viewModel.onFirstNameChanged("Mike")
    viewModel.onLastNameChanged("V")

    //When
    viewModel.onTermsExcepted(true)

    //Then
    val state = viewModel.uiState
    assertEquals("[email protected]", state.email)
    assertEquals("password", state.password)
    assertEquals("Mike", state.firstName)
    assertEquals("V", state.lastName)
    assertTrue(state.acceptedTerms)
    assertTrue(state.isSubmitEnabled)
}

It had one thing to check: “the submit button turns on when terms are accepted“.

It verified every field instead.

Should you trust this test?

Your instinct is right — you shouldn't.

Even though the coverage passed.

Issue 1 - Multiple assertions

By 2026, devs feel it in their gut: one assertion per test.

There’s no official Android documentation for this. The principle is called OTOA — one test, one assertion.

So you get angry at the AI and trim it down to the one check that matters:

@Test
fun `Should enable submit button When terms accepted`() = runTest {
    //Given
    coEvery { isEmailValidUseCase(any()) } returns true
    coEvery { isPasswordValidUseCase(any()) } returns true

    viewModel = RegisterViewModel(dependencies)

    viewModel.onEmailChanged("[email protected]")
    viewModel.onPasswordChanged("password")
    viewModel.onFirstNameChanged("Mike")
    viewModel.onLastNameChanged("V")

    //When
    viewModel.onTermsExcepted(true)

    //Then
    assertTrue(viewModel.uiState.isSubmitEnabled)
}

Now it follows OTOA. But something is still off.

Issue 2 - Multiple State updates

The four setters are still there. And every other form test repeats them:

  • Submit off when the account step is invalid → fill the form again.

  • Submit off when terms are unchecked → fill it again.

  • Profile step invalid → fill it again.

OTOA trimmed the asserts. It did nothing for the setup.

I call the next rule OTOS — one test, one state.

→ No more than one state update per test.

The test isn’t fragile. The UI State is.

This issue has lived in the ViewModel since May 2017 — the day it was introduced.

Whatever your architecture, your by-the-book setup looks like this:

class RegisterViewModel(
    private val registrationRepository: RegistrationRepository,
    private val isEmailValidUseCase: IsEmailValidUseCase,
    private val isPasswordValidUseCase: IsPasswordValidUseCase
): ViewModel() {
    
    private val _uiState = MutableStateFlow(RegisterUiState())
    val uiState = _uiState.asStateFlow()
}

The initial state is hardcoded inside the ViewModel.

RegisterUiState() — empty, every time. The constructor takes dependencies, not state. There's no way to say "start here."

So the only way to reach a state under test is to drive it there.

Call a setter. Then another. Then three more.

Then repeat the same waterfall in every test you write.

→ One hardcoded line — and the Given block explodes.

The Fix: Provide UiState via constructor injection.

Guideline

The how: Implementation guideline and recommendations

Step 1 - Set up DI module

The ViewModel no longer knows its initial state. So DI provides one.

@Module
@InstallIn(ViewModelComponent::class)
object ViewModelModule {

    @Provides
    fun provideInitialState(): RegisterUiState = RegisterUiState()
}

🧠 Pro tip: this module isn't only for the initial state. You can override the state for E2E tests from here — which cuts their setup down a lot.

Step 2 - Take the State out of the ViewModel

Add initialState to the constructor. Seed the flow from it. Delete the hardcoded RegisterUiState().

@HiltViewModel
class RegisterViewModel @Inject constructor(
    initialState: RegisterUiState,
) : ViewModel() {

    private val _uiState = MutableStateFlow(initialState)
    val uiState = _uiState.asStateFlow()
}

⚠️ But MutableStateFlow shouldn't be your state holder — it doesn't survive process death.

Swap it for the State Machine. Now it looks like this:

@HiltViewModel
class RegisterViewModel @Inject constructor(
    initialState: RegisterUiState,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {

    private val uiStateMachine: UiStateMachine<RegisterUiState> =
        savedStateHandle.asUiStateMachine(initialState)

    val uiState: StateFlow<RegisterUiState> by uiStateMachine
}

I covered why the State Machine is the right solution in the previous issue.

Step 3 — Seed the state in tests

Add initialState to your test factory, defaulted so old tests don't change:

private fun createObjectUnderTest(
    initialState: RegisterUiState = RegisterUiState(),
) = RegisterViewModel(
    initialState = initialState,
    isEmailValidUseCase = isEmailValidUseCase,
    isPasswordValidUseCase = isPasswordValidUseCase,
    registrationRepository = registrationRepository
)

Now the test states where it starts:

@Test
fun `Should enable submit button When terms accepted`() = runTest {
    //Given
    viewModel = createObjectUnderTest(
        initialState = RegisterUiState(
            email = "[email protected]",
            password = "password1",
            firstName = "Mike",
            lastName = "V"
        ),
    )

    //When
    viewModel.onTermsExcepted(true)

    //Then
    assertTrue(viewModel.uiState.value.isSubmitEnabled)
}

One line changed in the ViewModel. The impact on the tests is dramatic:

  • no flakiness

  • no mock setup

  • no method calls

  • no intermediate state updates

  • follows OTOA and OTOS

🧠 Pro tip: the state is often reused between tests. Pull it into one place:

object TestState {
    val validInput: RegisterUiState = RegisterUiState(...)
    val invalidInput: RegisterUiState = RegisterUiState(...)
    
    //any other state ...    
}

And you land a true three-line test:

@Test
fun `Should enable submit button When terms accepted`() = runTest {
    //Given
    viewModel = createObjectUnderTest(initialState = TestState.validInput)

    //When
    viewModel.onTermsExcepted(true)

    //Then
    assertTrue(viewModel.uiState.isSubmitEnabled)
}

→ One line change in the ViewModel, and the Given block becomes a single line.

Pitch

The why: How to get it approved and get recognized

Before you do anything

Don't just forward this issue to your team.

That's the entry-level move.

Seniors turn this into a business case with a problem, numbers, and a solution.

Spend 10 minutes in your codebase first:

  1. Find the locked-in ViewModels — where UIState hardcoded inside, no way to pass state in.

  2. Open their tests — count the setup lines in the Given block before the first assert. That number is your evidence.

  3. Turn it into cost
    ↳ time — heavy setup, slow to write and change
    ↳ change — tied to implementation, so every refactor forces a rewrite
    ↳ tokens — more setup, more AI regenerates per edit

You don't need exact numbers. A fair estimate holds up.

The message

This is your script. Send it to whoever needs to approve the fix - team lead, manager, or the whole team chat.

Hey [name],

Our test state is hardcoded inside each ViewModel, so a test can't start from the case it checks. It rebuilds the state through setter calls first.

These tests violate the OTOA and OTOS principles, which ties them to implementation details and forces updates every time the ViewModel changes.

I checked our heaviest suites. [ViewModel 1] and [ViewModel 2] are both affected. Based on [your estimate], that's roughly [number] setup lines before the first assert, rewritten on every refactor.

The fix is a one-line change per ViewModel. It takes ~[N]h to fix by leveraging AI.

I'd like to prioritize it before we add more screens on top.

Happy to walk you through it if helpful.

→ You walk in as the engineer who found the problem, quantified it, and came with the solution ready.

That's not just a fix. That's a promotion conversation waiting to happen 😎

Explore

The where: Related resources and topics

One of the TDD pillars. Explains why you should always use a single assertion per test, and the common pitfalls when developers try to hack it.

Missing state restoration is even more critical than having hardcoded UI state. Learn why you should never use MutableStateFlow for holding screen state.

Old but still good guide on how to improve the readability and maintainability of your tests. Teaches what to do and what not to do, with practical examples.

This week I ran a survey to figure out the newsletter content direction.

There were three winning topics — so I want you to tell me what you want to see next.

Keep Reading