Case Study: CI/CD and Unit Testing for Three White-Label Banking Apps in Ten Weeks
How Mobian Studio moved a fintech SDK and three white-label banking apps from manual builds to one CI/CD system, and nearly tripled the SDK's unit tests.
Most CI/CD articles describe what a pipeline should look like. This one describes a pipeline we actually built: for a fintech platform whose Android SDK powers three white-label banking apps.
When we started, none of the six repositories had working CI. Builds were made by hand, version numbers were bumped by hand in commits, and one library existed only on one developer’s machine. Ten weeks later, the SDK and three client apps shared one CI/CD system, every tester build could be reproduced from a Git tag, and the SDK’s unit-test suite had nearly tripled.
In this article, we’ll walk through the starting point, the pipeline design, the testing strategy, the results with dates, and the work that is still open.
The Starting Point
The client is a fintech platform behind several banking and payment apps. The product family includes:
-
An Android SDK of about 29 Gradle modules that turns backend JSON into native screens, and also handles payments, biometrics and card scanning.
-
Three white-label banking apps built on that SDK, each with its own branching and release model.
-
A networking library and an end-to-end test suite owned by QA.
In July–August 2026, the situation looked like this:
-
Old CI templates sat in the SDK repository, but nothing ran.
-
Version codes were bumped by hand: about 95 such commits in one app since 2025, and 28 in another.
-
A build could not be reproduced from a clean checkout, because one library lived only in a developer’s local Maven cache.
-
Some SDK test modules did not even compile — the very first CI run exposed it.
-
The SDK had 1,122 executed tests and 8.1% total coverage; the main app had 56 tests.
📌 Key Insight:
Nobody on the team was doing anything wrong. Without a pipeline, release knowledge simply lives in people’s heads and on their laptops — and in a regulated banking domain, that is a risk in its own right.
The Constraints
The pipeline had to fit the client’s real infrastructure, not an ideal one:
-
Self-hosted GitLab, migrated from gitlab.com in July 2026.
-
One shared macOS runner with no Docker, also used by other company projects, with a workspace that persists between jobs.
-
Builds that span two repositories: each app build combines an app branch with an SDK branch that may not be merged yet.
-
A regulated banking domain, which pushes towards reproducible builds, release signing kept out of Git, and dependency scanning.
What We Built: One Pipeline for Three Apps
1. One Web Form to Testers
A tester build starts from one GitLab form: choose the app branch, the SDK branch, the build type and the tester groups. The pipeline then runs six stages:
-
Prepare — resolves the SDK branch to a single commit, so every later job uses exactly the same SDK code.
-
Test — unit tests of the app and the SDK, coverage, and a dependency scan.
-
Build — a signed APK (plus the Play Store AAB for releases), with signing credentials taken from CI variables.
-
Distribute — Firebase App Distribution to the selected tester groups.
-
Notify — a Jira comment on every included ticket with the build link and coverage, and a move to “Ready for QA”.
-
Tag — the same tag on the app commit and the SDK commit.
2. Every Tester Build Is Reproducible
Because the tag lands in both repositories after distribution, a tag means “this exact build reached testers”. Checking out the tag in both repos reproduces the build. Before, that was not possible at all.
3. One Template, Mechanical Ports
All three apps run the same six-stage pipeline. Only a few constants differ — Jira label, tag prefix, tester groups — and they live in one hidden template job. The second and third apps were ported in days, despite one of them using a release-branch model and having seven separate blockers to clear.
4. Quality Gates Where People Already Work
-
Test results in the merge request’s Tests tab.
-
A logic-coverage badge in the merge request widget, and coverage marks on changed lines.
-
SonarQube on the main branch.
-
OWASP Dependency-Check with a CycloneDX SBOM in the main app.
5. A Shared Runner Treated as Untrusted
Secrets live only in CI variables, nothing is stored on the runner, each job clones the SDK into its own workspace (after parallel jobs once deleted each other’s checkout), and signed release builds always bypass the build cache.
What We Built: A Testing Strategy That Starts With Architecture
Coverage was the yardstick, but the real work was making the code testable. Four decisions shaped the result.
1. Measure the Code That Matters
We defined a logic set: domain, data, non-Android utilities, the request and event engines, and base ViewModels. UI code, DI wiring and generated code are still reported, but carry no target. This alone changed the honest baseline from 8% (whole codebase) to about 24% (logic).
2. Ratchet the Architecture
Seven automated architecture rules run as unit tests — for example, no Android types in logic code and no global dependency injection. Today’s violations are frozen in a baseline file: a new violation fails the build, and so does a fixed one until it is removed from the baseline. The debt can only shrink.
3. Seams First, Tests Second
Each Android or vendor dependency — analytics, permissions, biometrics, screen cache, request signing — got a small interface in the domain layer and an adapter outside the coverage gate. After that, the logic runs on the plain JVM in milliseconds, and tests become cheap to write.
4. Pin Behaviour, Don’t Quietly Fix It
Data-layer tests record what the code does today. Every questionable behaviour becomes a question for product or backend instead of a silent change — 76 open questions so far. When a decision lands, the pinning test fails on purpose and is updated together with the fix.
The Results
All figures below are as of October 5, 2026, read from the GitLab pipeline API or counted from the repositories.
| Metric | Before | After |
| Repositories with working CI | 0 of 6 | 4 of 6 (SDK + 3 apps) |
| Merge to testers | Manual builds, manual version bumps | About 6–8 minutes, fully automated |
| Tester builds through the pipeline | — | 101 since September 8 |
| Pipelines run | — | About 530 |
| SDK unit tests | 1,141 (September 11) | 3,246 (×2.8) |
| SDK logic coverage | About 24% | 70.9% |
| Failing tests block merges | No | Yes, in the SDK and the main app |
| Android imports in logic code | 298 | 192 (−36%) |
| Reproducing a tester build | Not possible | Check out one tag in both repos |
The fastest stretch: logic coverage went from 30.7% to 70.9% in seven days (September 28 to October 5), when the whole data layer was covered across 13 tickets. That jump only happened because the architecture seams were already in place.
The tests also paid for themselves before the coverage target was reached. Writing pinning tests surfaced crashes on malformed server data, forward-compatibility gaps in older app versions, and an overly aggressive token-refresh retry loop — all now tracked and prioritized.
What Is Still Open
We prefer to publish the gaps alongside the results:
-
No coverage floor yet — merges are blocked on failing tests, not on a coverage drop. The 40% and 60% milestones are passed; 80% is still ahead.
-
Two apps still have non-blocking tests and have not adopted the shared test toolkit.
-
Two repositories have no CI, and end-to-end tests still run on a local machine.
-
A single runner is a single point of failure — on two days, jobs waited until they timed out while it was unavailable. A second runner or an autoscaled pool is the next step.
The Playbook We Now Use With Other Clients
-
Pin shared dependencies and tag every repo involved in a build, so any tester build can be reproduced.
-
Use one pipeline template per product family, with differences reduced to a handful of constants.
-
Close the loop where people already work — the build link and coverage go on the Jira ticket and in the merge request.
-
Redefine coverage before chasing it, so the number is honest and the target is reachable.
-
Ratchet, don’t big-bang — freeze today’s violations and fail on anything new.
-
Treat a shared runner as untrusted — secrets in CI variables, release builds never cached.
Conclusion
In ten weeks, a fintech product family moved from hand-built APKs to one CI/CD system: about 530 pipelines, 101 automated tester builds, merge-to-testers in minutes, and an SDK test suite that grew from 1,141 to 3,246 tests with logic coverage at 70.9%. None of it required a new CI vendor — only a pipeline designed around the client’s real infrastructure and an architecture that allows testing.
At Mobian, this is the kind of work we like to show rather than describe. If your releases still depend on one person’s laptop, we can start with an audit of your current build and release process and tell you honestly what is worth automating first.
Frequently asked questions
How long did the CI/CD setup take?
About ten weeks, from late July to early October 2026. The first green SDK pipeline ran after about a month. Tester builds through the pipeline started in early September. After that, porting the same pipeline to the other banking apps took days, not weeks.
Why measure logic coverage instead of total coverage?
Total coverage counts UI code, dependency-injection wiring and generated code, where unit tests add little value. Logic coverage only counts the domain, data and engine code where bugs cost money. It makes the number honest and the target reachable. In this project the baseline was 8.1% total coverage, but about 24% logic coverage, before a single new test was written.
Did the pipeline run on cloud CI?
No. The client runs a self-hosted GitLab with a single shared macOS runner, so the pipeline was designed for that: each job gets its own SDK checkout, secrets live only in CI variables, and signed release builds are never taken from the shared build cache.
Is the work finished?
Not fully, and we prefer to say so. The coverage floor (blocking a merge when coverage drops) is not built yet, tests in two of the apps are still non-blocking, and the single runner remains a capacity and availability risk. The 80% logic-coverage milestone is still ahead.
Can this approach work for an iOS or cross-platform product?
The principles carry over: pin shared dependencies, tag every tester build, measure the code that matters, and add tests after the architecture allows them. This particular project was Android-only, so the tools differ on iOS (Xcode, Swift Package Manager, XCTest), but the playbook stays the same.