Using a test suite for automation

Have you ever needed to automate a task that needs to be run on an iOS simulator and not known quite how to make it work in an unsupervised way? As an example - imagine we need to take some JSON files, run each one of them through an app and capture the generated UI so that the screenshots can be uploaded for further processing.


At a high level we want an executable that can take a directory full of input, run a task inside the simulator and place the output of all of the tasks into a different directory. We’ll start by looking at building something to run our task inside a simulator and then see how to get data in/out of the process.


Running our task

To run our code inside the simulator we can make use of a new test suite with a single test method. The test method will enumerate files found at the input directory path, execute some code on the simulator and store the results in a different directory. Here’s a basic implementation of the above:

import XCTest

class Processor: XCTestCase {
    func testExample() throws {
        let (inputDirectory, outputDirectory) = try readDirectoryURLs()
        let fileManager = FileManager.default
        
        try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
        
        try fileManager.contentsOfDirectory(at: inputDirectory, includingPropertiesForKeys: nil).forEach {
            try eval($0).write(to: outputDirectory.appendingPathComponent($0.lastPathComponent))
        }
    }
    
    private func readDirectoryURLs() throws -> (input: URL, output: URL) {
        func read(_ path: String) throws -> URL {
            URL(fileURLWithPath: try String(contentsOf: Bundle(for: Processor.self).bundleURL.appendingPathComponent("\(path)-directory-path")).trimmingCharacters(in: .whitespacesAndNewlines))
        }
        
        return try (read("input"), read("output"))
    }
}

The interesting things to note above are:

There are plenty of things that could be done to customise the above for individual use cases but it’s enough for this post.

How do we set up the input-directory-path and output-directory-path files inside the test bundle?


Wrapping the task

In order to inject the relevant paths we need to ensure that our test suite is built and then run as two separate steps. This gives us a chance to build the project, inject our file paths and then actually execute the test.

A Ruby script to do this would look something like the following:

#!/usr/bin/env ruby

unless ARGV.count == 2
  puts "USAGE: #{$PROGRAM_NAME} INPUT_DIRECTORY OUTPUT_DIRECTORY"
  exit 1
end

input_directory, output_directory = *ARGV

def xcode_build mode
  `xcodebuild #{mode} -scheme Processor -destination 'platform=iOS Simulator,name=iPhone 12,OS=14.5' -derivedDataPath ./.build`
end

xcode_build "build-for-testing"

Dir[".build/**/Processor.app"].each do |path|
  write = -> name, contents do
    File.open("#{path}/PlugIns/ProcessorTests.xctest/#{name}-directory-path", 'w') do |file| file.puts contents end
  end
  write["input", input_directory]
  write["output", output_directory]
end

xcode_build "test-without-building"

This script is doing the following:

With all of these pieces in place and assuming we named this script run-processor we can execute this script like this:

./run-processor /path/to/input /path/to/output

Result

We have a pretty bare bones implementation that should demonstrate the general idea and leave plenty of scope for expansion and experimentation.