P
Philip Ellis
Guest
How fixture data, UI tests, and Pillow turn six app scenes into matching store images across phones, tablets, and foldables
Our courier app's screenshots need to look identical on iOS and Android. If a delivery appears on a phone or a tablet, it should have the same caption, same progress and same messages.
To make that happen, we developed a pipeline that opens up six scenes with placeholder data, takes screenshots of them on emulators and simulators, and then adds a caption with Pillow. Our configured set covers three devices per platform, producing 18 App Store images and 18 Play Store images. Capturing and adding a caption are separate steps. So we could change a headline without having to restart the app.
One fictional morning at 9:41
What's nice about this approach is that each screenshot depends so little on a live account. The app provides predictable data; the test opens a known scene; and the compositor provides presentation. In the code below, for simplicity, I've removed project-specific wiring and names.
Each screenshot follows the day of a fictional courier, Maya, in the San Francisco Bay Area. She makes a pickup of a lab specimen, runs an errand for aircraft parts and exchanges a message with dispatch. On each platform, the scenes use a fixtures file containing the same orders.
The fixture clock is anchored to 9:41 on the day of the capture. In the status bar we see 9:41, so "picked up at 9:37" and "arriving in 10 minutes" make sense in every screenshot. The addresses are of real businesses. The people are fictional.
iOS: a DEBUG launch argument and deeplink routing
On iOS, to enter screenshot mode, you pass a launch argument. Only DEBUG builds of the app include this argument. If you pass it, the app swaps out its auth session and service container for fixture versions. Writes to the app have no effect. Reads will return data from the fixtures. Analytics are disabled and first-run cards are automatically dismissed.
Another argument picks which scene to open. And via the same flow used by Live Activities and push notifications, that scene is opened:
Code:
#if DEBUG
enum ScreenshotMode {
static let isEnabled = ProcessInfo.processInfo.arguments.contains("-ScreenshotMode")
static let scene: ScreenshotScene? = {
let args = ProcessInfo.processInfo.arguments
guard isEnabled, let i = args.firstIndex(of: "-ScreenshotScene"),
args.indices.contains(i + 1) else { return nil }
return ScreenshotScene(rawValue: args[i + 1])
}()
}
enum ScreenshotScene: String {
case orders, orderStops, orderMessages
@MainActor func apply(to router: DeeplinkRouter) {
switch self {
case .orders: router.selectedTab = .orders
case .orderStops: router.open(.order(id: Fixtures.specimenOrderId, tab: .stops))
case .orderMessages: router.open(.order(id: Fixtures.specimenOrderId, tab: .messages))
}
}
}
#endif
No tap is needed in the app to open up a scene. So each screenshot is reproducible. In screenshot mode, even screens deeper than a route (e.g. a proof of delivery checklist) will have their selection seeded. These tests check is that a scene appeared. It's up to interaction tests to tap through to it.
Each scene is one XCUITest, which takes a scene argument, waits for a fixture string and then calls
snapshot("order-stops"). With this in mind, fastlane snapshot fires off these tests in each simulator. It uses a 9:41 status bar and en-US locale.A Python script spins up simulators (on the latest runtime) for each device. It sets GPS on the driver fixture, and pre-approves location. That way, we won't pick up a prompt in any capture.
In our iOS pipeline, we have lanes for the phone/tablet set and for the foldable set. And it matters which comes first.
Code:
bundle exec fastlane screenshots
bundle exec fastlane screenshots_duo
Each lane is in our project. It depends on our capture tests, setup of simulators and use of fixtures.
Android: a separate build type
On Android, our fixtures live in a build type of
screenshots, with its own source set. For the release and debug builds, we pull the auth session and API client from src/live/. But in the screenshots build, we pull them from src/screenshots/:
Code:
android {
buildTypes {
create("screenshots") {
initWith(getByName("debug"))
matchingFallbacks += "debug"
buildConfigField("String", "ANALYTICS_KEY", "\"\"")
}
}
sourceSets {
for (type in listOf("debug", "release")) {
getByName(type).java.directories.add("src/live/java")
}
}
}
In
src/screenshots, we use a Hilt module to plug in a FixtureSession and FixtureApiClient in place of the ones in the live module. Nowhere in our app code do we check BuildConfig.SCREENSHOTS, nor would we need to. And none of this code would be included in our dev or production APKs.Make missing fixtures fail loudly
Only the API calls used in each scene are handled by the fixture client. If we left stubs for other calls, we'd never know when a screen started making a new call.
So, in Kotlin, we delegate all methods of our objects that we aren't overriding to a
java.lang.reflect.Proxy, which throws an error:
Code:
class FixtureApiClient @Inject constructor() : ApiClient by unsupported() {
override suspend fun getDriver(): Driver = Fixtures.driver
override suspend fun listOrders(request: ListOrdersRequest) =
OrderList(items = Fixtures.orders)
}
private fun unsupported(): ApiClient =
Proxy.newProxyInstance(
ApiClient::class.java.classLoader,
arrayOf(ApiClient::class.java),
) { _, method, _ ->
Log.e("Screenshots", "No screenshot fixture for ApiClient.${method.name}")
throw UnsupportedOperationException("No fixture for ${method.name}")
} as ApiClient
If a scene fails to load, in logcat we'll see which method is missing.
Real taps, real emulators
On Android, our UI tests tap their way to each scene. For example, they'd tap on "In Progress", then on the order and then up on the list of stops.
UiAutomation takes a screenshot of the whole screen (the system bars, the map surface and everything else).A script (
capture.py) sets up the emulators and runs instrumentation tests. It picks a revision of the system image, cold boots the device, sets its location and puts the status bar into demo mode. This means each capture will show the same battery and clock, and there'll be no need to edit those pixels in any of them.Run from the Android repository, our project-defined lane captures and composes the configured devices:
Code:
bundle exec fastlane screenshots
In our listing, we included a phone, a tablet and an unfolded foldable. These are the devices we wanted to use. For your listing, you should pick devices that match the store slots you want to fill and the layouts you support.
In testing, we found that the screen of a device might show text before its map loaded tiles and styles. And so a test that relied only on the label of an order might end up with an incomplete map.
The map warm-up test
So we added a test that sorts first and captures nothing. On Android, this is
scene0_warmUpMaps; on iOS it's test00WarmUpMaps. Each test opens an order and waits 15 seconds (on Android) or 20 seconds (on iOS) before proceeding.Later scenes with a map will wait 6 seconds after the text appears. That's because the list appears before the route line. In our pipeline, such waits aren't sufficient to ensure that all the tiles in the map have finished loading. So we still need to inspect each image.
Keep captions separate from captures
A script (in Pillow) stitches together our raw captures for each platform. Each one pulls its caption from a
captions.json file. In general, the six scenes are in the same order across all three devices, as are most of the captions. But device size and wording differ across platforms. Here's an example of one caption file:
Code:
{
"scenes": ["offers", "order-stops", "proof", "offer-detail",
"order-messages", "in-progress"],
"captions": {
"order-messages": {
"title": "Stay in sync with dispatch",
"subtitle": "Every message stays with the shipment"
}
},
"devices": {
"fold": { "capture": [2152, 2076], "size": [3840, 2160] }
}
}
The script puts a caption band in each image, along with the screen and a background gradient at the size of the store. Type in the captions scales according to the shorter dimension of the canvas. That way, a phone, tablet and thumbnail would all show the same caption.
A three-line caption prints a warning instead of shifting the screen. We export RGB images with no alpha channel.
In the captions, iOS says "Turn-by-turn to each pickup and drop" and Android says "Directions to every pickup and drop." That's because in the Android app, navigation is delegated to the device's map app. The caption reflects the functionality on that platform.
Once I have raw captures, to change the caption in iOS I could simply run:
Code:
bundle exec fastlane compose_screenshots
That separation makes copy review much less expensive. A title change does not need another app build, simulator boot, or map download. I can adjust the words and inspect the composed result while preserving the original capture.
Reject bad captures before a human sees them
The compositor on each platform checks expected dimensions of the capture on each device (for Android) and on the foldable (for iOS). If the dimensions of a capture differ from expected by more than 2 pixels, the check fails. That would pick up a case where the foldable switched back to its cover screen. On Android, the capture is downsized to 16x16 pixels, in grayscale. If the lightest and darkest pixel in that capture differ by less than 12, the check fails. That would pick up almost any all-black capture. And it wouldn't tell me whether the map was rendered in a given capture.
Another risk with the foldable is that a reboot might put it into a different posture. A test could pass, but it could be capturing the wrong screen. In its lane, my capture scripts set the posture of the foldable. And the compositor checks the dimensions of the capture. But even a successful test wouldn't necessarily show me that the capture would be useful.
Screenshots also exercise layouts
By showing the same scene in different widths, I can see layout differences. A details sheet that leaves enough room on a phone can obscure the route on a wide display. If I take a picture of the map and its details, I could check for this.
More importantly, the set of scenes I generate for a store listing could be used in other ways. It could show the same scene across devices. And in each case, the content would be the same. That would make it easier to spot, for example, a misplaced control or a label that didn't wrap. And it would make it easier to tell whether some content was missing from the user's account.
This is only a small set of scenes. And I'd still want to check, for example, long text, errors, empty lists and interactions on the scenes. None of these would show up in a marketing image.
What I'd tell you before you start
- Put fixture code in a build type that isn't used in release. On iOS, that could be
#if DEBUG. On Android, a different source set and build type. - Base the time in the fixtures on the time in the status bar.
- Make unanswered fixture calls throw. In the log, show the name of the method.
- Include a test in my fixtures to warm up the map.
- Don't include a capture if it's the wrong size, or if there's little content in it. And check the content and layout of any capture I do produce.
- Don't overwrite previous captures if I change the copy. Instead, just update the composition.
- Before uploading images, check them.
I'd start with one scene whose data I could set. I'd take a picture of it on each device. And I'd save the raw image. Then I'd add the caption. It's easier to build up a set of scenes that way.