Quiblo wiki

Engineering practice

Testing

What is tested, what deliberately is not, and how to write a test that holds.

# The shape of the suite

JUnit 5 with MockK, and Turbine for flows. DAOs are mocked rather than exercised against a real SQLite: asserting one query plan is not worth a database per test.

Robolectric is used, but only where the question genuinely needs a framework — resolving a real Koin graph, and measuring a scrolling list frame by frame. Both are things a mock cannot answer by construction, which is the bar for reaching for it.

Coverage thresholds apply where they mean something. The two parsers carry our highest coverage — :source:m3u around 98%, :source:xtream around 84% — because they are pure JVM, deterministic, and face hostile input. UI modules are not chased for coverage, because a covered composable proves very little.

# The load-bearing tests

Some tests exist to catch a specific historical failure and should never be deleted for tidiness:

  • The block-gate tests — that after one refusal not a further byte is sent, that a mid-catalogue block ends a refresh at four requests, and that a stored block survives a restart.
  • The rate-limiter tests — the burst size, and the sustained pacing across twenty requests rather than the gap between two. See why the aggregate is the assertion.
  • The off-main-thread test — that the browse mapping leaves the collector's thread.
  • The key-map tests — that the television's channel keys do nothing on a film, and that the settings icon is reachable past the last tab.
  • The scroll-stability test — that walking a poster row does not move the catalogue. It reads the position off every frame at Android's key-repeat rate, and it is the only reason the shake is known to be fixed rather than believed to be.
  • The module wiring tests — see below.

# A Koin module is not type-checked

This one deserves its own heading, because it is a category of bug the compiler looks like it is covering and is not.

single { Thing(get(), get(), get()) }

That hands Kotlin a lambda whose argument types are inferred from the constructor. Any number of get()s compiles as long as the arity matches, and each one is only resolved when something first asks for the object — which is to say on a device, in front of a viewer.

Ours failed exactly as you would expect. ChannelRepository gained a parameter in the middle of its list; the module gained one more get() at the end rather than in the middle; and the arguments shifted by one, landing a resolution on now: () -> Long — a clock with a perfectly good default. It compiled, it passed every unit test, and it crashed every catalogue screen the moment the app ran.

Only a test that resolves the real graph catches this. Each app has one: Robolectric starts Koin with the production modules and asks for every repository by type. It is a handful of lines, it needs no assertions beyond "this resolved", and it stands between a reordered constructor and a crash that reaches hardware.

# Two habits worth copying

Assert the mechanism, not the symptom

The test that the browse mapping runs off the main thread pins the thread, not the elapsed time. A timing assertion would pass on a fast machine with the bug fully reintroduced; a thread assertion fails the moment the flowOn is removed.

To make that testable at all, the dispatcher is injected rather than hardcoded — the same trick the repositories use for the clock. Injecting a dependency purely so a test can observe it is a legitimate reason to inject it.

Make a negative test prove itself

A test asserting that something is not called passes trivially if the code under test never ran. The guide-subscription tests pair each negative with a positive on the same machinery: films and series must not ask for the guide, and Live and Favourites must. The positives prove the negatives are not vacuous.

This was not hypothetical. The first version of those tests passed for the wrong reason — the mocked flows never emitted, so the chain never ran at all.