Imported from anddegilevich/Dream (
.claude/skills/unit-test-rules/SKILL.md). Install upstream withnpx skills add anddegilevich/Dream --skill unit-test-rules. Copyright stays with the author.
Unit Test Rules
- One
<Class>Testper production class, same package as the class under test, in that module's owncommonTestsource set - Runner:
kotlin.test.Test. Assertions: Kotest matchers (shouldBe,shouldContainExactly, etc. fromkotest-assertions-core) —kotlin.teststays the test runner, Kotest only supplies assertions - No mocking library — hand-rolled fakes. A
Fake<Interface>implements the production interface; each interface method's behavior is a constructor-supplied lambda fieldval on<Method>: ... = { throw FakeImplementationException() }(never a mutablevar result/manual return field, never a safe/computed default):- Every lambda defaults to
{ throw FakeImplementationException() }(shared/foundation/abstraction/exception) — a test that exercises a code path without wiring the lambda that path needs fails loudly instead of silently passing against a guessed default. Only wire the lambdas the test under construction actually needs; leaving the rest on the throwing default is correct, not an oversight - Method's return doesn't depend on input (canned result) → zero-arg lambda:
val onMap: () -> String = { throw FakeImplementationException() }. If the test needs to assert what was passed in, keep a separatevar lastX: Params? = null; private setrecorded inside theoverridebefore callingonMap() - Method's return genuinely depends on input (e.g. a near-pass-through entity mapper) → lambda takes the real param(s):
val onMap: (AlbumData) -> AlbumEntity = { throw FakeImplementationException() }; the test supplies the real transform explicitly at the fake's construction site (once, even if shared across multiple@Testmethods in the class) rather than relying on a hidden default - Void/
Unitmethod used only to observe calls (DAOupsert, repositorycacheX) → lambda takes the real param(s) and returnsUnit, same throwing default. There's no built-in recording list — the test supplies a lambda that appends to a list it declares itself, e.g.val upserted = mutableListOf<AlbumEntity>(); FakeAlbumDao(onUpsert = { upserted.add(it) })
- Every lambda defaults to
- A layer's fakes of its own public api interfaces (e.g. a feature's
Repository, a cross-feature mapper interface) live in a siblingtestmodule (data/test,ui/test, etc., built with the same convention plugin as that layer's ownapimodule — no dedicated test plugin) so other modules — including other features — can depend on them as acommonTest-only dependency; add viacommonTest.dependencies { implementation(projects...data.test) }in the consumer'sbuild.gradle.kts - A fake of an interface that's
internalto oneimplmodule (nothing outside that module consumes it) stays inline in that module's owncommonTest— no dedicatedtestmodule needed - Suspend functions under test: wrap the test body in
runTest(kotlinx-coroutines-test) - Flow-returning code under test: use Turbine (
app.cash.turbine) - Arrange-Act-Assert structure; test names describe method + condition + expected outcome
LocalDataSourceImpl(Room-backed): fake the DAOs directly (plain interfaces, e.g.AlbumDao) rather than standing up a realAppDatabase— Room's in-memory builder needs a realandroid.content.Contexton the Android target, unavailable in JVM unit tests without Robolectric (not in this project). Faking the DAOs still fully covers the class's own orchestration logic (dedup, cross-ref construction); it just doesn't exercise Room/SQL mapping correctness itself, which is Room's concern, not this class's- A remote data source's generated API client (
AlbumsApi, etc.) is faked via Ktor'sMockEngine(ktor-client-mock, wired intodata/impl'scommonTest) — construct the api class withhttpClientEngine = MockEngine { ... }and respond with a serialized fixture; don't hand-roll a fake of the generated client itself - Never hold the SUT, its fakes, or a mutable assertion list (
mutableListOf<...>()) as a class-levelval/var— construct all of it fresh inside each@Testbody (or via a private factory called from the test body, see below). JVM/JUnit5 creates a new test-class instance per@Testmethod, but this is a Kotlin MultiplatformcommonTest:kotlin.teston Kotlin/Native and JS runs every@Testfunction of a class against one shared instance, so class-level mutable state (recording fakes, accumulation lists) silently leaks across tests on those targets even though it happens to work on the JVM target alone - If the SUT has enough collaborators that wiring them inline in every test would be repetitive (roughly 3+ constructor params), extract a
private fun create<Sut>(...)factory with one named parameter per collaborator, each defaulting to that collaborator's own no-argFake(which throws if invoked, per the rule above). Each test only passes the params it actually wires; unused collaborators — including ones the SUT calls unconditionally along the way but this test doesn't care about — stay on the throwing default, which doubles as a "this must not be called" assertion that's stronger than asserting an empty/canned result. For a trivial SUT (1-2 dependencies), skip the factory and construct inline per test — the factory is only worth it once it removes real repetition - A
Fake's default lambda must be a lambda literal —{ fakeImplementationError() }/{ throw FakeImplementationException() }— never the bare call= fakeImplementationError().fakeImplementationError(): Nothingthrows immediately when used as a default-argument expression, i.e. at construction time, regardless of whether the method is ever invoked; wrapping it in{ }defers the throw to actual invocation, which is what makes "leave it unwired" a meaningful, lazy assertion instead of an immediate crash - Order within the test class: all
@Testmethods first, then anyprivate fun create<Sut>(...)factory and other private helpers (e.g. per-field transform functions used as factory defaults) at the very bottom of the file — a reader scans the behaviors under test before the plumbing that builds them