Screenplay Entities with XCTest and Swift (Part 2 of 3)
This is part 2 of 3 in a series about using the Screenplay pattern in Swift.
Part 1: What is the Screenplay Pattern?
Part 3: Writing Screenplay Tests in Swift
Part 1 of this series on the Screenplay pattern explained what the Screenplay pattern is, and where its value lies. In this part, we'll go through each of the entities in the Screenplay pattern in more detail, using a Swift + XCTest implementation of Screenplay, focusing around the 'bag' domain area of an e-commerce application.
For each domain area of your app, you will need to locate the elements which are part of that domain area. Element Locators (or 'Elements entities') will encapsulate the knowledge of how to locate the elements in each domain of the application under test.
Since this example is for tests which only ever use a single instance of the application under test, the reference to the application is created (statically) by the Elements entity. When using XCTest, any instance of XCUIApplication will be paired with your application under test by default. XCUIApplication gives you access to the elements of the application under test, which will allow you to expose elements to Actions and Questions without exposing the knowledge of how to find them. The Elements entity does not expose the app driver as part of its public or internal interface to help ensure that only Elements entities have direct access to XCUIApplication, and prevent any other types of objects from taking on the responsibility of locating elements of the application.
import XCTest enum BagElements { private static let app = XCUIApplication() static func cell(at index: Int) -> XCUIElement { return app.cells.element(boundBy: index) } static func removeButton(forCellAt index: Int) -> XCUIElement { return cell(at: index).buttons["delete"] } static var cells: XCUIElementQuery { return app.cells } }
The BagElements enum provides static functions, exposing all of the elements in the 'bag' domain of the app which are needed to execute the functional tests.
Elements entities should return XCUIElement objects whenever possible, however, in some situations, it is not possible to narrow the query down to a single object. This is often the case for groups of similar elements or repeated elements, like the list of item cells in the user's bag. In these cases, you may expose an XCUIElementQuery to allow Questions to query the group of elements as a whole, or to allow Actions to execute the same action on multiple elements at once.
Actions are static methods which encapsulate the implementation details of how to interact with elements using XCTest (or your test framework of choice) in order to perform certain actions.
Actions are grouped by domain area, so related actions may be grouped together under a single entity.
import XCTest enum BagActions { static func showRemoveItemButton(forItemAt index: Int) { let cell = BagElements.cell(at: index) cell.swipeLeft() } static func confirmRemoveItem(forItemAt index: Int) { let button = BagElements.removeButton(forCellAt: index) button.tap() } }
For each interaction which can be made by the user, there should be an action. In some cases, it may be helpful to have multiple levels of Actions (Actions which encapsulate groups of Actions), to provide a cleaner interface at the Task level.
Actions will be used by Task implementations, and will not be visible at the test level, so it's not necessary for Actions to present behavioural interfaces.
Tasks are objects which encapsulate the actions which need to be executed in order to complete a particular task. Some tasks may only need one Action to be completed. Other tasks may need multiple Actions to be undertaken. The job of a Task is to allow the author of a test to achieve some behaviour in a single line of code; without having to explicitly direct the test on what to interact with, and how to interact with it. As such, Tasks should exhibit a behavioural interface.
Task objects must conform to the Task protocol in order to be accepted by an Actor.
public protocol Task { func perform() }
When the Task is given to an Actor, the Actor will perform() the Task. In order to ensure that the task is performed correctly, your implementation of perform() must execute some Actions.
This example implementation of a Task shows how you would construct a Task for removing an item from the user's bag, using the Actions we defined in the Actions section above.
Tasks do not need to import XCTest as all of the work with XCTest entities is done by Actions and Element Locators.
struct RemoveBagItem: Task { let index: Int /// Executes actions to remove an item from the bag. func perform() { BagActions.showRemoveItemButton(forItemAt: index) BagActions.confirmRemoveItem(forItemAt: index) } /// Creates a Task for removing a bag item at the given `index`. /// /// - Parameter index: Index of the bag item to be removed. Bag items are 0-indexed. /// - Returns: RemoveBagItem Task which, when performed, will remove the item in the user's bag at the given `index`. static func inPosition(_ index: Int) -> RemoveBagItem { return RemoveBagItem(index: index) } }
Tasks are small, focused objects. As your test suite grows, it will begin to contain many Tasks for each different domain being tested. Each different Task should have its own object definition with a unique perform() implementation and at least one factory method (like inPosition(_:)). Some tasks may have additional factory methods for convenience. If many tests are removing bag items in position 0, you may want to create a convenience factory method called inPosition0().
Remember when naming your Task and its factory methods that it will be used in the format:
Task.factoryMethodName(with: someValue)
So be sure to name your Tasks and factory methods in a way that makes sense as part of this sentence-like code structure.
RemoveBagItem.inPosition(3)
Prefer method and function names that make use sites form grammatical English phrases. - Swift API Design Guidelines
Questions must conform to the Question protocol in order to be accepted by an Actor.
public protocol Question { func ask() }
When the Question is given to an Actor, the Actor will ask() the Question. In order to ensure that the question is answered, you must make sure that your implementation of the ask() method makes an assertion, failing the test if the answer is incorrect.
This example implementation of a Question shows how you would construct a Question about the number of items in the user's bag.
import XCTest enum BagItems: Question { case count(actual: Int, expected: Int) /// Ask this question. /// /// If the answer is incorrect, the test will fail. func ask() { switch self { case .count(let actualValue, let expectedValue): XCTAssertEqual(actualValue, expectedValue) } } /// Create a question about the number of bag items being displayed. /// /// - Parameter expectedValue: The number of bag items you expect to be displayed. /// - Returns: A Question about the number of bag items being displayed. When asked, this Question will compare the number of bag items displayed with the given `expectedValue`. If they are not equal, the test will fail. static func displayedCount(is expectedValue: Int) -> BagItems { let actualValue = BagElements.cells.count return count(actual: actualValue, expected: expectedValue) } }
This implementation could be extended to ask more questions about the state of the bag, such as whether a certain item is present in the bag, or what the total price is for all the items in the bag. For each different question you want to ask about this part of the application under test, add another case with the associated values needed to make your assertion (like count(actual:expected:)), and add another static factory method (like displayedCount(is:)) to create questions using that format.
Remember when naming your Question and its factory methods that they will be used in the format:
QuestionDomainArea.factoryMethodName(is: someValue)
So be sure to name your Question entities and factory methods in a way that makes sense as part of this sentence-like code structure.
BagItems.displayedCount(is: 5)
Prefer method and function names that make use sites form grammatical English phrases. - Swift API Design Guidelines
The Actor is the point of use for Tasks and Questions. It uses the Interface Segregation and Dependency Inversion principles (the I and D in SOLID) to accept any Task or Question.
You can give your Actor a name if you'd like. This isn't strictly necessary, but may help to add context to the test when it is written. In a scenario with multiple Actors, having a name will help you to differenciate between them, which can be particularly helpful when debugging.
Actors do not need to import XCTest. Any interactions with XCTest entities should be handled by other objects.
public class Actor { let name: String public init(called name: String) { self.name = name } // MARK: Public methods /// Make this Actor perform a setup task. /// /// Interacts with the application by performing the given `task`. /// /// - Parameter task: Task to be performed. public func has(_ task: Task) { perform(task) } /// Make this Actor perform a task which should cause your expected outcome. /// /// Interacts with the application by performing the given `task`. /// /// - Parameter task: Task to be performed. public func attemptsTo(_ task: Task) { perform(task) } /// Ask a question about what this Actor sees on the screen to verify that it is what you expect. /// /// Enquires about the state of the application using the given `question`. /// /// - Parameter question: Question to be answered. public func sees(_ question: Question) { ask(question) } // MARK: Private methods /// Asks the given `question`. private func ask(_ question: Question) { question.ask() } /// Performs the given `task`. private func perform(_ task: Task) { task.perform() } }
The has(_:), attemptsTo(_:) and sees(_:) methods are available publicly to allow test writers to use them. The private ask(_:) and perform(_:) methods separate the use of the Task and Question interfaces from the implementation of the public-facing methods. This means that in the event that the Task or Question interfaces change, that change will only need to be made in the relevant private method, rather than all of the public methods which use a Task or Question.
Actors are created and used inside test methods to facilitate the actions and assertions of a test.
In part 3, you'll see how the Screenplay objects are used at the test level to write behavioural tests, and a couple of options for the interface you can have.