iOS testing
Unit testing
- Use vanilla XCTest testing framework for Unit testing. We found that third party BDD frameworks does not integrate well with Xcode and lacks stability after a new Xcode version is released.
- Match tests folder structure to app structure. For example,
App/Data/Models/Client.swift
tests are located inAppTests/Data/Models/ClientTests.swift
.
UI Testing
- Use Xcode UI Testing framework for user interface testing.
- Record tests with Xcode and clean up the generated code.
- Run UI tests on both iPhone and iPad simulators.
- Do not stub network requests. We run our tests against dedicated testing environment. This ensures that our API works correctly.
- All test cases inherit from BaseUITests class which handles initialization and defines helpers.
- Use MagicalRecord’s
CoreDataStackWithInMemoryStore
to clean up the database before running a test. - Clear
NSUserDefaults
after the app starts. - Define helper methods (waitForElementToAppear, goBack, isIPad, etc) in BaseUITests.
- Create methods for steps common to many tests (login, signOut, etc) in BaseUITests.
- Create methods for common steps to one test case (selectClient, etc) in the same test case where they are used.
- Use waitForElementToAppear helper when expecting UI to change after a network request (defaults to 60 seconds).
- Use accessibility identifiers in UI elements to find them instead of using their position in the element tree.
Disabling features in UI tests
- Disable features which are not being tested in a test case. It saves time when running specs and sometimes makes it easier to write a test case.
- We have
AppSettings
struct defined in tests to allow disabling features. All features are enabled by default.
struct AppSettings {
var loginEnabled = true
var welcomeEnabled = true
func toLaunchEnvironment() -> [String: String] {
return [
"loginEnabled" = loginEnabled.stringValue,
"welcomeEnabled" = welcomeEnabled.stringValue
]
}
}
- Call launchApp with AppSettings before each test.
override func setUp() { super.setUp() let settings = AppSettings(loginEnabled: false, welcomeEnabled: true) launchApp(settings: settings) }
- Before the app is launched when running a test, app settings are converted to launch arguments and passed to app runtime.
func launchApp(settings: AppSettings) {
app = XCUIApplication()
XCUIDevice.sharedDevice().orientation = .Portrait
app.launchEnvironment = settings.toLaunchEnvironment()
app.launch()
waitForAppLaunch()
}
- In the app delegate, launch environment values are parsed and features are disabled by settings variables on a shared instance of
Settings
.
for (key, value) in NSProcessInfo.processInfo().environment {
switch key {
case "loginEnabled":
Settings.sharedInstance.surveyEnabled = NSString(string: value).boolValue
case "welcomeEnabled":
Settings.sharedInstance.welcomeEnabled = NSString(string: value).boolValue
default:
break
}
}
Running tests
- We have a dedicated Jenkins slave running macOS to run iOS tests.
- Use fastlane scan tool to run tests on both iPhone and iPad simulators.
desc "Runs all the tests"
lane :test do
scan(scheme: 'PhysiApp', devices: ["iPhone 7", "iPad Air 2"])
end
- Use ios-deploy tool to install the newest version of the app (from testing branch) on a dedicated iOS device for manual testing.
desc "Install on local device"
lane :install do
gym(scheme: 'PhysiApp', output_directory: 'build', output_name: 'PhysiApp.ipa')
install_on_device(device_id: 'abc123', ipa: './build/PhysiApp.ipa')
end
Build Configuration
- We have 3 build configurations: Debug, Testing, and Release. Having different configurations allows us to pass Swift compiler flags (DEBUG, TESTING), which we convert to
ProjectConfiguration
enum. Then we can write conditional code based onProjectConfiguration.currentConfiguration
.
enum ProjectConfiguration {
case Debug
case Testing
case Release
}
static var currentConfiguration: ProjectConfiguration {
# if DEBUG
return .Debug
# elseif TESTING
return .Testing
# else
return .Release
# endif
}
static var APIURLString: String {
switch currentConfiguration {
case .Debug:
return "https://staging.example.com"
case .Testing:
return "https://test.example.com"
case .Release:
return "https://example.com"
}
}