How to Test Session Management on Android (Complete Guide)

Session management is the glue that keeps a user’s identity, preferences, and state consistent across activities, fragments, services, and process restarts. When it works, the app feels seamless: a us

May 06, 2026 · 17 min read · How-To Guides

Why Session Management Matters on Android

Session management is the glue that keeps a user’s identity, preferences, and state consistent across activities, fragments, services, and process restarts. When it works, the app feels seamless: a user can leave the app, answer a call, or switch to another task and return to exactly where they left off. When it fails, the consequences are immediate and visible—users are logged out unexpectedly, data is lost, or the app crashes while trying to restore a stale token.

From a business perspective, broken sessions translate into abandoned carts, failed sign‑ins, and negative reviews. From a security standpoint, a session that is not properly invalidated can leave an access token exposed to a malicious app or a rooted device. Because Android’s process model is aggressive—background kills, low‑memory reclamation, and battery optimizations can terminate your app at any moment—session logic must survive these events without relying on the UI thread alone.

Testing session management therefore is not a nicety; it is a core reliability activity that touches functional correctness, user experience, and security. The following sections lay out a complete, practical approach to verify that your Android app handles sessions correctly under the full range of conditions it will encounter in the wild.

Common Session‑Related Failures in Production

Before diving into test techniques, it helps to know what typically goes wrong. The list below summarizes failure patterns observed in real‑world Android apps, grouped by root cause.

Failure PatternTypical SymptomRoot Cause
Token cleared on process killUser sees login screen after returning from backgroundSession stored only in memory or in SharedPreferences without persistence
Duplicate login after rotationTwo overlapping login flows, UI shows “already logged in” toastSession state not retained across configuration changes
Silent token expirationAPI calls return 401 without user interaction, app shows generic errorNo refresh‑token handling or background token validation
Access token leaked via logsToken appears in Logcat, accessible to any app with READ_LOGS permissionDebug logging left in release build
Inconsistent state after multi‑windowApp shows stale UI in one window while another window reflects fresh dataSession not broadcast to all UI components via LiveData or ViewModel
Accessibility service interferesTalkBack reads outdated user name after loginUI updates not announced via accessibilityEvent
Battery optimization kills background servicePeriodic sync stops, user sees outdated data after device idleService not declared as foreground or exempt from battery optimizations
Credential storage migration failureAfter OS upgrade, login fails because old SharedPreferences token unreadableApp did not handle migration from plaintext to EncryptedSharedPreferences

These patterns inform the test matrix that follows: each cell maps to a concrete verification you can perform manually or automate.

Test Matrix for Session Management

A structured matrix helps you ensure coverage across happy paths, error paths, edge cases, accessibility, and security. The table below assigns a unique identifier (ID) to each test case, a short description, the expected outcome, and the recommended verification method (manual, automated, or tool‑assisted).

IDCategoryDescriptionExpected OutcomeVerification Method
S1Happy PathUser logs in with valid credentials, navigates to home, then backgrounds the app for 30 s and returns.User remains logged in, UI shows correct profile name, no re‑login prompt.Manual (adb shell am start) or Espresso flow
S2Happy PathDevice rotation (portrait ↔ landscape) occurs while on a protected screen.Session persists, UI does not flicker, no loss of data.Manual rotation or Espresso onView(...).perform(pressKey(KeyEvent.KEYCODE_0))
S3Happy PathUser logs out via settings screen, then immediately attempts to re‑login with same credentials.New session created, old token invalidated, no token reuse.Automated API mock + UI assertion
S4Error PathInvalid credentials submitted; server returns 401.Login error shown, no session stored, user stays on login screen.Unit test of ViewModel + Espresso error message check
S5Error PathNetwork loss after successful login but before token is persisted.App retains login state locally, retries sync when network returns, does not prompt login again.Manual wifi off/on or adb shell emulation network
S6Edge CaseSystem kills app process while user is on a deep‑linked screen (e.g., from notification).Upon relaunch via launcher, app restores deep link and shows correct screen with session intact.adb shell am kill then launch via URI
S7Edge CaseUser enables “Don’t keep activities” developer option.Session survives activity destruction; returning to app shows logged‑in state.Toggle setting, background/freeze cycle
S8Edge CaseApp runs in split‑screen mode; user interacts with other app, then returns.Both panes show consistent session state; no UI tearing.Manual split‑screen test or UIAutomator multi‑window script
S9AccessibilityTalkBack is enabled; user logs in and navigates to profile screen.Focus order announces updated user name; no stale announcements.Manual TalkBack verification or Accessibility Test Framework
S10SecurityApp stores refresh token in plain SharedPreferences.Token is encrypted; attempting to read file directly yields ciphertext.Manual file inspection (run-as) or Stetho plugin
S11SecuritySession token appears in Logcat via Log.d.No token visible in logcat after release build; only non‑sensitive tags appear.adb logcat filter + grep
S12PrivacyApp shares session ID with third‑party analytics SDK without user consent.Analytics call does not contain session identifier when opt‑out is toggled.Network interception (Charles/Proxy) + consent toggle
S13StressRapid login/logout cycle 50 times in a loop.No memory leak, no ANR, token rotation works each iteration.Automated loop with UIAutomator or JUnit
S14Cross‑OSDevice upgraded from Android 12 to Android 13; app reads old token from SharedPreferences.Migration routine runs, token moved to EncryptedSharedPreferences, login succeeds.Manual OS upgrade simulation via emulator snapshot

The matrix is intentionally exhaustive but not prescriptive; you can prioritize based on risk. Notice how each test targets a concrete failure mode from the previous section, ensuring that verification is tied to observable outcomes rather than vague “check that it works”.

Happy Path Tests (S1‑S3)

These validate that the core login‑maintain‑logout cycle behaves as expected when the environment is stable. They form the baseline; any deviation here indicates a fundamental flaw in session storage or state propagation.

Error Path Tests (S4‑S5)

Error paths confirm that the app degrades gracefully when authentication fails or the network disappears. Proper handling prevents users from being stuck in a login loop or losing data unnecessarily.

Edge‑Case Tests (S6‑S8)

Android’s process lifecycle is aggressive. These tests force the system to kill, recreate, or resize your app while a session is active, exposing assumptions about where state is kept.

Accessibility Tests (S9)

Accessibility is often overlooked in session logic. A session change must be announced to users who rely on screen readers; otherwise they may believe they are still logged out after a successful login.

Security & Privacy Tests (S10‑S12)

Tokens and identifiers are high‑value assets. These checks verify that storage is encrypted, logs are sanitized, and data sharing respects user consent.

Stress & Cross‑OS Tests (S13‑S14)

Long‑running sessions and platform upgrades reveal latent bugs such as memory leaks, improper migration, or reliance on deprecated APIs.

Manual Testing Approach

Manual verification remains valuable for exploratory checks, especially when you need to observe UI nuances or device‑specific behaviors that automated scripts may miss. Below is a step‑by‑step guide you can follow on a physical device or an emulator.

Setting Up a Test Device

  1. Enable Developer Options – tap Build number seven times in Settings → About phone.
  2. Turn on USB debugging and, if using a physical device, grant the RSA key prompt.
  3. Disable battery optimizations for your app under Settings → Apps → [YourApp] → Battery → “Unrestricted” to isolate test results from aggressive doze mode (you can re‑enable later to test that scenario).
  4. Install the latest debug build via adb install -r app-debug.apk.
  5. Clear existing data to start clean: adb shell pm clear com.example.app.

Step‑by‑Step Session Flow Verification

The following script outlines a manual happy‑path validation (S1) that you can adapt for other cases.

  1. Launch the app from the launcher or via adb shell monkey -p com.example.app -c android.intent.category.LAUNCHER 1.
  2. Navigate to login screen – if the app auto‑routes to home when a session exists, first log out via Settings → Logout to ensure a clean state.
  3. Enter valid credentials (you can use a test account provisioned on your backend).
  4. Tap the login button and wait for the home screen to appear.
  5. Background the app: press the Home key, then immediately open another app (e.g., Chrome) and interact with it for ~30 seconds.
  6. Return to your app via recent‑apps tray or launcher.
  7. Observe: the home screen should display the user’s name or avatar without showing a login prompt.
  8. Optional – verify network call: enable adb logcat | grep "auth" to see if a silent token refresh occurs.

Repeat the same flow but substitute steps 5‑6 with:

Using Android Studio Profiler

The Profiler helps you confirm that session‑related objects are not being leaked.

  1. Open ProfilerMemory while the app is running.
  2. Perform a login, background the app, then force a garbage collection (GC) button.
  3. Observe the Allocated Objects count for classes like SessionManager, AuthRepository, or any ViewModel. A steady increase after each cycle indicates a leak.
  4. Switch to the CPU tab and record a short trace while you rotate the device; look for excessive work on the main thread that could cause jank during state restoration.

Logging and Debugging Techniques


if (BuildConfig.DEBUG) {
    StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
            .detectDiskReads()
            .detectDiskWrites()
            .detectNetwork()
            .penaltyLog()
            .build());
}

Manual testing gives you confidence that the UI behaves as a real user would see it, but it does not scale. The next section shows how to automate the same checks and go beyond what a human can reliably repeat.

Automated Testing Approaches

Automation turns the manual steps from‑matrix into repeatable checks that run on every commit. Android offers a layered testing pyramid: unit tests for pure logic, instrumentation tests for UI interactions, and UIAutomator for cross‑app or system‑level scenarios. Below we detail each layer with concrete snippets.

Unit and Instrumentation Tests for Session Logic

Start by isolating the session‑management class (often a Repository or ViewModel). Mock the networking layer and verify that tokens are saved, cleared, and refreshed correctly.


// SessionRepositoryTest.kt
@RunWith(MockitoJUnitRunner::class)
class SessionRepositoryTest {

    @Mock private lateinit var authApi: AuthService
    @Mock private lateinit var prefs: SharedPreferences
    private lateinit var repository: SessionRepository

    @Before fun setUp() {
        MockitoAnnotations.initMocks(this)
        repository = SessionRepository(authApi, prefs)
    }

    @Test fun `login saves encrypted token`() {
        val fakeToken = "enc_token_123"
        `when`(authApi.login(any(), any())).thenReturn(Result.success(fakeToken))

        repository.login("user", "pass")

        // Verify that the token is stored via the encrypted prefs wrapper
        verify(prefs).edit()
                .putString(SessionRepository.KEY_TOKEN, fakeToken)
                .apply()
    }

    @Test fun `logout clears token and invalidates server side`() {
        `when`(authApi.logout(any())).thenReturn(Result.success(Unit))

        repository.logout()

        verify(prefs).edit()
                .remove(SessionRepository.KEY_TOKEN)
                .apply()
        verify(authApi).logout(any())
    }
}

Instrumentation tests (placed in androidTest) validate that the UI reacts to changes in the session repository.


// LoginFlowTest.kt
@RunWith(AndroidJUnit4::class)
class LoginFlowTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test fun `loginSuccess shows home and retains session after rotation` {
        // Given a mocked auth server that returns a token
        val server = MockWebServer()
        server.enqueue(MockResponse().setResponseCode(200)
                .setBody("{\"token\":\"abc\"}"))
        // Inject server via dependency‑injection framework (e.g., Hilt test)

        // When
        onView(withId(R.id.email)).perform(typeText("test@example.com"), closeSoftKeyboard())
        onView(withId(R.id.password)).perform(typeText("Password1!"), closeSoftKeyboard())
        onView(withId(R.id.loginBtn)).perform(click())

        // Then home screen is visible
        onView(withText("Welcome, test@example.com")).check(matches(isDisplayed()))

        // Rotate device
        ActivityScenario.launch(MainActivity::class.java)
        onView(withText("Welcome, test@example.com")).check(matches(isDisplayed()))
    }
}

These tests run fast on the JVM or on an emulator and give you confidence that the core logic is sound.

UI Tests with Espresso for Session‑Specific Scenarios

Espresso shines when you need to assert UI state after background events. Use IdlingResource to wait for asynchronous work such as token refresh.


// TokenRefreshIdlingResource.kt
class TokenRefreshIdlingResource(private val repository: SessionRepository) :
    IdlingResource {

    private var callback: IdlingResource.ResourceCallback? = null
    private var refreshInFlight = false

    init {
        repository.addRefreshListener(object : SessionRepository.RefreshListener {
            override fun onRefreshStart() { refreshInFlight = true }
            override fun onRefreshEnd(success: Boolean) {
                refreshInFlight = false
                callback?.onTransitionToIdle()
            }
        })
    }

    override fun getName() = "TokenRefresh"
    override fun isIdleNow() = !refreshInFlight
    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
        this.callback = callback
    }
}

// In test
@Test fun `session survives background kill` {
    // Login
    onView(withId(R.id.email)).perform(typeText("user@test.com"), closeSoftKeyboard())
    onView(withId(R.id.password)).perform(typeText("Pwd!"), closeSoftKeyboard())
    onView(withId(R.id.loginBtn)).perform(click())

    // Background the app
    InstrumentationRegistry.getInstrumentation()
            .runOnMainSync { 
                // Simulate Home key
                val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
                intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
                ActivityScenario.launch(intent.componentName!!)
            }

    // Wait for possible token refresh
    val idling = TokenRefreshIdlingResource(repository)
    IdlingRegistry.getInstance().register(idling)

    // Return to app
    onView(withId(R.id.nav_home)).perform(pressBack()) // opens recent apps then selects our app

    // Assert still logged in
    onView(withText("Welcome, user@test.com")).check(matches(isDisplayed()))

    IdlingRegistry.getInstance().unregister(idling)
}

Espresso tests can be run on Firebase Test Lab or a local emulator with ./gradlew connectedAndroidTest.

Using UIAutomator for Cross‑App Scenarios

When you need to verify that your app behaves correctly after another app interferes (e.g., a password manager autofills, or a device admin policy locks the screen), UIAutomator is the right tool.


// SessionSurvivesUiAutomatorTest.java
@RunWith(AndroidJUnit4::class)
public class SessionSurvivesUiAutomatorTest {

    private UiDevice device;

    @Before
    public void setUp() {
        device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
    }

    @Test
    public void testSessionAfterKeyguardLock() throws Exception {
        // Launch app and login
        Context ctx = InstrumentationRegistry.getTargetContext();
        Intent launchIntent = ctx.getPackageManager()
                .getLaunchIntentForPackage(ctx.getPackageName());
        launchIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
        ctx.startActivity(launchIntent);

        // Perform login via UIAutomator (similar to Espresso but in separate process)
        // ... login steps ...

        // Lock the device (simulate power button then immediate unlock)
        device.pressPower();
        Thread.sleep(500);
        device.wakeUp();
        device.swipe(500, 1000, 500, 300, 10); // upward swipe to unlock

        // Return to app via recent apps
        device.pressRecentApps();
        UiObject recentApp = device.findObject(new UiSelector()
                .descriptionContains("YourApp"));
        recentApp.clickAndWaitForNewWindow();

        // Verify session still present
        UiObject welcome = device.findObject(new UiSelector()
                .textContains("Welcome"));
        assertTrue(welcome.waitForExists(5000));
    }
}

UIAutomator tests run completely outside your app’s process, making them ideal for checking that session state survives system‑level interruptions such as keyguard, battery‑optimizer dialogs, or incoming calls.

Leveraging SUSA for Autonomous Exploration

While scripted tests cover known paths, they rarely exercise the countless combinations of user behavior, device state, and timing that occur in the wild. An autonomous QA agent can complement your test suite by exploring the app without pre‑written steps, using personas that mimic real users.

How it works in practice:

  1. Upload the latest APK to the SUSA platform or point the CLI at a test server URL.
  2. Choose a set of personas (e.g., *impatient* – rapid taps, *elderly* – long press durations, *adversarial* – attempts to inject malformed input).
  3. The agent launches the app on a device farm, begins interacting, and automatically handles dialogs, permission prompts, and system UI.
  4. As it walks through the app, it builds a graph of screens and transitions. Whenever it detects a session‑related action (login, logout, token refresh), it records the preceding and following states.
  5. After the exploration phase, SUSA generates regression scripts: Appium scripts for Android and Playwright scripts for any embedded web views. Those scripts can be added to your CI pipeline to catch regressions that unit or Espresso tests missed.

Because the agent does not rely on hard‑coded locators, it will try unexpected sequences—like tapping the overflow menu while a login request is in flight, or rotating the device mid‑animation. Those actions often surface session bugs such as:

Integrating SUSA does not replace your existing test suite; it adds a layer of *behavioral* coverage that catches edge cases only discovered through varied, realistic interaction patterns. You can run a short exploratory session nightly and keep the generated scripts as a living regression suite.

CI Integration Tips

To make session‑management testing a gate in your pipeline:

StageToolCommandPurpose
Unit./gradlew testRuns pure‑logic JUnit tests (including SessionRepository).
Instrumentation./gradlew connectedAndroidTestExecutes Espresso and UIAutomator tests on attached devices/emulators.
Autonomoussusatest-agent run --apk app-release.apk --personas curious,impatient,adversarial --output reports/Launches SUSA exploration; fails the job if any crash, ANR, or WCAG‑AA violation is found.
Static Analysisdetekt or lintChecks for hard‑coded tokens, improper logging, or missing EncryptedSharedPreferences usage.
Deploymentfastlane betaAfter all tests pass, distributes the build to internal testers.

Add a post-success step that archives the generated Appium/Playwright scripts from SUSA so that future runs can reuse them as baseline regression tests.

Tooling Deep‑Dive

Effective session testing leans on a handful of Android‑specific utilities. Below we detail the most useful commands and libraries, with concrete examples you can copy into your workflow.

ADB Commands for Session Inspection

These commands let you reproduce the exact conditions that cause session loss without needing to write UI automation.

Using Stetho / Flipper for Network & SharedPrefs

Integrating Flipper (the successor to Stetho) gives you a desktop UI to inspect runtime state.

  1. Add the dependency:
  2. 
       debugImplementation 'com.facebook.flipper:flipper:0.156.0'
       debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.156.0'
       debugImplementation 'com.facebook.flipper:flipper-sharedpreferences-plugin:0.156.0'
    
  3. Initialize in Application.onCreate():
  4. 
       if (BuildConfig.DEBUG) {
           FlipperInitializer.initialize(Flipper.getInstance(this))
                   .addPlugin(new NetworkPlugin(OkHttpClient.Builder()))
                   .addPlugin(new SharedPreferencesPlugin())
                   .start();
       }
    
  5. Run the app, open Flipper desktop, and you can:

Flipper is especially handy for verifying that your EncryptedSharedPreferences wrapper actually encrypts values; the plugin will show ciphertext rather than plaintext.

Checking Token Storage (Keystore, EncryptedSharedPreferences)

If you encrypt tokens, verify that the encryption key is bound to hardware-backed keystore when available.


fun createEncryptedPrefs(context: Context): SharedPreferences {
    val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .setUserAuthenticationRequired(false) // or true for biometric-bound
        .build()

    return EncryptedSharedPreferences.create(
            context,
            "secure_prefs",
            masterKey,
            EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
            EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )
}

To test that the key is hardware-backed on devices that support it:


val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val entry = keyStore.getEntry("master_key", null) as? KeyStore.SecretKeyEntry
entry?.let {
    val spec = it.keySpec as AndroidKeyStoreSecretKeySpec
    Log.d("Crypto", "Hardware backed: ${spec.isInsideSecureHardware()}")
}

Run this check on a range of devices (emulator with Google Play APIs, a Pixel, and a low‑end device) to confirm graceful fallback to software encryption when hardware is unavailable.

Analyzing Crash Logs for Session‑Related ANRs

ANRs often surface when session restoration blocks the main thread (e.g., synchronous disk read of a large token blob). Use the following workflow:

  1. Reproduce the ANR (e.g., rapid login/logout while rotating).
  2. Capture the tombstone: adb bugreport > bugreport.zip then extract tombstones/tombstone_XX.
  3. Look for the “main” thread trace; a typical pattern:

"main" prio=5 tid=1 Runnable
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x7f5a0000 self=0x7f8b2c3000
  | sysTid=123454 cgrp=default sched=0/0 handle=0xf78b2c3000
  | state=R schedstat=( 1200000000 8000000 20 ) utm=110 stm=10 core=2 HZ=100
  | #00  pc 0000000000045678  /system/lib/libc.so (syscall+28)
  | #01  pc 0000000000067890  /system/lib/libart.so (art::Lock::Lock(bool)+...)
  | #02  pc 00000000000abcde  /data/app/com.example.app-1/lib/arm/libapp.so (SessionRepository.loadToken+12)

If you see a call to loadToken or SharedPreferences.getString on the main thread, move that work to a Coroutine (Dispatcher.IO) or WorkManager.

Additionally, enable StrictMode.VmPolicy to catch accidental disk reads:


if (BuildConfig.DEBUG) {
    StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
            .detectLeakedSqlLiteObjects()
            .detectLeakedClosableObjects()
            .penaltyLog()
            .penaltyDeath()
            .build());
}

Edge Cases That Only Appear in Production

Even the most thorough lab testing can miss issues that arise only under specific real‑world conditions. Below are several production‑only phenomena that have caused session bugs in the field, along with how to detect or mitigate them.

Background Kill and Restore

Android may kill your app’s process while it is in the background to free memory. If your session relies solely on an in‑memory singleton, the user will be logged out on return.

Detection:

Mitigation:

Device‑Level Battery Optimizations

Doze mode and app standby can defer alarms, jobs, and foreground service execution, breaking periodic token‑refresh logic.

Detection:

Mitigation:

Multi‑Window and Split‑Screen Behaviors

When your app shares the screen with another, the system may pause one of your activities while keeping others alive. If you store session state only in a paused activity, the visible fragment may show stale data.

Detection:

Mitigation:

OS‑Level Credential Storage Changes (Android 13+)

Android 13 introduced a new Credential Manager API and tightened access to SharedPreferences for apps targeting API 33+. If you still write tokens to raw SharedPreferences, they may be ignored on newer devices.

Detection:

Test Your App Autonomously

Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.

Try SUSA Free