- Copy the
HomeScreen.swift
,ExampleTestApp.swift
, andIlluminatorTestCase.swift
files from the example app. - Record some element interactions using XCUITest against your app
- Copy those element interactions into new screen actions as appropriate
- Remove any anti-patterns as specified in this guide
- Set breakpoints where it says "(set breakpoint here)"
- Script your actions together using this basic skeleton:
func testUsingIlluminatorForTheFirstTime() {
// these 2 lines should go in the setUp() method for your test class,
// or better yet: the IlluminatorTestCase class
let interface = ExampleTestApp(testCase: self)
let initialState = IlluminatorTestProgress<AppTestState>.Passing(AppTestState(didSomething: false))
initialState // The initial state is "passing"
.apply(interface.home.enterText("123")) // to which we apply an action
.apply(interface.home.verifyText("123")) // and another action
.finish(self) // then finalize & handle the result
}
- Run tests and see what happens. Send complaints about this documentation.
#Illuminator Anti-Patterns and How to Fix Them
You don't have to do any of these things, because Illuminator is completely compatible with the XCTest paradigm. (You can even mix & match Illuminator actions with your own XCUITest code, if you need to migrate slowly.) But, you'll get the full benefits of Illuminator if you follow these guidelines.
These functions are test cancer -- they prematurely terminate the life of a test before it can tell you anything useful. All you get is a note on how it died. Depressing, right? Right. Avoid them.
Throw exceptions within your actions -- they will automatically be caught and, later, interpreted as a test failure in the .finish()
method.
For general purpose comparisons, throw IlluminatorError.VerificationFailed
.
XCTAssertEqual(app.allElementsBoundByAccessibilityElement.count, 3) // Bad
guard app.allElementsBoundByAccessibilityElement.count == 3 else { //
throw IlluminatorError.VerificationFailed(message: "!= 3") // Good
} // (In practice, wrap it to make a one-liner)
For element-centric assertions, use .assertProperty()
to check any property of an XCUIElement.
XCTAssert(myElement.exists) // Bad
try myElement.assertProperty(true) { $0.exists } // Good
If your concern is only that an element is ready for interactions (it exists and is hittable), use .ready()
.
XCTAssert(myElement.hittable) // Bad
myElement.tap() //
try myElement.ready().tap() // Good
Async would be great if it resulted in exceptions instead of test failures, but as of this writing it doesn't. Generally, the need for these functions implies that the app is busy doing something that you need to wait for. Illuminator is, at its core, designed to wait patiently.
Consider this example where we need to wait for the existence of an element to tap it.
let exists = NSPredicate(format: "exists == 1") //
expectationForPredicate(exists, evaluatedWithObject: myElement) { // Bad
myElement.tap() //
return true //
} //
waitForExpectationsWithTimeout(5, handler: nil) //
try myElement.waitForProperty(5, desired: true) { $0.exists } // Good
myElement.tap() // (you can even chain these calls)
Sleeping is a naive way of waiting for some UI change to happen. It's better to simply watch for the change and move on as soon as it happens. There are 2 anti-patterns here, one within a screen and one between screens.
Within the same screen, consider this example where tapping myButton
causes someOtherButton
to become visible, after several seconds.
myButton.tap() //
sleep(3) // Bad
someOtherButton.tap() //
myButton.tap() //
try someOtherButton.whenReady(3).tap() // Good
For uses of sleep between screens, see the "Best Practices" section instead.
If you want to tap a button called "Delete" in a table cell, and there are multiple cells each with their own "Delete" button, using XCUIElmentQuery to access the button will cause the test to automatically fail. The dreaded Multiple matches found
error.
This is tragic and unnecessary. Illuminator provides functions to more safely traverse the element tree.
Consider the situation in which multiple matches might appear, but you only ever want the first one.
// Assume that multiple "Delete" buttons exist
app.buttons["Delete"].tap() // Bad
let matches = app.buttons.subscriptsMatching("Delete") //
guard let myButton = matches[safe: 0] else { //
throw IlluminatorError.VerificationFailed(message: "None") // Good
} //
myButton.tap() //
Or, perhaps you expect one and only one match. Illuminator has an operator for that as well.
// Assume that multiple "Delete" buttons might exist, but shouldn't
app.buttons["Delete"].tap() // Bad
try app.buttons.hardSubscript("Delete").tap() // Good
If a particular user interaction spans a change of screens, break that into separate actions on separate screens. The most common blunder here is considering a modal to be the same "screen" as the page it obscures.
For example:
// snip from what might be an "account" screen
public override var isActive: Bool {
return app.navigationBars["Account"].exists
}
func logIn(username: String, password: String) -> IlluminatorActionGeneric<AppTestState> {
return makeAction() {
app.buttons["Log in"].tap() // trigger the login modal
sleep(1) // THIS IS BAD
app.textFields["Username"].typeText(username) // THIS IS A NEW SCREEN
app.textFields["Password"].typeText(password)
app.buttons["Submit credentials"].tap()
}
}
Split this action into two separate screens. First, the Account screen:
public override var isActive: Bool {
return app.navigationBars["Account"].exists
}
func openLoginModal() -> IlluminatorActionGeneric<AppTestState> {
return makeAction() {
try app.buttons["Log in"].ready().tap()
}
}
Next, the modal screen:
public override var isActive: Bool {
return app.buttons["Submit credentials"].exists
}
func logIn(username: String, password: String) -> IlluminatorActionGeneric<AppTestState> {
return makeAction() {
try app.textFields["Username"].ready().typeText(username)
app.textFields["Password"].typeText(password)
app.buttons["Submit credentials"].tap()
}
}
Consider the case where you have the same tab bar of navigational elements on several screens. Rather than making a base screen that the others inherit from, it's easier (and necessary, just in case you'd run into multiple inheritance problems) to define a protocol extension for screens, that supply the extra features.
protocol TabBarScreen {
var app: XCUIApplication { get }
var testCaseWrapper: IlluminatorTestcaseWrapper { get }
func makeAction(label l: String, task: () throws -> ()) -> IlluminatorActionGeneric<AppTestState>
}
extension TabBarScreen {
// The tab bar should be able to assert its own existence
var tabBarIsActive: Bool {
get {
return app.tabBars.elementBoundByIndex(0).exists
}
}
func toHome() -> IlluminatorActionGeneric<AppTestState> {
return makeAction(label: #function) {
try self.app.tabBars.buttons["Home"].whenReady(3).tap()
}
}
}
Adding tab bar functionality to a screen is now as simple as marking it with the protocol.
public class HomeScreen: IlluminatorDelayedScreen<AppTestState>, SearchFieldScreen, TabBarScreen {
// consider the tab bar in whether the screen is active
public override var isActive: Bool {
guard tabBarIsActive else { return false }
guard searchScreenIsActive else { return false }
return app.navigationBars["Home"].exists
}
Make sure that your IlluminatorTestProgress
variable calls .finish()
, otherwise XCFail()
will never trigger.