Skip to content
All posts
· 5 min read

Where to Start Testing an App That Has None

testingiosengineering

Every guide to testing an untested codebase answers with the pyramid: many unit tests, fewer integration tests, a handful of UI tests. It's correct, and it's useless on your first day. It describes the shape of a finished suite, not what to type on Monday morning.

The real question is narrower. Of the ten thousand lines in front of me, which twenty do I test first - and how do I know I picked right?

Start with the bugs you've already shipped

Before writing a single test, open the bug tracker.

Your issue history is a ranked list of where the code is weakest, and somebody already did the work of compiling it. Take the last handful of production bugs - the ones that got hotfixed, the ones that generated support tickets - and for each, write the test that would have caught it.

Three things happen at once, and only one of them is testing:

  • It proves the suite catches real defects. "Coverage went up four points" convinces nobody. "This test fails on the commit right before the hotfix" ends the argument.
  • It maps the risky modules for free. Bugs cluster. The third time you find yourself writing a test in the same file, you've found your first real target.
  • It gives you failing tests to start from. On a codebase with no tests there's no red to work against. Past bugs manufacture it.

And if a bug's test turns out to be impossible to write without restructuring the code, that isn't a detour from the assignment. That's the most useful thing you'll learn all week.

Then rank what's left: risk over cost

Once the known bugs are covered, you're guessing. Guess with a formula:

priority ≈ (cost of failure × rate of change) ÷ cost to test

Three inputs. Two of them you can measure.

Cost of failure is the one that needs judgment. Wrong amounts, lost data, a security check that silently stops firing, an operation that runs twice - top of the list. Wrong padding, a mistimed animation - bottom. The question isn't how clever the code is, it's what the incident report would say.

On the banking app I work on, that puts currency arithmetic first, and it's the clearest example of why this axis beats intuition. Rounding a monetary amount looks like a solved problem - right up until you notice the value is sitting in a Double, which cannot represent 0.1. Nobody would call that file interesting. It is boring code with the largest blast radius in the codebase, and a bug in it isn't a one-star review, it's a conversation with an auditor.

Rate of change is free, because git already knows. Code nobody has touched in two years is not what regresses next sprint; it has been in production long enough that users found its bugs for you. Rank by churn:

# Files by number of commits touching them in the last year.
git log --since="1 year ago" --format=format: --name-only \
  | grep -v '^$' | sort | uniq -c | sort -rn | head -30

Cross that against the files you'd be frightened to break. The intersection - complex and frequently edited - is your backlog. Complex but frozen can wait.

Cost to test is the divisor everyone forgets. A pure function that takes values and returns values costs you minutes. The same logic buried in a view controller behind three singletons costs a day of restructuring first. Early on, deliberately take the cheap ones. You need momentum, and you need people to see green before you ask them to accept refactors.

A stop-list is shorter than a start-list, and more useful:

Don't testBecause
Getters, setters, plain data holdersYou're testing the compiler
Framework or third-party behaviorTest your use of it, at the boundary
Code that's scheduled for deletionA test is a commitment to keep something alive
Exact layout, pixel values, animation timingYou'll rewrite it on every design tweak
Private methodsGo through the public API, or promote it to its own type

UI tests answer a different question

Everything above ranks code. UI tests don't rank code - they rank journeys, and the question changes completely.

Not "which code is risky." "Which flow, if it broke in production, would make us roll back the release?"

That list is short. It's usually the way in (can people sign in at all?), the one thing the app exists to do, and anything that moves money or destroys data. Five to eight flows, not fifty. Write them down before you write any test - teams agree on this list faster than they agree on almost anything else.

There's also a cost that doesn't show up in any list of flows. On some apps the expensive part isn't writing the test - it's getting the app to accept a predictable backend at all. A UI test needs the same screen every run, which means serving it something you control, and a hardened app is built to refuse exactly that. Certificate pinning can't tell your stub apart from an attacker, and it isn't supposed to. So budget for the harness, not just the tests: the first UI test can cost a week, and the next twenty cost an hour each.

The reason to keep the list short isn't taste, it's arithmetic. UI tests are bounded by wall-clock time in a way unit tests aren't. A suite that finishes in six minutes runs on every pull request. A suite that takes forty gets moved to nightly, then gets ignored when it's red at 3am, then gets deleted a year later. You aren't picking tests, you're spending a fixed budget - and every flow you add slows the feedback on every other flow.

So the admission test for a UI test is: could this be a unit test instead? Usually it can, because most "UI tests" are really assertions about state that happens to be visible. Write the unit test. Spend the UI budget only on what a real device can prove and a unit test can't - that the screens connect, that navigation works, that the thing launches at all.


If you take one thing: start with the bugs you already know about, then let git tell you where to go next. The first is evidence, the second is arithmetic, and neither one requires you to be the person who wrote the code - which, if you've just joined the team, is the entire problem.

Coverage percentage is a lagging indicator of all of this. Fine to report. A terrible thing to aim at.