Unit Test Development for Android App (JUnit 5, MockK)

Application logic breaks unnoticed: a change in one place causes failures in another, and regressions surface only in production. We develop unit tests for Android turnkey — from analyzing critical scenarios to a ready set of checks in your project. Covering key modules reduces regressions by several times and speeds up release cycles.

Development and support of all types of mobile applications:

Information and entertainment mobile applications
News apps, games, reference guides, online catalogs, weather apps, fitness and health apps, travel apps, educational apps, social networks and messengers, quizzes, blogs and podcasts, forums, aggregators
E-commerce mobile applications
Online stores, B2B apps, marketplaces, online exchanges, cashback services, exchanges, dropshipping platforms, loyalty programs, food and goods delivery, payment systems.
Business process management mobile applications
CRM systems, ERP systems, project management, sales team tools, financial management, production management, logistics and delivery management, HR management, data monitoring systems
Electronic services mobile applications
Classified ads platforms, online schools, online cinemas, electronic service platforms, cashback platforms, video hosting, thematic portals, online booking and scheduling platforms, online trading platforms

These are just some of the types of mobile applications we work with, and each of them may have its own specific features and functionality, tailored to the specific needs and goals of the client.

Our competencies:

Frequently Asked Questions

Latest works

  • Development of a mobile application for FEEDME
    Development of a mobile application for FEEDME
    971
  • Development of a mobile application for XOOMER
    Development of a mobile application for XOOMER
    869
  • Development of a mobile application for RHL
    Development of a mobile application for RHL
    1289
  • Development of a mobile application for ZIPPY
    Development of a mobile application for ZIPPY
    1143
  • Development of a mobile application for Affhome
    Development of a mobile application for Affhome
    1076
  • Development of a mobile application for the FLAVORS company
    Development of a mobile application for the FLAVORS company
    677

Android Unit Tests: Where Logic Breaks and How to Catch It

Android app logic doesn't break in the layout — it breaks in asynchronous scenarios. The screen opened, the user went to the background, came back — and sees stale data, because the network response arrived after the request was cancelled.

Such defects can't be reproduced manually with any consistency: they depend on the order of events, network speed, and the lifecycle.

Android unit tests pin down exactly that order — they stub out the network and storage, feed responses in the required sequence, and verify that the screen state ends up in the expected form.

The second bottleneck is screen state. Loading, error, empty result, and success are often mixed into a single flag, and during rapid switching the UI shows a combination that shouldn't exist: a spinner on top of an error, or an empty list instead of cached data. A test for state transitions catches such combinations before the user ever sees them.

The third is error handling and caching. What happens on a timeout, on an invalid response, when local data is out of sync with the server? Without logic coverage, these branches remain untested, and the very first abnormal situation ships to release as a regression.

Unit tests run each branch in isolation — without an emulator or a device, in seconds, so they can be run on every change.

The point isn't coverage percentages for the sake of reporting, but which failure classes are closed. We cover, first and foremost, asynchronous scenarios, screen state transitions, cache handling, and error handling — the things that most often break during further development. Below, we break down how this is set up in a project.

What test coverage delivers: measurable impact

Android unit tests give the team something no stage of manual testing can — feedback in seconds instead of hours.

While the build and the run on the emulator take minutes, a suite of module checks on JUnit and MockK completes in a time comparable to compilation, and the developer learns about a breakage right after saving the file, not at the regression stage before release.

This changes the very mechanics of the work: an edit in the business logic stops being a risk, because its consequences are visible immediately.

The share of defects caught before code review grows predictably — boundary values, empty responses, parsing errors and exception-handling branches are covered by tests at the level of functions and classes.

The more densely the ViewModel and the data-access layer scenarios are covered, the fewer errors reach the build and the less often a release is held up by findings at acceptance.

Release stability is a direct consequence. When the logic is locked down by tests, refactoring and dependency updates stop being a lottery: any discrepancy in behavior surfaces during the run, not among users. Rollbacks and re-releases are needed less often, and the release cycle itself becomes shorter.

We build coverage where it pays off: critical scenarios, calculations, network and storage work are under tests, while secondary screens do not slow the team down with unnecessary checks. Describe your task — we will come back with an estimate and a coverage plan for your project.

Testing Approaches: Comparing Options

The choice of approach is determined by what exactly we are verifying and how expensive feedback is for us. Pure unit tests isolate logic without the Android environment and deliver results in seconds, but they do not catch layer integration errors.

Tests with dependency substitution cover interaction with the network, storage, and system services while remaining fast. Integration checks assemble real components and confirm that modules work together — at the cost of run time and fragility to environment changes.

Approach When it fits Limitations
Pure unit tests Business logic, calculations, response parsing, boundary conditions Do not verify real dependencies and platform behavior
Tests with dependency substitution Scenarios with network, database, system services; verification of branches and errors Substitution may diverge from the real behavior of the dependency
Integration checks Layer integration, working with storage, end-to-end scenarios Long run, sensitivity to the environment, harder to localize a failure

How to combine approaches

The practice is a pyramid: a foundation of pure unit tests for business logic, a middle layer with dependency substitution for interactions, and a top of integration checks for critical paths.

This distribution keeps the run fast and the coverage meaningful: most defects are caught at the lower layers, where they are cheaper to fix.

Limitations to keep in mind

No single approach replaces the others. Pure tests will not catch library version incompatibility, substitution will not catch a contract divergence from the real service, and integration checks will not catch rare boundary cases of logic.

Therefore, the set is built around the risks of a specific application, not according to a template.

How we implement tests: stages of work

Implementing tests begins not with writing code, but with an inventory: which modules actually exist, where the logic is concentrated, and where there is only screen markup.

At this stage, we look at the change history and find the classes that are edited most often: that is where errors are most costly and tests deliver the greatest effect.

  1. Code audit. We analyze the project structure, dependencies between modules, and the quality of layer separation. We note places where logic is mixed with network or database access — they will need to be slightly separated, otherwise the test cannot be isolated without external systems.
  2. Coverage plan. We fix exactly what we check: calculations, data transformation, error handling, boundary values. We agree on a target coverage share for critical modules — not "everything in a row," but where the cost of an error is higher.
  3. Environment setup. We set up a test loop in the build: a separate set of tasks, a coverage report, automatic runs on every change. Tests that need to be run manually stop being run after two weeks.
  4. Writing tests. We go from critical to secondary. Each test checks one behavior and does not depend on the order of execution. We replace external dependencies with controllable doubles, so the suite runs in seconds and does not require a network.
  5. Verification on real scenarios. We run the suite against changes that have already broken the application before — we make sure the tests catch them. A test that is always green is worse than a missing one: it creates false confidence.
  6. Handover. We hand over the suite, the run instructions, the rules for adding new tests, and an analysis of typical errors. We conduct a walkthrough with your team so that support does not depend on us.

The key criterion at the output is not the number of tests, but the time to detect a regression: an error should surface at build time, not in the app store. Describe the task — we will come back with an estimate and a plan for your project.

Artifacts and processes: what stays in the project

After the project is handed over, what remains in the repository is not a "set of tests" but a working verification loop that can be run without the author. Below is what exactly is handed over and how the customer's team uses it.

  • Test suites by layer. The domain and business logic are covered by fast tests without Android dependencies; storage and network interactions are covered by separate suites with external calls stubbed out. Each suite runs in isolation, so a failure is localized in seconds rather than over a full project run.
  • Run configurations. Ready-made profiles for local runs, for checks on every change, and for a nightly build with full coverage. A developer launches the required profile with a single command, without manually configuring the environment.
  • Dependency diagrams. Diagrams showing which modules depend on what and where the substitution boundary lies. They show why a test fails when a specific class changes, and where to add new checks.
  • Coverage reports. A configured summary by module with a threshold below which the build does not pass. It shows which logic branches are untested — this is a task list, not an abstract number.
  • Documentation. Conventions for naming tests, rules for preparing test data, and a description of what counts as sufficient coverage for new code.
  • Run processes. When tests are mandatory, what blocks the merging of changes, who investigates failed checks, and within what timeframe. The rules are recorded in the repository, not in a chat thread.
  • Maintenance guide. How to add a test for new functionality, how to update stubs when external services change, and what to do about a flaky test.

All of this sits alongside the code and is versioned with it. A new engineer on the team sets up the environment and gets a green run on their first day — without verbal explanations from the author.

Case: reducing regressions after implementing tests

The project is an Android app with payments and subscriptions, released every two weeks. Tests existed, but were written inconsistently and mostly covered "pure" functions: the logic of payments, discount calculation, and subscription restoration remained uncovered.

Regressions were caught during manual runs before release, and roughly every fourth one reached users — most often in the form of duplicate charges and "stuck" order statuses.

What was changed

We separated the logic from the platform code: business rules were moved into separate classes without dependencies on the Android environment, and external services — payments, storage, network — were wrapped behind interfaces.

Then, using MockK, we described the behavior of these interfaces, including failures and timeouts, while JUnit 5 provided parameterized checks: one set of boundary values instead of a dozen nearly identical methods.

Tests run on every commit, so the build fails before review, not after. We also established a separate rule: a new branch of payment logic does not pass review unless it is covered by error scenarios — not just the happy path.

Metrics before and after

Metric Before After
Share of defects reaching users ~25% ~6%
Manual release verification 2 days 4-6 hours
Builds failing on integration every third isolated
Coverage of payment logic fragmentary main and error scenarios

The manual run shrank not because there were fewer checks, but because critical paths are now filtered out automatically. The freed-up time went into verifying what tests do not cover: behavior on real devices, handling payment dialogs, recovery after the app is minimized.

Two releases later, the share of defects stabilized at the new level, and rolling back a version stopped being a routine scenario.

How do unit tests differ from integration tests?

Unit tests verify a single module in isolation: all external dependencies — repositories, network clients, system services — are replaced with controllable doubles.

Such checks run on the JVM, without an emulator or device, so an entire suite of hundreds of scenarios completes in seconds.

Isolating modules here is not an end in itself: it eliminates false failures, when a test fails because of an unavailable server or someone else's code, rather than because of an error in the logic being tested.

Integration tests operate at the junction of modules and verify that they actually fit together: request serialization and response parsing, cache behavior when data changes, correctness of queries to a local database.

Here a real environment is spun up — a test database, a stub server, sometimes an emulator. Such checks take longer and fail more often, but they catch a class of errors that an isolated test simply cannot see.

The boundary between the checks comes down to the question of "whose responsibility is this." Calculation logic, field validation, parsing screen states, model transformation — these are unit tests, because the result depends only on the input data.

Interaction with the network, database, file system, permissions, and lifecycle — these are integration tests, because the result depends on the environment. You cannot mix them in a single suite: you lose speed and precision in localizing errors.

In practice, a suite is built as a pyramid: the bulk consists of fast isolated checks on business logic, on top of that — integration tests on critical junctions, and a few end-to-end scenarios on the main user paths.

This distribution provides early feedback during development and does not turn the pre-release run into a multi-hour wait. If the boundary is blurred and everything gets dumped into integration tests, the team stops running them locally — and the checks stop protecting the code.

Common technical concerns

The testability of an Android application rarely hinges on the framework itself — it hinges on the architecture that took shape before anyone even thought about tests.

Below are the four questions technical leads ask most often, and how we answer them at the level of code rather than generalities.

We have legacy code with no tests — where do you start?

First we lock in the current behavior with characterization tests: we cover the most heavily used scenarios exactly as they work now, quirks included. This is insurance before refactoring — after every change, the run shows that behavior hasn't shifted.

Next we move logic out of activities and fragments into separate classes that can be tested without an emulator: a test on pure logic runs in fractions of a second, and the whole run stops being a build bottleneck.

How do you test asynchrony and coroutines so the tests don't "flicker"?

We swap dispatchers for a test scheduler and advance virtual time manually — the test stops depending on real delays and network timeouts. Asynchronous code is verified deterministically: the same run produces the same result both on a local machine and on the build server.

Separately, we cover coroutine cancellation and error handling — that's exactly where defects most often hide that manual testing doesn't catch.

How deeply do you substitute dependencies?

We isolate the external boundaries — network, database, file system, system time — and leave the logic we're verifying real. A mock for every little thing makes a test fragile: it breaks on a harmless refactor and says nothing about behavior.

Dependency substitution is built on interfaces, so a test survives a change of network client or storage without being rewritten.

Who maintains the tests after the project is delivered?

Tests are part of the codebase, not a separate artifact: they live next to the code, go through review in the same merge request, and run on every change.

We hand the team conventions for test structure and a walkthrough of typical scenarios, so a new developer writes them in the same style. If a test becomes outdated along with a requirement, it gets fixed or deleted, not disabled forever.

Describe your codebase and current coverage — we'll come back with an estimate and a plan for which areas to cover first.

Let's discuss the task and come back with a plan

To come back with a plan rather than generalities, we need to understand the structure of your Android project and the current state of testing.

Send us repository access or an export of the modules — we'll look at how the code is split across layers, where the boundary with the network and storage lies, which dependencies are injected through constructors and which are pulled in statically.

This determines where unit tests can be added without rewriting production code, and where related components will first need to be decoupled.

We'll also clarify the requirements separately: which scenarios are critical for you (business logic calculations, network error handling, cache, validation), what level of coverage you consider sufficient, and whether there are already tests that must not be broken.

If the project uses a DI container and coroutine-based asynchrony, let us know — a test dispatcher is configured for it so that checks run deterministically, without random failures.

What we'll prepare for the next step:

  • a map of modules and layers where tests deliver the greatest impact with minimal changes;
  • a list of classes to be checked, prioritized by regression risk;
  • a mock scheme for external dependencies — network, storage, system time;
  • rules for test isolation so they don't depend on execution order;
  • build run configuration and a coverage report;
  • an estimate of the scope of work by stages with intermediate acceptance checkpoints;
  • a plan for integrating checks into the build pipeline so regressions are caught before release.

Describe the task in free form: what kind of app it is, what stage of development it's at, what hurts right now — frequent regressions, fear of refactoring, or preparation for handing the code over to another team. That's enough for us to come back with an estimate, a work plan, and a proposal for the next step.