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.swiftto 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
I am actively job-hunting and available
Interested? Feel free to reach