Creating screenshots with localization
I don’t know why, but somehow I lost my Fastfile. I swear I felt like this guy lol. So, I decided to write my Fastfile from scratch again.
I have a parcel tracking application called Find My Parcels I wanted to update its screenshots for iOS and do this with as little manual work as possible. My app supports both iPhone and iPad, and with 10 languages and 4 pictures for each device, I had to create 200 screenshots. Why 200?
App Store wants you to have screenshots for following devices: Screenshot Specifications You can actually skip creating 6.5” screenshots but I wanted to supply all the screenshots that is listed on App Store Connect.
iPhone
Size | Example |
---|---|
5.5” | iPhone 8 Plus |
6.5” | iPhone 14 Plus |
6.7” | iPhone 15 Pro Max |
iPad
Size | Example |
---|---|
12.9” | iPad Pro (12.9-inch) (6th generation) |
13” | iPad Pro 13-inch (M4) |
This process takes a long time, so I wanted to automate it.
Tests
To navigate different screens and identify regressions and bugs, it is crucial to have some UI tests in your code. You can simply open your app, write a function that starts with test
, and hit the red record button.
When you’re writing your tests, there are a couple of things you should be aware of.
Localizations
If your app supports multiple languages, you can’t use static text directly. There are a couple of strategies you can use:
- Use an index-based strategy to find the elements. For example, if you want to click the first cell of a table, you can use the following code:
app.tables.staticTexts.element(boundBy: 0).tap()
This strategy works well for clicking navigation buttons, which are generally consistent inside the app. However, it will not work for complex content and layouts.
- Use localized strings. You might ask, why can’t I use
NSLocalizedString
? It won’t work because your localization strings are located inside the main app’s bundle, and you can’t access them inside your tests. You can either add localization strings or catalogs to your UI Test targets, or you can write a basic function to translate the values inside your test based on the language, like this:
let localizationDict: [String: [String: String]] = [
"en-US": [
"Theme": "Theme",
"Night":"Night",
"Settings": "Settings"
],
"tr-TR": [
"Theme": "Tema",
"Night":"Gece",
"Settings": "Ayarlar"
]
]
@MainActor func localized(_ key: String) -> String {
let language = Snapshot.currentLocale // part of Fastlane
if let translations = localizationDict[language], let translatedText = translations[key] {
return translatedText
} else {
return key
}
}
Then, with a couple of simulators, make sure your tests are working. If your app supports iPad, you need to be extra careful with your tests. For example, if your app uses a navigation controller, you may only see one navigation controller on iOS but multiple ones on iPad. You might need to add the following code blocks to run different tests on iPad.
let leftNavigationBar = app.navigationBars.element(boundBy: 0)
let detailNavigationBar = app.navigationBars.element(boundBy: 1)
if UIDevice.current.userInterfaceIdiom == .pad {
let informationButton = detailNavigationBar.children(matching: .button).element(boundBy: 1)
informationButton.tap()
} else {
let informationButton = leftNavigationBar.children(matching: .button).element(boundBy: 1)
informationButton.tap()
}
Screenshots
Our tests are executing well, but how will we get the screenshots?
XCUIScreenshot
Xcode supports taking screenshots and adding them to test results via XCTAttachment
. After you run your tests, you can extract the files from .xcresult
. I wrote a simple parser for this, which you can download from here You can also use xcparse
Fastlane
You can add SnapshotHelper.swift
to your project and let the Fastlane do the heavy lifting. Instead of XCUIScreenshot
, you use the snapshot(_:timeWaitingForIdle:)
function to take the screenshots. Please check the documentation
In this blog post, I will use Fastlane to take the screenshots.
Seeding data for the app
We want our tests to run fast, and we want to have data that is possibly localized for the app. I didn’t want to use any network requests or mock functions to create the complex data structure I need inside the app. Instead, I chose to populate the data I recorded earlier and copy it to the simulator directory before running the tests.
My app uses CoreData, and I use App Groups to share data between the main app and widgets. I strongly recommend using App Groups and Keychain Groups to share data. You may not need App Groups right now, but in the long run, you will definitely need it.
Our strategy will look like this:
- Create a Fastfile with our screenshot lane.
- Feed the simulators for each language.
- For each simulator, find the simulator, boot it, and copy the files. For this to work, you may need to install the app once. We can also automate this, but I didn’t implement it in my Fastfile.
- When the simulator is ready, run our tests with the
capture_screenshots
action. - To speed up testing, we will only build our test target once and use the DerivedData folder to run other tests.
- You may also use SimulatorStatusMagic and AppleTime to set the date as
9:41
Even though Fastlane supports overriding status bar, it didn’t work for all the simulators I tried and it has bugs related to timezones.
You can find my current Fastfile down below and examine its source code for more details.
require 'tmpdir'
def get_app_container(device_uuid)
# if you're not using App Groups, just delete group identifier
stdout, stderr, status = Open3.capture3("xcrun simctl get_app_container #{device_uuid} com.mustafadur.Kargotakip group.com.mustafadur.kargotakip")
if status.success?
return stdout.strip
else
raise "Failed to get app container: #{stderr}"
end
end
def ios_version(device_name)
if device_name == 'iPhone 8 Plus'
'16.4'
else
'18.0'
end
end
def copy_files_to_simulator(device_uuid, folder_location)
app_container_path = get_app_container(device_uuid)
puts "Copying files to #{app_container_path}..."
kargo_shm = File.join(app_container_path, "Kargo.sqlite-shm")
kargo_wal = File.join(app_container_path, "Kargo.sqlite-wal")
FileUtils.rm_rf(kargo_shm)
FileUtils.rm_rf(kargo_wal)
FileUtils.cp("#{folder_location}/Kargo.sqlite", app_container_path)
puts "Files copied successfully!"
end
def boot_simulator(device_udid)
stdout, stderr, status = Open3.capture3("xcrun simctl boot #{device_udid}")
if status.success?
puts "Simulator #{device_udid} booted successfully."
elsif stderr.include?("Unable to boot device in current state: Booted")
puts "Simulator #{device_udid} is already booted."
else
raise "Failed to boot simulator: #{stderr}"
end
end
def find_device(device_name)
device_list = FastlaneCore::Simulator.all()
device_udid = ""
if device_list.find_all { |device| device.name == device_name }.any?
device_udid = device_list.find_all { |device| device.name == device_name and device.ios_version == ios_version(device_name)}.first.udid
end
device_udid
end
default_platform(:ios)
languages = [
"en-US",
'de-DE',
'fr-FR',
'it-IT',
'es-ES',
'tr-TR',
'ru-RU',
'nl-NL',
'pt-PT',
'pl-PL'
]
devices = [
"iPhone 15 Pro Max", # 6.7"
"iPhone 14 Plus", # 6.5"
"iPhone 8 Plus", # 5.5"
"iPad Pro 13-inch (M4)",
"iPad Pro (12.9-inch) (6th generation)"
]
test_without_building = false
platform :ios do
desc "Generate new localized screenshots"
temp_dir = Dir.mktmpdir
test_without_building = false # first build is always fresh
lane :screenshots do
languages.each do | language |
puts "Language #{language}"
devices.each do |device|
puts "Seeding #{device}"
device_udid = find_device(device)
puts "#{device} #{device_udid}"
boot_simulator(device_udid)
copy_files_to_simulator(device_udid,"./Sample Database/#{language}/")
end
# We seeded all devices let's start the screenshot
capture_screenshots(
step_name: "Screenshots for #{language}",
workspace: "Kargotakip.xcworkspace",
scheme: "Kargotakip",
skip_open_summary:true,
derived_data_path: temp_dir,
devices: devices,
languages: [language],
clear_previous_screenshots: false,
test_without_building: test_without_building
)
test_without_building = true # after the first build, we can use the old test runner
end
FileUtils.remove_entry temp_dir
end
end