It’s no secret that when you mention ProGuard to Android developers, most of them cringe. ProGuard often feels like over-complicated voodoo, that’s never trivial to configure.
I felt quite the same way about it a while ago when introducing ProGuard to our open-source, cross-platform UI testing automation framework,
Having proper UI tests in your project brings ProGuard configuration complexity even higher, for several reasons. First and foremost, it’s due to this one pitfall:
ProGuard will not retain production code if only used by instrumentation-testing code. It can therefore carelessly omit production code required by the test suite during the minification phase, possibly causing tests to fail for the wrong reasons.
In addition, there are many levels where ProGuard rules can by applied:
- App level (Gradle’s
- Test level (Gradle’s
- Library level (Gradle’s
consumerProguardFilesconfiguration); Libraries can be used as either production dependencies (i.e. declared using
implementation), or test-only dependencies (using
To what extent do the rules apply, in each case? Pretty confusing! 😕
I’ll be offering 3 configuration schemes I believe can cover what most Android projects, which incorporate instrumentation testing, require. I’ll be doing it in a learn-by-example way, thus shedding light on the various configuration options and addressing the pitfalls, as I go along. Hang in tight.
Let’s start by creating a common baseline.
Test code packaging — under the hood
Before diving in, there’s one concept that’s important to comprehend. With that, I’m referring to how instrumentation code (i.e. test logic + Espresso, UIAutomator, etc.) gets packaged and is run alongside production code on the target device. Consider this illustration:
With some simplification, there are in fact two separate Gradle tasks —
assembleAndroidTest that generate two different apk files: one for the actual app, and one for the test code, respectively.
When running instrumentation tests, both of them get installed on the test-device, and the merged code runs under the same process (i.e.the app’s process) — hence the test code’s ability to access intimate aspects of the app such as the it’s context and even the main thread.
What’s important to understand here is this:
ProGuard operates distinctly on each of the apk files, within the context of two distinct Gradle tasks.
The trivial config scheme (i.e. simple test cases)
The first use case is simple: you’re done setting up app-level ProGuard rules — typically, as explained by the ‘Shrink your code’ Android tutorial, and your instrumentation tests run flawlessly. This means you must have integrated this configuration to your Gradle build script:
proguard-rules.pro is the file where you’ve specified all of your
-dontwarn’s and the likes:
In essence, in terms of build-scripts, indeed only the
proguardFiles configuration is needed here, and nothing more.
Let’s just try, then, to unravel what goes on beneath the covers —
proguardFiles(in the statement above) mainly tells Gradle to consider your custom rules (in the
proguard-files.profile) along with some standard android ones (hence the
getDefaultProfileFile()), when running ProGuard prior to generating the app apk (i.e. while
assembleReleaseis in action).
Note: You can specify as many files as you like in this csv list, and even repeat
Be advised, however, that at this point, Gradle+ProGuard also consider your dependent libraries’ ProGuard files — should they have been declared using
consumerProguardFiles in the library itself. Namely:
consumerProguardFilesis a library-scoped configuration, that tells Gradle the library has its own ProGuard rules to consider if integrated into an app that itself, uses ProGuard.
So if your app wants to
-keep class A, and you have a library that wants to
-keep class L — you guessed it: ProGuard will merge all rules so as to have both classes A and L kept in the final apk.
So far, so good!
The custom test-rules config scheme
Consider this common scenario: your app uses a 3rd-party library that does some background processing for you. It seems to work well, but in your instrumentation tests — you want to provide an
IdlingResource that can intimately interrogate what the library is up to (namely, whether busy or idle). This can bring up a conflict:
- On the one hand, you want to ProGuard away all of what’s not needed off of this (bloated?) library.
- On the other hand, your
IdlingResourceneeds access to some intimate pieces of that library that ProGuard wants to throw away.
That illustrates how the initial ProGuard pitfall I’ve mentioned comes into practice. This really isn’t just theoretical, too: The
Detox testing library, which focuses on React Native apps, does require deep access to the React Native library itself, at times. And if there’s anything React Native developers want to do before going in to production— it is to throw away all of React Native’s irrelevant code.
So what’s the right configuration here?
Some would argue that
testProguardFiles is the go-to solution: specify additional ProGuard rules to apply only when running test, right?… Wrong, I’m afraid. As I explained, ProGuard is applied distinctly on production and test code. Hence:
testProguardFilesonly specifies immediate rules to apply over the test apk, not the production apk.
Actually, an even more important conclusion hides within — that we should probably note as well:
When enabled, ProGuard runs on both your production code and the test code. With
testProguardFiles, you can configure ProGuard with explicit rules so as to keep and avoid warnings related to test code, such as code from test dependencies (e.g. UIAutomator).
So, what do you do, then? there’s actually more than one solution. The simplest one is to add rules needed by your test code to your existing
proguard-rules.pro file. For example, if class
com.thirdparty.Z, coming from an external library, isn’t required in production — but is required by your test code, add a rule for keeping it, nevertheless:
That oughtta do it. Unless…
What if I’m the one developing a library? 📚
…unless you’re actually working on a library rather than the app itself. In such a case, you can rightfully argue that you don’t have full control over the users’ ProGuard configuration. That is partly true:
- As we’ve already mentioned in the previous section, libraries can use
consumerProguardFilesin order to export ProGuard rules.
- While that works well for run-time, production-oriented libraries (i.e. declared using
implementation). When it comes to test-oriented libraries — typically imported using
androidTestImplementation, however, you’re playing a different ball game:
androidTestImplementationis only associated with the test apk, and therefore, when applied, the rules you export using
consumerProguardFilesonly affect the test apk. As explained: two separate Gradle tasks, two separate apk outcome files!
With a slightly different approach, you can reach the same result, though: provide an app-level ProGuard configuration file and instruct your users to add it to their
proguardFiles list in a magical way:
proguard-rules-app.pro is the separate ProGuard configuration file with
-dontwarn’s you want your lib users to apply over production code for running your test-code properly.
This is in fact what we did with Detox, which, by the way, really makes an interesting ProGuard use case in that sense.
But there’s a hidden assumption here — that you’re actually able to ship some code alongside your packaged
.aar. While this is typically true for React Native libraries, that are published over npm.js (even React Native does that), pure Android libraries are hosted on maven-ish artifactories such as jcenter — and the code is there merely for reference, as a source jar.
If that is the case, you have no choice but to explicitly ask the library users to add a set of ProGuard rules to the app’s configuration (e.g. keep the old
class Z), under the ProGuard section of your README.md (Retrofit as an example).
The price you pay here is that you lose control over the set of rules — and make it the user’s problem, instead of maintaining it yourself alongside code changes published in each version.
An optimized config scheme
The previous solution, described-through thoroughly, works overall — but it has a significant downside: whether instructed by the app or a test-library, the aforementioned
class Z would get shipped out to production! You can get more specific and pin-point the exact methods to keep, but that makes things a bit harder to maintain.
Most of the time, that really is a small price to pay — having your apk contain some unused code just for the sake of having proper instrumentation testing. But if you’re keen on keeping the apk size down to bare minimum, or otherwise need to keep a substantially large set of classes for your tests to run successfully, here’s the last (but not least) suggestion: Apply the additional ProGuard rules in a custom BuildType.
proguard-rules-instrum.pro is a file that holds the extra rules, separately:
With that you sort of both have the cake and eat it 🍰.
Do note that now, in order to run instrumentation tests you have to either select the right build type (if on Android Studio) or run this (simplified) command instead of the usual one (if in the command line / running on CI):
> ./gradlew assembleInstrum assembleInstrumAndroidTest -DtestBuildType=instrum
The test-library prespective📚
The same quirks as in the previous section apply here, when it comes to test-oriented libraries. Hence, the solution is also the same: Rather then having the app create and manage its own
proguard-rules-instrum.pro file, it should just declare the library’s provided one — this time, under the dedicated build type (e.g.
Downsides, yet again
Yes, this solution isn’t perfect either, because it can only work as long as you keep yourself wise about which ProGuard rules you put where: If the wrong rules leak into the test configuration, you would get the impression of that the app works great (all tests green) while in practice, something could be missing, and the app is broken. Life is about trade-offs 😅
ProGuard can be untrivial and setting it up for work in an environment that incorporates instrumentation tests is not easy. Nevertheless, it is not rocket science! 👩🔬 ⚛️ 👨🔬
Overall, the Android build system — based on Gradle, is pretty flexible. Once you work out the details, there’s usually a way for you to set things up the way you want to.
With this post I’ve offered 3 formulas that should largely cover most use cases. To sum them up:
- Instrumentation test code isn’t harmed by ProGuard: nothing that requires more than a few ProGuard rules to specify using
- Instrumentation test code needs to explicitly keep ProGuard minification from tossing away production code. If you don’t mind keeping that code in the apk, add the test-associated rules to your app’s ProGuard rules.
In the context of a test-library, this can be achieved using some trivial techniques.
- Same as the last point, only you can’t afford having unnecessary code shipped out to production. In this case, a custom build-type can help you both have the cake and eat it.
Undoubtedly, there ought to be a great amount of use cases out there I couldn’t think of. I hope that with what I’ve shared regarding the various build-script configurations (knowledge based on reading and experimentation in large-scale projects), it’d be easier for everyone to iron out those use case as well.
Thanks for reading!