The other day I did some changes to the UI of an app, on which I work in my free time. This app is quite small and simple, there are only 3 screens, and I need to upload 8 screenshots to Google Play which will be shown on app's page. Problem is, the app has 3 locales, so I'd have to provide 8 screenshots for each of them. This makes a total of 24 screenshots. And that's only for smartphones. There are also tablets with 7" and 10" screens, and in order to convince Google Play that the app in question is optimized for such devices as well, I'd have to provide another 48 screenshots. 72 screenshots in total. Usually I'd do this manually, but I was feeling lazy all of a sudden. So I decided to automate this process.
I already use Espresso
for UI testing in my project and it provides android.support.test.runner.screenshot
package which contains classes, required to capture and save scrrenshots on a test device or an emulator. Apart from capturing actual screenshots it is necessary to change locale somehow while running tests and capture screenshots only after UI changes.
JUnit rules allow to modify behavior of each test method in a class. Or they can do some work in order to make tests work at all. In order to implement such a rule we need to create a class that implements TestRule
interface which contains only one method
public interface TestRule {
/**
* Modifies the method-running {@link Statement} to implement this
* test-running rule.
*
* @param base The {@link Statement} to be modified
* @param description A {@link Description} of the test implemented in {@code base}
* @return a new statement, which may be the same as {@code base},
* a wrapper around {@code base}, or a completely new Statement.
*/
Statement apply(Statement base, Description description);
}
Let's implement a TestRule
which will change locale before running a test. Here's the code:
public class LocaleRule implements TestRule {
private final Locale[] mLocales;
private Locale mDeviceLocale;
public LocaleRule(Locale... locales) {
mLocales = locales;
}
@Override
public Statement apply(Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
try {
if (mLocales != null) {
mDeviceLocale = Locale.getDefault();
for (Locale locale : mLocales) {
setLocale(locale);
base.evaluate();
}
}
} finally {
if (mDeviceLocale != null) {
setLocale(mDeviceLocale);
}
}
}
};
}
private void setLocale(Locale locale) {
Resources resources = InstrumentationRegistry.getTargetContext().getResources();
Locale.setDefault(locale);
Configuration config = resources.getConfiguration();
config.setLocale(locale);
DisplayMetrics displayMetrics = resources.getDisplayMetrics();
resources.updateConfiguration(config, displayMetrics);
}
}
Basically, what this rule does is iterates over mLocales
and changes locale on the device/emulator before running the test. After that locale is changed back. Main problem of this approach is that changing device locale like this isn't officially supported and this code may not work on all API levels. I've found a nice article about this here. Otherwise, it's really simple.
It can be used in a test the following way:
@Rule
public final LocaleRule mLocaleRule = new LocaleRule(Locale.ENGLISH, Locale.FRENCH);
In this case each test method will be invoked 2 times with different locales.
You can also static import values passed into constructor to make the code less verbose.
Since I'm doing screenshots for 3 locales, I've created Locales
class.
public final class Locales {
private Locales() {
throw new AssertionError();
}
public static Locale english() {
return Locale.ENGLISH;
}
public static Locale russian() {
return new Locale.Builder().setLanguage("ru").build();
}
public static Locale ukrainian() {
return new Locale.Builder().setLanguage("uk").build();
}
}
And then I instantiate LocaleRule
like this:
@Rule
public final LocaleRule mLocaleRule = new LocaleRule(english(), russian(), ukrainian());
Looks tidy to me.
JUnit also offers TestWatcher
class that can be extended and used as a @Rule
to receive notifications when test methods succeed of fail. I'll be using this class as a base to implement my ScreenshotWatcher
.
public class ScreenshotWatcher extends TestWatcher {
@Override
protected void succeeded(Description description) {
Locale locale = InstrumentationRegistry.getTargetContext()
.getResources()
.getConfiguration()
.getLocales()
.get(0);
captureScreenshot(description.getMethodName() + "_" + locale.toLanguageTag());
}
private void captureScreenshot(String name) {
ScreenCapture capture = Screenshot.capture();
capture.setFormat(Bitmap.CompressFormat.PNG);
capture.setName(name);
try {
capture.process();
} catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
@Override
protected void failed(Throwable e, Description description) {
captureScreenshot(description.getMethodName() + "_fail");
}
}
Let's look at the code inside captureScreenshot()
method closer. Screenshot.capture();
captures visible screen content for Build.VERSION_CODES.JELLY_BEAN_MR2 and above which means no automated screenshooting on older devices. capture.setFormat(Bitmap.CompressFormat.PNG);
specifies output format for the given screenshot. Can be JPEG
, PNG
or WEBP
. capture.setName(name);
sets name for the screenshot file. capture.process();
actually saves the image to the device storage despite its somewhat obscured name. By default screenshots are saved to external storage. This is handled by android.support.test.runner.screenshot.BasicScreenCaptureProcessor
which implements android.support.test.runner.screenshot.ScreenCaptureProcessor
interface. Screenshot
class exposes an API that allows to use custom ScreenCaptureProcessor
implementations.
name
parameter passed to this method depends on the result of each test. It can be whatever you decide it to be. I went with testMethodName_currentLocale
when test is successful.
This @Rule
is then used like this:
@Rule
public final ScreenshotWatcher mScreenshotWatcher = new ScreenshotWatcher();
But there's one more thing that is required to make this actually work. To be able to save files to external storage we need READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE permissions. And, somewhat unsurprisingly, it's up to developer to grant them before running the test.
So we just have to add this to the test class:
@Rule
public final GrantPermissionRule mGrantPermissionRule =
GrantPermissionRule.grant(READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE);
After this we are actually ready to create an ActivityRule
and write some tests with UI interactions which will trigger our screenshots. I'll be using my MainActivity
.
But in order to make all those rules work predictably we need to enforce an order in which they'll be applied.
In order to do this, we need to change rule declaration inside the test class.
private final LocaleRule mLocaleRule = new LocaleRule(english(), russian(), ukrainian());
private final ScreenshotWatcher mScreenshotWatcher = new ScreenshotWatcher();
private final GrantPermissionRule mGrantPermissionRule =
GrantPermissionRule.grant(READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE);
private final ActivityTestRule mActivityTestRule = new ActivityTestRule<>(MainActivity.class);
@Rule
public final RuleChain mRuleChain = RuleChain.outerRule(mLocaleRule)
.around(mScreenshotWatcher)
.around(mGrantPermissionRule)
.around(mActivityTestRule);
And RuleChain
does just that. Here we must declare mLocaleRule
as outerRule otherwise the expected behavior is broken.
Here's how my test class looks like
@RunWith(AndroidJUnit4.class)
public class MainActivityScreenshot {
private final LocaleRule mLocaleRule = new LocaleRule(english(), russian(), ukrainian());
private final ScreenshotWatcher mScreenshotWatcher = new ScreenshotWatcher();
private final GrantPermissionRule mGrantPermissionRule = GrantPermissionRule.grant(READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE);
private final ActivityTestRule mActivityTestRule = new ActivityTestRule<>(MainActivity.class);
@Rule
public final RuleChain mRuleChain = RuleChain.outerRule(mLocaleRule)
.around(mScreenshotWatcher)
.around(mGrantPermissionRule)
.around(mActivityTestRule);
@Test
public void emptyMainActivityPortrait() {
}
}
I've named this test class MainActivityScreenshot
to indicate its purpose.
But there's yet another pitfall. If there are no UI interactions in the test Activity is finished almost instantly and in worst-case scenario you'll just end up with empty home screen on a screenshot. To cater for this, I've copied ActivityRule
class to my project and added a small pause after the test.
It looks like this:
private class ActivityStatement extends Statement {
private final Statement mBase;
public ActivityStatement(Statement base) {
mBase = base;
}
@Override
public void evaluate() throws Throwable {
MonitoringInstrumentation instrumentation =
CustomActivityTestRule.this.mInstrumentation instanceof MonitoringInstrumentation
? (MonitoringInstrumentation) CustomActivityTestRule.this.mInstrumentation
: null;
try {
if (mActivityFactory != null && instrumentation != null) {
instrumentation.interceptActivityUsing(mActivityFactory);
}
if (mLaunchActivity) {
launchActivity(getActivityIntent());
}
mBase.evaluate();
SystemClock.sleep(1_000);
} finally {
if (instrumentation != null) {
instrumentation.useDefaultInterceptingActivityFactory();
}
if (mActivity != null) {
finishActivity();
}
}
}
}
The only change is SystemClock.sleep(1_000);
.
Now, if we launch the test on the emulator, it looks like this:
So with a bit of a code, I'm now able to automate the process of repeating same actions with different locales and taking screenshots.
Once implemented, I don't have to do any additional setup to make this work on different OSes, which is good, because I have a Windows laptop and Ubuntu-based desktop. But there's also a problem with retaining locale settings on configuration change, which is not so good.
Anyway, hopefully you've learned a thing or two about JUnit Rules and Espresso.
More information about Espresso screenshot package can be found here. JUnit has a wiki page dedicated to Rules here.
Top comments (7)
Update: LocaleRule switch does not work anymore with latest Android O (API >= 26), at least with official Google emulator images - ref. proandroiddev.com/change-language-...
After updated the locale change code as suggested @ proandroiddev.com/change-language-.... Getting all screenshots in same locale language.
for anyone having issues after androidx update: androidx.appcompat:appcompat:1.1.0 is currently bugged ref. github.com/YarikSOffice/LanguageTe...
Hi, Not working on api level 28 even after updated the locale change code as suggested @ proandroiddev.com/change-language-.... Getting all screenshots in same locale language.
Is it possible to capture several activities in the process?
For example:
loop:
(en)
MainActivity (screenshot) > OtherActivity (screenshot) > ThirdActivity (screenshot)
(de)
same
and so on for every locale in the list.
Excellent article, greetings from Italy!
Great article. Thank you.