Testing

July 25, 2026 · View on GitHub

How Boxlore is tested: layers, commands, coverage floors, architecture gates, and the coverage checklist.

Status legend

StatusMeaning
DonePresent and exercised at the bar
WIPExists but below the bar
Yet to startNot implemented yet
ExcludedIrreducible exclusion with an alternate coverage layer

Goal

Automated coverage focused on hermetic JVM for product logic (queue fill, ranking, catalog, prefs, feature logic/). High Kover floors fail CI on drop. Architecture guards fail the unit PR job on graph drift.

Strategy: constructors, domain ports, shared fakes in :core:testing, assemblers, Turbine. No MockK/Hilt. No Application-backed Home/Info suites. Media3 service / PlaybackRepository stay out of the line gate; covered by policy unit tests. Maestro YAML is validated nightly (no paid Maestro Cloud device runs).

Layers

LayerCommand / locationCatchesStatus
JVM unit./gradlew testDebugUnitTestLogic / state bugsWIP
Architecture-as-code:core:testing Konsist / scriptsFeature isolation, graph, allowlists, new-code testsDone
Static analysis./gradlew detektStyle / quality beyond baselinesDone
Android lint./gradlew lintDebugManifest / resource / API lintDone
Coverage (Kover)./gradlew :koverVerifyMergedMerged floor (ratchet toward 80%)WIP
Screenshotsscreenshots/baselines/ + optional Roborazzi (local)Visual regressionsOptional

| Maestro | maestro/ YAML validate | Flow file presence/syntax | Done |

Architecture boundaries: ARCHITECTURE.md.

Stack

  • JUnit 5 (+ Vintage where leftovers remain)
  • Turbine, MockWebServer, Robolectric
  • Konsist (architecture guards in :core:testing)
  • Shared fixtures / fakes: :core:testing (TestFixtures, MainDispatcherExtension, core.testing.fakes.*)
  • No MockK / Hilt
  • Optional Roborazzi JVM screenshot goldens (not CI-gated)

Coverage bars

LayerBarCI fail
JVMEvery public behavior-owning type has a suite (happy/empty/error/branches) or an irreducible exclusiontestDebugUnitTest
ViewModelsBehaviors via hermetic logic/ + Settings Turbine; AndroidViewModels allowlisted when logic suites existunit job
ScreenshotsHome settings goldens (local record/verify)Manual / local only
MaestroYAML present and well-formednightly validate
Kover merged≥ 80% end state (ratchet 40 → 45 → 55 → 70 → 80):koverVerifyMerged
Kover per-module≥ 70% on logic-heavy modules (ratchet as suites land)module verify
ArchitectureARCHITECTURE.md boundariesscripts + ArchitectureGuardTest + dependencyGuard
New code*ViewModel / *Repository need matching *Test.kt (allowlists for stubs / Media3 / hard AndroidViewModels)ArchitectureGuardTest

Current Kover floor

TargetStatus
Merged floor ≥ 45% on full gated setDone (enforced by :koverVerifyMerged)
Measured merged line coverage≈ 47.9% (13,358 / 27,869 lines)
Per-module ≥ 70% on logic modulesYet to start
Merged floor ≥ 55% / 70% / 80%Yet to start (next ratchets)

The CI floor is locked at 45 (never lower). Reaching 55+ requires more hermetic suites on remaining pure helpers. Media3-bound types (PlaybackRepository, queue/telemetry coordinators, DownloadRepository, SmartDownloadManager) stay on alternate layers (policy tests, logic/ packages).

:app Compose nav / FCM / survey chrome is excluded from the line gate; see root build.gradle.kts kover { }.

Gated modules: :core:catalog, :core:domain, :core:analytics, :core:rss, :core:downloads, :core:playback, :core:ranking, :core:prefs, :core:network, :core:database, :core:model, :feature:home, :feature:info, :feature:explore, :feature:library, :feature:onboarding, :feature:briefing, :feature:player, :app.

./gradlew testDebugUnitTest
./gradlew :core:testing:testDebugUnitTest
./gradlew :koverVerifyMerged
./gradlew :koverHtmlReportMerged
./gradlew :koverXmlReportMerged

Reports: build/reports/kover/.

Irreducible exclusions (line gate only)

ExclusionAlternate coverage
PlaybackRepository + core.playback.service.* / AutoPolicy unit tests
@Composable / @PreviewManual (+ optional local Roborazzi)
:app navigation.* / ui.* / fcm.* / surveys.*Manual / optional local Maestro
PostHog / Firebase SDK internalsNot our code; features must not import PostHog
Generated R / BuildConfig / databindingGenerated

Architecture CI (fail on deviate)

unit-tests.yml (PR / dispatch) fails when architecture drifts:

GuardWhat it enforces
ArchitectureGuardTestNo feature→feature Gradle deps or imports; catalog↛designsystem; catalog↛playback; catalog must not api analytics/ranking; module READMEs; getInstance allowlist; package=module (+ core.data stubs); no Hilt/Koin/Dagger/MockK; new *ViewModel/*Repository need matching *Test.kt
GlossaryCoverageGuardTestevent_glossary.csvAnalyticsGlossary allowlist; every event has glossary_emission_coverage.csv mode (emission / sdk_backed / person_props_only); no dual open/install volume
GlossaryAllEventsEmissionTest (:core:analytics)Every emission: inventory row captures via façade; lifecycle helpers never emit app_open / app_background / install_attributed
scripts/ci/check-feature-no-posthog.shFeatures never import/capture via PostHog
scripts/ci/check-feature-no-boxlore-database.shHome/Info VMs/assemblers do not take BoxLoreDatabase
dependencyGuardLocked dependency lists for :app, :core:catalog, :core:playback
bash scripts/ci/check-feature-no-boxlore-database.sh
bash scripts/ci/check-feature-no-posthog.sh
./gradlew :core:testing:testDebugUnitTest
./gradlew :app:dependencyGuard :core:catalog:dependencyGuard :core:playback:dependencyGuard

Static analysis

./gradlew detekt
./gradlew ktlintCheck
./gradlew lintDebug

Detekt: config/detekt/{detekt.yml,baseline.xml}.
ktlint: per-project baselines under config/ktlint/.

Module × layer checklist

ModuleJVMVM / logicNotes
:core:rankingDonen/aRepos, scorer, runtime controls
:core:catalogWIPn/aPorts/consent/content Done; backup WIP
:core:playbackWIPn/aQueue/mixtape/policy Done; service excluded
:core:downloadsWIPn/aCandidate logic Done; Media3 manager Excluded
:core:prefsDonen/aDataStore + migrator
:core:databaseDonen/aIn-memory DAOs
:core:rssDonen/aFeed fixtures + helpers
:core:analyticsDonen/aTracks + glossary + facade
:core:networkDonen/aMockWebServer contracts
:core:domainDonen/aPort contracts
:core:modelDonen/aBehavior helpers
:feature:homeDoneDoneSettings Turbine + logic (+ optional Roborazzi goldens)
:feature:infoDoneDonePort/logic suites
:feature:exploreDoneDoneLogic + Learn store
:feature:libraryDoneDoneSort/filter + download models
:feature:onboardingDoneDoneLogic suites
:feature:briefingDoneDoneStory text helpers
:feature:playerDonen/av2 logic JVM
:appWIPn/aWorker/push allowlists

Application-backed Home/Info suites are not pursued; hermetic logic/ + assembler/port suites replace them.

Maestro

TargetStatus
Flow YAML under maestro/Done
Nightly YAML validateDone
Maestro Cloud device runsOut of scope (not subscribed)

See maestro/README.md.

Screenshots

TargetStatus
Reserved screenshots/baselines/Done
Checked-in PNG goldens (Add RSS, Reset analytics, Downloads)Done
Roborazzi CI gateRemoved (local optional only)

See docs/screenshots/README.md.

CI

WorkflowRunsWhenStatus
unit-tests.ymlArchitecture + detekt + unit + Kover + lint + Dependency GuardPR / dispatchDone
coderabbit-threads-resolved.ymlFail unless all non-outdated CodeRabbit review threads are ResolvedPR / reviewDone
gitleaks.ymlSecret scanPR / push to masterDone
maestro-nightly.ymlValidate Maestro YAMLNightly / manualDone

Merge gate: master uses a branch ruleset (no merge queue). Required checks: testDebugUnitTest and coderabbit-threads-resolved. SonarCloud / CodeRabbit / Gitleaks still run on PRs (fix Sonar new-code issues; resolve CodeRabbit threads — the bare CodeRabbit status only means the review finished). The unit suite cancels prior in-progress runs on each PR push (or via Actions → Run workflow). Put [skip unit] in the PR title to no-op that job for docs/chore-only changes (still reports green; workflow_dispatch always runs full). Bots push to master via boxlore-master-pusher (ruleset Integration bypass).

Protected inputs: app/google-services.json is gitignored; CI writes a non-secret stub.

Conventions

  • Prefer constructor injection + fakes (core.testing.fakes) over getInstance in new tests.
  • Hard ViewModels use assemblers + ports from :core:domain and Turbine + MainDispatcherExtension when constructible; otherwise exhaust logic/ packages.
  • Do not rewrite feature/player v2/logic behavior when migrating runners.
  • Keep DataStore name user_preferences, DB filename, and rss: / negative IDs stable in fixtures.
  • Room/Robolectric DAO tests need unitTests.isIncludeAndroidResources = true where required.
  • Workers that need listen history use HistoryRecommendationSource / ports — not a second PlaybackRepository.

Module README checklist

Every app/, core/*/, and feature/*/ module keeps a folder README. Shape: MODULE_README_TEMPLATE.md. Konsist fails if an included module lacks README.md.