Test Levels and the Test Pyramid Questions
How unit, integration, component, end-to-end and contract tests fit together and where each provides the most value. Covers the test pyramid and the competing shapes proposed against it (the testing trophy and the honeycomb), multi-layer test architecture, contract testing as the seam between services, choosing the right level to catch a given class of defect cheaply, and what to run per commit versus per release. Includes the cost and confidence trade-offs between fast low-level tests and slower, broader system tests. The scope is which level a test belongs at and why. Deciding how much to invest in testing and where to prioritize under time pressure is covered separately.
For a cross-platform codebase (for example, Flutter or React Native) that includes some native modules, propose how to allocate testing responsibility: which logic to cover with unit tests in the shared code, which native-specific features require per-platform tests, and how you would combine native UI tests with cross-platform end-to-end tests to minimize duplicated effort while maximizing coverage.
Sample Answer
A cross-platform codebase with native modules has three genuinely distinct code populations, and the right test allocation follows directly from which population each piece of logic lives in.
Shared code: unit tests
Any logic that lives in the shared cross-platform layer (business rules, state management, data transformation, API-client logic) should be unit-tested ONCE, in the shared layer itself, using the cross-platform framework's own testing tools (for example Dart tests for Flutter, or Jest for React Native). This is the highest-leverage layer to invest in, since a bug found and fixed here is fixed for every platform simultaneously, and a test written here verifies every platform at once rather than needing platform-specific duplicates.
Native-specific features: per-platform tests
Any feature that genuinely requires native code, camera access, platform-specific biometrics, deep OS integration, cannot be verified by the shared layer's tests at all, since the actual behavior under test lives entirely in platform-specific code the cross-platform framework doesn't execute. These need dedicated per-platform tests: XCTest for the iOS-native module, an equivalent JVM/Android test for the Android-native module, each verifying that platform's actual implementation of the native feature.
Combining native UI tests with cross-platform end-to-end tests
For the small number of end-to-end journeys that matter most, use the cross-platform framework's own end-to-end tooling, and note these tools don't cross over between frameworks: Detox for React Native, or Flutter's official integration_test package for Flutter (Patrol is a common third-party alternative when a test needs to interact with native permission dialogs or other platform UI outside the Flutter widget tree). Either way, drive the FULL app, including native modules, on both platforms of whichever framework you're on, rather than writing separate native-only end-to-end suites for iOS and Android; this avoids the duplicated effort of maintaining two parallel end-to-end suites for logic that's actually shared. Reserve native-only UI tests for verifying a native module's OWN platform-specific interaction details in isolation (does a native camera permission prompt display correctly on this specific OS version), which the cross-platform end-to-end tooling either can't reliably exercise or can't do so with enough platform-specific precision.
Minimizing duplication while maximizing coverage
The discipline that makes this work is asking, for every piece of logic, "does this code path exist identically on both platforms, or does it diverge?" Shared logic gets exactly one test, in the shared layer; genuinely divergent native logic gets exactly one test per platform where it diverges, never both a shared-layer test AND a redundant per-platform test for the same logic. The most common source of wasted effort is testing the SAME shared business logic redundantly inside each platform's native test suite "just to be sure," which adds maintenance cost without adding real coverage, since the shared-layer test already proves that logic correct on every platform that consumes it.
Trade-offs and pitfalls
The trap specific to cross-platform codebases is assuming the shared layer's tests provide FULL coverage of a feature just because most of its logic lives there, when a feature that touches even one native-specific interaction point (a permission prompt, a platform gesture) needs a dedicated native-level test for that specific interaction, layered on top of, not instead of, the shared-layer coverage for the rest of the feature's logic.
Your mobile product uses feature flags extensively. Design a testing strategy that validates feature-flagged code paths across unit tests, staged-rollout validation, and end-to-end permutations, in continuous integration, without exploding the total number of tests. Discuss API-driven flag control for tests and safeguards to prevent false positives or false negatives caused by the flags themselves.
Sample Answer
Feature flags multiply a mobile app's effective state space (every flag combination is a distinct version of the app), so a naive "test every combination at every level" approach explodes combinatorially; the strategy has to deliberately choose which level handles which part of that explosion.
Unit tests: the flag-evaluation logic itself
Test the function that DECIDES a flag's effective value (given the stored flag state, any user segment, any override) in complete isolation, covering on, off, and each override case directly, with no UI or network involved. This is where you can afford to test every meaningful combination of INPUTS to the decision logic, since it's cheap; it is not where you test every combination of flags interacting with the rest of the app, since that's a different, much larger problem handled at other levels.
Staged-rollout validation
Separately from the per-flag decision logic, test the ROLLOUT MECHANISM itself: given a configured rollout percentage, does the flag system correctly and consistently assign a given user or device to the flagged or unflagged group, and does that assignment stay stable across app launches (a user shouldn't flip between variants on every relaunch). This is a narrow, dedicated test of the rollout infrastructure, not of every feature that happens to use it.
End-to-end permutations, without exploding test count
Rather than testing every combination of every flag end-to-end, test each flag's ON and OFF state independently against the app's DEFAULT configuration for every other flag, plus a small, deliberately chosen set of specific combinations known to interact (two flags that touch the same screen or the same underlying data), identified by reviewing which flags share code paths rather than guessing. This keeps end-to-end test count linear in the number of flags rather than exponential, at the accepted cost of not exercising every theoretical combination, which is a reasonable trade given that most flag pairs genuinely don't interact at all.
API-driven flag control for tests
Expose an API or test-only override mechanism that lets automated tests set a specific flag state directly for a test run, rather than relying on the same staged-rollout percentage mechanism real users go through; this makes end-to-end tests deterministic (a test can force a flag ON or OFF reliably) instead of depending on random assignment, which would make the same test flaky from run to run.
Safeguards against false positives and false negatives from the flags themselves
A false negative (a real bug hidden because a test always ran with a flag in one particular state) is guarded against by ensuring the test suite explicitly exercises both states for every flag that affects a tested code path, not just whatever the default happens to be. A false positive (a test failing only because of unrelated flag state, not because of a real bug) is guarded against by having tests explicitly declare which flag states they require rather than implicitly depending on whatever the test environment's defaults happen to be, so a change to an unrelated flag's default doesn't silently break unrelated tests.
Trade-offs and pitfalls
The pitfall specific to feature flags is treating "we have end-to-end coverage" as meaning "we've tested every real configuration users will see," when in practice a permutation-limited approach, by design, doesn't cover every combination; keep an inventory of currently-active flags and periodically review whether any pair now shares enough code to warrant adding a dedicated combination test, since flag interactions can emerge over time even when they didn't exist when each flag was first introduced.
Explain the test pyramid and how it applies specifically to mobile application development. Describe what kinds of tests belong at each level (unit, integration, UI, and end-to-end), give approximate percentage targets for test distribution in a mature mobile project, and explain how and why the pyramid differs from a backend or web application, considering device fragmentation, UI complexity, and platform tooling constraints. Include the specific distinction between an integration test (for example, local-database-plus-repository interactions) and a UI test (for example, navigation and visual states) on mobile, and name recommended tools for unit, integration, UI, and end-to-end tests on native iOS, native Android, and React Native.
Sample Answer
The mobile pyramid keeps the same three-to-four levels as web (unit, integration, UI, end-to-end) but the SHAPE shifts, because mobile carries cost sources web mostly doesn't: device and OS fragmentation, heavier and slower UI tooling, and a release process (app-store review) that makes shipping a fix far slower than a web deploy.
What belongs at each level
- Unit: pure business logic in isolation, exactly as on any platform (a pricing calculation, a validation rule, a view-model's decision logic with its dependencies mocked). No emulator or device needed, fastest and cheapest level.
- Integration: your code against a REAL local dependency, most commonly a local database (Room on Android, Core Data on iOS) or a background sync worker, proving the app's own persistence and data-flow logic is wired correctly, without yet touching a real network or UI.
- UI: navigation flows and visual states rendered on a real or simulated device (does tapping a button navigate to the right screen, does a loading state actually appear), distinct from integration tests because the SUBJECT here is the rendering and interaction layer itself, not the underlying data logic.
- End-to-end: the full app, real network calls (or a close staging equivalent), driven through actual user flows on a real or emulated device, proving the whole assembled app works for a real user.
The integration-versus-UI distinction is worth being precise about: an integration test asking "does saving a setting to the local database actually persist it and load correctly on next launch" is testing data flow with no interest in what's on screen; a UI test asking "does tapping save show a confirmation toast" is testing the interaction and rendering layer specifically, and the two require different tooling and run at different speeds.
Approximate distribution targets for a mature mobile project
A reasonable mature-project target is roughly 65-70% unit tests, 15-20% integration tests, and the remaining 10-15% split between UI and end-to-end tests, skewed slightly higher toward the top of the pyramid than a typical backend service would use, because of the fragmentation risk described below. This is worth sanity-checking by deriving it a second, independent way rather than trusting one pass of reasoning: starting instead from "how many of each test type does a mature mobile codebase typically accumulate" (a large base of fast logic tests, a modest layer of local-DB and sync-worker integration tests, and a small, deliberately curated set of UI and end-to-end tests for the handful of flows that matter most) lands on essentially the same shape, which is a useful confirmation that the target isn't an artifact of one particular line of reasoning.
Why mobile differs from backend or web
- Device fragmentation: a backend service runs on infrastructure you control; a mobile app runs on a long tail of device models, screen sizes, and OS versions you do not control, so UI and end-to-end tests need to cover a MATRIX of device/OS combinations, not just one environment, which increases their real cost disproportionately compared to web.
- UI complexity and tooling: mobile UI tests typically need an emulator or a real device farm, both slower to provision and run than a headless browser, and platform UI-testing frameworks (Espresso, XCUITest) are generally slower and more resource-intensive per test than their web counterparts.
- Release cadence constraints: an app-store review cycle means a bug that reaches production cannot be fixed with an immediate hotfix the way a web deploy can, which raises the cost of an escaped bug and is part of why the top of the mobile pyramid, while still small, tends to get slightly more relative investment than on a fast-deploying web service.
Recommended tools per platform
| Level | Native iOS | Native Android | React Native |
|---|---|---|---|
| Unit | XCTest | JUnit | Jest |
| Integration | XCTest against a real Core Data store | JUnit/Robolectric against a real Room database | Jest against a real local storage layer, or Detox for deeper native module integration |
| UI | XCUITest | Espresso | Detox |
| End-to-end | XCUITest against a staging build | Espresso against a staging build, or a device-farm run | Detox against a staging build, run on both platforms |
For unit testing suitability specifically: business logic (calculations, decisions, data transformations) is almost always a good unit-test candidate; UI-rendering code and platform-specific behavior usually is not, and belongs at the UI or integration level instead. External dependencies (network clients, platform APIs) should be mocked or stubbed at the unit level so the test stays fast and deterministic, with a simple check to judge suitability: if a function's correctness can be verified with only its inputs and outputs, with no real device or platform behavior involved, it belongs at the unit level.
Trade-offs and pitfalls
The most common mobile-specific mistake is under-investing in the integration level specifically, jumping straight from unit tests to full UI/end-to-end tests on a real device, because local-database and background-worker integration testing feels like extra setup work; that gap means bugs in local persistence and sync logic, a common source of real mobile defects, get caught only by the slowest, most fragmented layer instead of a much cheaper one built specifically for them.
Tell me about a time you advocated for improving testing practices on a mobile engineering team. Describe the problems you observed, the changes you proposed (tools, process, or refactoring), how you prioritized and implemented them, the metrics you used to measure success, and how you handled resistance from teammates or product managers.
Sample Answer
A strong answer to this question demonstrates that you correctly diagnosed a SPECIFIC, mobile-particular testing gap, not a generic "we needed more tests" observation, and that you drove the change with evidence rather than just opinion.
How to structure the STAR response
Situation and problems observed: describe a concrete, mobile-specific gap, for example a team that had solid backend test coverage but almost no automated coverage on the mobile client itself, relying instead on manual QA passes before each release, which was becoming a release-cadence bottleneck as the app grew, or a team whose existing mobile UI tests were so flaky that they were routinely ignored, effectively providing zero real signal despite real engineering time spent maintaining them.
Changes you proposed: be specific about WHICH level and WHY, for example proposing a foundational layer of unit tests for the app's view-model and business-logic layer first (highest value per hour invested, since almost none currently existed), rather than jumping straight to UI test automation, or proposing a specific fix to the flaky UI suite's root cause (unstable selectors, missing explicit waits for async work) rather than simply asking for "more reliable tests" in the abstract.
How you prioritized and implemented them: describe a realistic, incremental rollout, for example starting with the highest-traffic, highest-risk screens first (login, checkout) as a pilot to demonstrate value before asking for broader team buy-in, and pairing the technical change with a process change (a definition-of-done requirement for new features to include unit tests) so the improvement didn't erode again once the initial push ended.
Metrics used to measure success: use metrics you can honestly derive from the situation, such as the manual QA cycle time before and after (if it's a release-bottleneck story), or the automated suite's pass-rate stability and rerun rate before and after (if it's a flakiness story), described honestly and specifically rather than with an invented precision figure; if you don't have an exact number from memory, describe the direction and rough magnitude of the change honestly (for example, manual QA time dropping from most of a release cycle to a small fraction of it) rather than fabricating a specific percentage.
Handling resistance: name a real, specific form of resistance and how you addressed it with evidence rather than authority, for example a teammate skeptical that unit-testing view-model logic was worth the time investment, addressed by pointing to a specific recent bug that a proposed test would have caught, or a product manager concerned that adding tests would slow down an already tight release schedule, addressed by showing that the pilot screens' review and QA time actually DECREASED once automated coverage existed, turning the argument from "tests slow us down" to "tests are what let us go faster."
Trade-offs and pitfalls
The pitfall in this story is claiming credit for a purely technical fix without acknowledging the process and buy-in work required to make it stick; testing improvements that aren't paired with a process change (definition of done, code-review expectations) tend to erode once the person who pushed for them moves on, and naming that explicitly is itself a sign of senior-level thinking about sustainable change, not just a one-time fix.
Design an integration-testing approach for a mobile app that uses a local database (such as Room or SQLite), a REST API backend, and a background sync worker. Describe which layers you would test together, how you would simulate backend behavior (stubs, mocks, or a test server), how you would validate resulting database state and background work, and how to keep these tests fast and reliable in CI.
Sample Answer
A mobile app with a local database, a REST backend, and a background sync worker has three real integration points, and the right approach tests each SEAM deliberately rather than trying to integrate everything at once.
Which layers to test together
Test the local-database-plus-repository layer together, against a real (in-memory or on-device test) instance of the local database itself, Room on Android or SQLite directly on either platform, to prove your data-access code produces correct schema and query results, independent of the network. Test the repository-plus-sync-worker layer together, with the network backend simulated rather than real (see below), to prove the sync worker correctly reads pending local changes, calls the network layer, and writes results back to that same real Room or SQLite instance in the right order, including on partial failure. Keep these as two separate integration tests rather than one combined test, since combining them makes a failure harder to localize (is the bug in the local DB layer or the sync logic?) without adding real additional confidence.
Simulating backend behavior
Three mechanisms are available here, and they trade off differently. A hand-written STUB buried in application code (a fake repository or network-client implementation swapped in for the real one) is the fastest to write and fully deterministic, but it never exercises your actual networking code, so it can silently drift from the real API's behavior (serialization bugs, header handling, timeout behavior) with nothing to catch the drift. A MOCK of the HTTP client itself (a mocking framework intercepting calls at the client-library boundary and returning canned responses) is similarly fast and deterministic and shares that same drift risk, plus your real request-building and response-parsing code still never actually runs. A lightweight local TEST SERVER (an in-process HTTP server, or a tool like WireMock/MockServer) closes both gaps: it lets the sync worker make REAL HTTP calls over a real socket, proving the networking code itself works, including serialization and error handling, while still being fully under the test's control for response content and timing, including deliberately simulating a slow response, a partial failure, or a dropped connection mid-sync, scenarios that are difficult to reliably trigger against a real backend on demand. For this integration layer specifically, prefer the test server over either a stub or a client-level mock, since the whole point of this seam is proving the sync worker's real network-handling code, not just its response-handling logic in isolation.
Validating database state and background work
After running a sync cycle against the simulated backend, assert directly against the local database's actual state (querying the real local database the same way the app itself would, not just inspecting the sync worker's return value), since the real risk in sync logic is exactly a case where the worker reports success but leaves the local database in an inconsistent state. For background work specifically, use the platform's real background-task testing utilities (rather than just calling the worker's logic function directly) where feasible, since a background worker's real risk includes platform-level details, being killed and resumed, running with restricted resources, that calling the underlying function directly in a test would not exercise.
Keeping these tests fast and reliable in CI
Run all of this against a real but LOCAL and ephemeral database instance (in-memory or freshly created per test run) rather than a shared test database, so tests don't interfere with each other and don't depend on CI environment state from a previous run. Keep the simulated backend server's responses deterministic and explicitly configured per test case, rather than reused across many tests, so a flaky-looking sync test can always be traced to a specific, known input rather than shared, drifting test-server state.
Trade-offs and pitfalls
The common mistake is testing the sync worker only against ideal-case responses from the simulated backend, when the sync worker's actual job is correctly handling the imperfect cases: partial writes, network timeouts mid-sync, and conflicting concurrent local and remote changes. Since these are exactly the cases a real backend is hardest to reliably trigger on demand, and exactly the cases a purely happy-path simulated backend would never exercise, deliberately including failure-mode test cases against the simulated backend is where most of this integration layer's real value comes from.
Unlock Full Question Bank
Get access to all 6 Test Levels and the Test Pyramid interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.