Skip to content

Harden the test safety net before refactoring - #76

Merged
dadachi merged 3 commits into
mainfrom
test/harden-safety-net
Oct 13, 2026
Merged

dadachi merged 3 commits into
mainfrom
test/harden-safety-net

Conversation

@dadachi

@dadachi dadachi commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Phase 4 (test hardening) before the refactoring phase: characterization tests of the current behavior of the code phase 5 is likely to touch. Test-only. No production code changes.

Coverage, measured locally (not committed)

JaCoCo via a temporary enableUnitTestCoverage = true (plus includeNoLocationClasses so the Robolectric tests count), reverted before committing. I didn't add a plugin because it would also need CI wiring. Line coverage of the Kotlin sources, excluding *View.kt composables and designsystem/:

before after
total 60.0% (1721/2870) 68.8% (1974/2870)
ItemTagRepositoryImpl / ShopRepositoryImpl / SignUpRepositoryImpl / AccountPasswordRepositoryImpl 0% covered for every call (RepositoryRequestsTest)
LoginRepositoryImpl 43.6% 75.6%
NetModule 32.4% 78.4%
NativeAppTemplatePreferencesDataSource 65.7% 96.4%
ApiResponseExtensions 69.6% 84.8%
NativeAppTemplateAppState 0% 69.8%

Still untested and not addressed here: navigation graphs (*Navigation.kt, NativeAppTemplateNavHost), MainActivity, NativeAppTemplateApp, ConnectivityManagerNetworkMonitor, the Hilt modules, and Utility intents. These are Compose/Android glue, which suits instrumented or UI tests better.

What's added

Network and repositories (data/RepositoryRequestsTest, new). Each repository runs through NetModule's real provideOkHttp and provideRetrofit (re-pointed with newBuilder().baseUrl), with AuthInterceptor and AcceptLanguageInterceptor, against a JDK HttpServer. The tests pin down:

  • the method and path of every API call, including the account id from the stored session, ?page= (and no query when the page is null), and the complete/idle routes
  • the request bodies: an empty item-tag description is sent, a null shop description is sent as null, the update-password, locale-only and sign-up payloads
  • the session headers and Accept-Language
  • logout clears the stored session on both success and failure
  • a 422 {code, error_message} becomes ApiError; a 401 devise body becomes isUnauthorized

ApiException mapping (ApiResponseExtensionsTest). Adds devise's {"errors":{"full_messages":[...]}}, JSON and empty bodies with no message ("HTTP <status>"), and the case where the body's code differs from the HTTP status.

AuthInterceptor host check. The API host is matched case-insensitively (RFC 4343).

DataStore (NativeAppTemplatePreferencesDataSourceTest). Covers the stored session fields, shouldUpdateApp against BuildConfig.VERSION_CODE (newer means an update is needed, equal means it isn't), the dark-theme round trip and its FOLLOW_SYSTEM default, the flags, and clearUserPreferences resetting everything.

Session bootstrap.

  • NativeAppTemplateAppStateTest (new) uses the real LoginRepositoryImpl and DataSource. authDestination is null until collected, then ONBOARDING → SHOPS → ACCEPT_PRIVACY → NEED_APP_UPDATES → ONBOARDING. It also covers shouldShowBottomBar, isOffline and the deletion flags.
  • MainActivityViewModelTest: updatePermissions saves the permissions when signed in and fetches nothing when signed out.
  • SignInEmailAndPasswordViewModelTest: rejected credentials show the coded message and save nothing. A sign-in clears the previous preferences before saving the new session. TestLoginRepository gains failLogin.

ItemTagList pagination. A page with no meta counts as the last page. A failed reload shows the error but does not set loadMoreFailed.

Data.updateRelationships (model/DataRelationshipsTest, new). This pins down the reported no-op without fixing it. With included, the result equals the original Data, and the shop reference keeps only {id, type}. Without included, it returns the same instance. ItemTags/Shops.getDatumWithRelationships() and ItemTag/Shop.getData() all equal the decoded data.

LoadOnceEffect / LoadOnceState. No new tests. The existing configuration-change and process-death tests already cover it (100% lines once Robolectric is instrumented).

Weak tests fixed

  • ItemTagListViewModelTest.stateShop_whenSuccess… compared the UI state with getDatumWithRelationships() applied to the same data, so the transformation was on both sides. It now compares with the fixtures.
  • SignInEmailAndPasswordViewModelTest and ShopkeeperEditViewModelTest used error.codedDescription as the expected message. They now use literals derived from ApiException.UnprocessableError ("[NATIVEAPPTEMPLATE-2002] Processing error: $rawMessage"). The red check below shows the difference: changing that description now fails both tests, and before it would have gone unnoticed.

I scanned the test sources for tests that assert nothing and found none. Every test the scan flagged either uses @Config or asserts through the Compose test APIs.

Red checks

I broke the code under test temporarily (never committed) and confirmed that each new or changed test failed:

  • Network and repositories:
    • ItemTagApi @Query("page") → "p"
    • removed addInterceptor(acceptLanguageInterceptor) from NetModule
    • completeItemTag → idleItemTag
    • deleteShop emitting false
    • removed clearUserPreferences in each logout branch
    • terms → privacy route
    • locale forced to "en"
    • wrong account id in the password update
    • permissions path changed
    • page ?: 1
    • confirmation path and sign-up locale changed
  • Request bodies: default values on ItemTagBodyDetail.description and ShopBodyDetail.description (both now dropped by encodeDefaults = false)
  • Error mapping:
    • dropped the full_messages branch
    • ApiError.code taken from the HTTP status
    • "HTTP error" fallback
    • isUnauthorized on 403
    • ApiError path disabled
    • RequestHelper always adding the session
  • AuthInterceptor: equals(apiHost) without ignoreCase
  • DataStore:
    • < → <= and > in shouldUpdateApp
    • LIGHT stored as DARK
    • a partial clear
    • isShopDeleted always true
    • expiry stored from the client value
  • App state:
    • bottom bar ignoring terms
    • authDestination starting at ONBOARDING
    • authDestination reading isEmailUpdated in place of shouldUpdateApp
    • isOffline without the not
    • isShopDeleted read from the wrong flow
  • ViewModels:
    • removed clearUserPreferences from sign-in
    • .catchShowingError → .catch {}
    • if (isLoggedIn) → if (true)
    • removed setPermissions
    • totalPages ?: 2
    • loadMoreFailed = true
    • UnprocessableError description text changed
  • Model: updateRelationships returning relationships = null or a copy

Findings for later phases (not fixed here)

  • Signed-out requests drop the account segment. With no stored account id, "{account_id}/api/v1/..." becomes "/api/v1/...", and Retrofit resolves that against the host root. So a request made while signed out goes to a route without the account segment instead of failing locally. RepositoryRequestsTest.aSignedOutRequest_carriesNoTokenAndNoAccountSegment pins this down. Low impact, because screens that call these APIs are only reachable when signed in.
  • Data.updateRelationships is a no-op, and included is never merged. Its callers (getDatumWithRelationships, Shop/ItemTag.getData) can be simplified or removed in phase 5.
  • Sign-out wipes device settings. clearUserPreferences (on sign-out, and on every sign-in) also resets dark mode and didShowTapShopBelowTip. That's a product decision, so I'm reporting it rather than changing it.
  • Unused AppState members. NativeAppTemplateAppState.isLoggedIn, shouldUpdateApp, shouldUpdatePrivacy and shouldUpdateTerms are never read outside the class.

Checks

  • spotlessCheck, lintDebug, :app:minifyReleaseWithR8, test and :app:assembleRelease all pass (run with --no-daemon). Tests: 383 in :app and 15 in :model, 0 failures.
  • No device check: the change is test-only and has no runtime effect.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH

dadachi and others added 3 commits October 13, 2026 09:49
RepositoryRequestsTest runs every repository through NetModule's OkHttp
client and Retrofit converters (with AuthInterceptor and
AcceptLanguageInterceptor) against a local HTTP server, pinning each
call's method, path, query and body, the session and Accept-Language
headers, and how error bodies become ApiExceptions.

ApiResponseExtensionsTest adds devise's full_messages body, JSON and
empty bodies without a message, and the body code vs the HTTP status.
AuthInterceptorTest adds the case-insensitive API host match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
- NativeAppTemplatePreferencesDataSource: the stored session, the
  shouldUpdateApp derivation from BuildConfig.VERSION_CODE, dark mode,
  the flags, and clearUserPreferences resetting everything (including
  dark mode and the shop tip).
- NativeAppTemplateAppState over the real preferences: authDestination
  (null until loaded, then following sign-in and the permissions),
  shouldShowBottomBar, isOffline and the deletion flags.
- Data.updateRelationships and its callers: "included" is merged into
  nothing today; the results equal the decoded data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
- SignInEmailAndPasswordViewModel: rejected credentials show the coded
  message and save nothing; a sign-in clears the previous preferences
  before saving the new session (TestLoginRepository gains failLogin).
- MainActivityViewModel.updatePermissions: saves the permissions when
  signed in, fetches nothing when signed out.
- ItemTagListViewModel: a page without pagination meta is the last one;
  a failed reload does not block loading more.
- Expected values no longer come from the code under test: the item
  tags are compared with the fixtures instead of
  getDatumWithRelationships() of the same data, and the error messages
  with literals derived from ApiException instead of codedDescription.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
@dadachi
dadachi merged commit 30c6f1c into main Oct 13, 2026
1 check passed
@dadachi
dadachi deleted the test/harden-safety-net branch October 13, 2026 00:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant