Debugging and Testing Android Apps Efficiently
The Logcat, ADB, profiler, and test workflow I use on every Android project — so you stop wasting hours staring at a wall of logs and start finding the bug.
So, in this article, I will be showing you how you can debug and test your Android apps efficiently — the exact commands, profiler tools, and test patterns I use in production projects, in the order I actually use them. Because the truth about Android debugging is that most of the time is not spent finding the bug. It is spent drowning in output that has nothing to do with the bug. Efficiency here is not about working faster; it is about knowing what to ignore.
This is a practical walkthrough: Logcat done right, the ADB commands that save real time, the profiler, memory leak hunting, then the test pyramid from unit tests to Firebase Test Lab. Let's jump into the technical part.
1. Logcat: Stop Reading the Firehose
The single biggest time-waster in Android development is scrolling through raw Logcat output. Your app logs, the system logs, other apps log — all into one unfiltered stream. The fix is that Logcat is a query language, and you should treat it like one.
Filter by your app's process, not by sight. The golden command — log only your PID:
adb logcat --pid=$(adb shell pidof -s com.your.app.package)
Run that and the noise of every other app on the device disappears. pidof resolves the process ID of your package, and --pid restricts the stream to it. On a real device or an emulator, this one command eliminates 90 percent of the visual noise.
Tag everything in code. The habit that pays off daily:
private const val TAG = "CartViewModel"
Log.d(TAG, "total recomputed: $total")
Log.e(TAG, "checkout failed: ${e.message}", e)
Consistent tags turn Logcat into a searchable index instead of a firehose. You can then filter by tag in the Logcat window (tag:CartViewModel), or from the command line:
adb logcat -s CartViewModel:V
-s sets silent as the default and enables only the tags you name — your log lines, and nothing else. Note the priority levels too: V verbose, D debug, I info, W warn, E error. A common efficiency trick is to set the filter to W and above when you are hunting a crash, so debug chatter disappears.
2. ADB: The Commands That Save Real Time
The Android Debug Bridge is where the daily time savings live. A handful of commands cover most production debugging:
- Wireless debugging — plug in once, pair, and stop fighting USB cables:
adb pairon Android 11+ followed byadb connect <ip>:5555. - Screen recording for a bug report — instead of describing a crash to a colleague, record it:
adb shell screenrecord --time-limit 30 /sdcard/bug.mp4, then pull it withadb pull. - Restart only the activity, not the whole app — when the layout is wrong and you do not need a fresh process:
adb shell am start -n com.your.app/.MainActivity. - Force-stop for clean-state tests —
adb shell am force-stop com.your.appis how I guarantee a cold start without reinstalling. - Reverse port forwarding — when your app talks to a local backend:
adb reverse tcp:8080 tcp:8080maps your machine's port into the device, solocalhostjust works.
None of these are new, but I am consistently surprised how few developers use them. The am start + screenrecord pair alone has saved me more hours than any single IDE feature.
3. The Android Studio Profiler: Read the Graph Before the Code
When the app is slow, do not guess — profile first. The CPU Profiler shows you where time actually goes, and it routinely contradicts developer intuition. The workflow I use:
- Record a CPU trace while reproducing the problem.
- Look at the Top Down flame chart for the function that owns the most self time.
- Question that function before reading any code. The bug is usually a surprise (a surprise JSON parse, a synchronous DB call on the main thread), and the trace will hand it to you by name.
Same for memory: the Allocation Profiler shows object churn, and the Memory Profiler shows heap growth. If your app's memory graph climbs steadily instead of sawtoothing, you have a leak — which brings us to the tool that finds it for you.
3.5. Debugging ANRs: The Crash That Is Not a Crash
One failure mode deserves its own treatment because its symptoms are a lie. An ANR — Application Not Responding — looks like a frozen app, but it is a main-thread problem, not a rendering one. When input events stop being processed, Android waits roughly five seconds and then kills your process with the dreaded "app isn't responding" dialog. The dialog is a smokescreen; the real answer lives in the ANR traces.
When it strikes, the fastest move is a bug report, then open traces.txt from the archive — it shows the exact main-thread stack at the moment the process hung:
adb bugreport
# extract and open ANR/traces.txt → main thread stack under "main"
The usual suspects are exactly what the trace will show you: a synchronous network call on the main thread, a giant JSON parse, a SharedPreferences commit at scale, or an unbounded database query. Fixing an ANR is trivial once the trace names the line; finding the trace is the skill. And then write a test for the offender — a unit test with runTest and a forced delay catches main-thread surprises long before they reach your users.
4. LeakCanary: Install the Leak Detector
Do not hand-hunt memory leaks. Add LeakCanary to your debug build and it finds them for you — it watches your activities and fragments, and when one is destroyed but still referenced (usually by a long-lived object holding an Activity context), it dumps the heap and shows you the reference chain in the notification.
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
releaseImplementation("com.squareup.leakcanary:leakcanary-android-no-op:2.14")
The no-op in release means zero production cost. Run the app for a day, check the LeakCanary notifications, fix the reference chains it flags — this has caught leaks I never would have found by reading code, and it catches them early, when the heap dump still points cleanly at the cause.
5. Unit Tests: The Fast Feedback Layer
The bottom of the test pyramid is where speed lives. A good unit test runs in milliseconds on your machine with no device, and the loop — edit, run, iterate — is what makes you fast. The stack I use on a Kotlin project:
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("io.mockk:mockk:1.13.12")
kotlinx-coroutines-test gives you runTest, which makes suspend functions deterministic — fake time, skip real delays. MockK replaces your repository and database dependencies so the test exercises only the logic you care about. A representative test:
class CartViewModelTest {
@Test
fun `total equals sum of items`() = runTest {
val repo = mockk<CartRepository>()
every { repo.items() } returns listOf(
CartItem(price = 10.0),
CartItem(price = 15.0)
)
val vm = CartViewModel(repo)
assertEquals(25.0, vm.total, 0.001)
}
}
That test is not impressive on its own. Its value is that it runs in half a second, so you will actually run it — a thousand times over a project's life — and every regression gets caught at the source instead of on a device. Cover your ViewModels and your business logic first; they are where the rules live.
6. UI Tests: Verify the User's Path
Unit tests prove the logic; UI tests prove the app works the way a person uses it. For Compose, the test API is compact and stable:
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun checkoutButton_isVisibleAndClickable() {
composeTestRule.setContent { CheckoutButton() }
composeTestRule.onNodeWithTag("checkout")
.assertIsDisplayed()
.performClick()
}
The rule of thumb that keeps UI tests from becoming a second maintenance burden: test user journeys, not components. One test for "add item → cart updates → checkout visible," not a test per button. And use Modifier.testTag() on the elements you assert — testTag is stable, where text selectors break the moment someone edits a label.
7. Instrumented Tests at Scale: Firebase Test Lab
Your unit tests run on your machine, your UI tests run on one emulator, and the reality is that Android's fragmentation means neither is enough. When a crash shows up only on one device, push the tests to a fleet. Firebase Test Lab runs your instrumented tests across a matrix of physical and virtual devices in the cloud — upload the debug APK plus the test APK, select the device matrix, and read the crash traces it collects per device.
The pattern I use: run the full instrumented suite on three representative devices — a low-end phone, a mid-range phone, and the latest flagship — on every release. That is a small matrix, it runs in minutes, and it catches the device-specific bugs (GLES version, screen size, OEM skin) that no single device in your office will ever reveal. Gradle managed devices in the emulator can cover the same ground locally in CI, but for physical-device coverage, Test Lab is the cheapest insurance you will buy.
The Pitfalls That Make Debugging Slow
- Debugging with
releasebuilds. ProGuard and R8 rename your classes and strip your logs, so the stack trace saysa.b.c. Always reproduce in adebugbuild (or aminifyEnabled falsebuild) where the names are real. - Not using breakpoints. The step-over habit is fine for small flows, but Android's lifecycle makes "just step through" slow. Set conditional breakpoints, use the Evaluate Expression panel, and let the debugger skip what you do not need.
- Logging every line. Log spam makes real logs invisible. Log at decision points and error sites, tag them consistently, and keep the chatter out of
V. - Trusting one device. A bug that does not reproduce on your Pixel but does on a budget phone is still a bug. Reproduce on the low-end device before you declare it "not a bug."
- No automated tests for the path you broke. The costliest debugging is the same bug twice. If you fixed it by hand, write the test that would have caught it — the fix is not done until the regression test is green.
The Efficient Workflow Checklist
-
adb logcat --pid=$(adb shell pidof -s <pkg>)for every debugging session - Tags consistent, filter by
tag:in the Logcat window - Profiler trace captured before reading suspicious code
- LeakCanary on debug, no-op on release
- Unit tests for ViewModels and business logic (runTest + MockK)
- UI tests for user journeys only, with
testTagselectors - Three-device matrix on Firebase Test Lab per release
- Debug builds for stack traces, release-only bugs reproduced with minify off
- A regression test written for every bug you fix by hand
That is the whole system. Debug by filtering before you read, profile before you guess, let LeakCanary find your leaks, and push your tests up the pyramid so the fast ones run constantly and the device-specific ones run on every release. The tooling is secondary; the loop is primary — and once the loop is in place, debugging stops being a fire drill and becomes a method. Done this way, the hours you used to spend staring at a wall of logs go back into shipping.
Comment below with the Android debugging problem you fight most — a flaky UI test, a memory leak, a release-only crash — and I will cover it in the next one.
*Gulshan Yad
Key Takeaways
- Use Android Studio’s Live Edit feature to apply code changes and see updated UI without rebuilding the entire app.
- Set up a dedicated logging strategy: use a constant tag, structured key‑value pairs, and a library like Timber to keep logs readable and searchable.
- Run unit tests with JUnit5 and Mockito on the JVM, then use JaCoCo to generate coverage reports and enforce thresholds in CI.
- Write instrumented UI tests with Espresso, handling asynchronous work through IdlingResources, and run them on a device farm to cover multiple API levels.
- Leverage Android Profiler to capture CPU, memory, and network traces, then drill down into flame graphs and heap dumps to locate performance bottlenecks.
1 followers
AI systems builder · 7 years in production. RAG, self-hosted infra, agent architecture. 📬 Deep-dives → mrgulshanyadav.substack.com





Comments
Sign in to join the conversation
No comments yet. Be the first to share your thoughts!