visit
Photo by on Unsplash.
While a unit test checks one particular place (scene/function/module), end-to-end tests check the whole flow that the user goes through.
User Interface (UI) Tests are a convenient tool that can be used for this purpose. They launch an app and start interacting with all the visible elements going from one screen to another the same way as the user does.
Advantages of UI testing:
Concerns Regarding UI testing:
Harder to build: Building a UI Test scenario requires preparation of the UI Elements and takes considerable time to create all the dependencies between our screen flows.
Longer running time: Each test takes about 13–34 seconds to run in total, all 21 UI tests take 8 minutes, whereas all our 42 Unit tests only take 2 seconds.
Harder to fix and maintain: Detecting the issue is a good thing, but when you want to catch a bug that’s far away from the code, it’s much harder to fix. When a unit test fails, it shows you the exact way it was broken. And yes, all changes in the UI require us to modify the UI test as well.
Once we started to write UI tests, one thing became obvious — a vast usage of string constants will clutter the code and make it difficult to maintain.
All components visible on the screen are represented as XCUIElement
objects and the most common way to identify them is to use string as an identifier.
Page Object
pattern is an effective solution for this problem. This is the description of our implementation.
Every screen is represented by one PageObject
and every PageObject
conforms to Page protocol:
import XCTest
protocol Page {
var app: XCUIApplication { get }
var view: XCUIElement { get }
init(app: XCUIApplication)
}
This is how the PageObject
looks like👍🏻:
import XCTest
class LoginPage: Page {
var app: XCUIApplication
var view: XCUIElement
private let loginButton: XCUIElement
private let emailTextField: XCUIElement
private let passwordTextField: XCUIElement
required init(app: XCUIApplication) {
self.app = app
view = app.otherElements["Login_Scene"]
emailTextField = app.textFields.firstMatch
passwordTextField = app.secureTextFields.firstMatch
loginButton = app.buttons["Log In"].firstMatch
}
@discardableResult
func tapEmailTextField() -> Self {
emailTextField.tap()
return self
}
@discardableResult
func typeInEmailTextField(_ text: String) -> Self {
emailTextField.typeText(text)
return self
}
@discardableResult
func tapLoginButton() -> DashboardPage {
loginButton.tap()
return DashboardPage(app: app)
}
}
Pay attention to the view = app.otherElements[“Login_Scene”]
line. Unlike buttons, images, or text fields, the main view should have an explicitly set identifier.
We set it in the UIViewController
of every scene, in the viewDidLoad
method:
override func viewDidLoad() {
super.viewDidLoad()
view.accessibilityIdentifier = "Login_Scene"
}
Another thing to mention about PageObject
is the return type of every function — it is either Self or another PageObject.
func testLogIn() {
app.launch()
// let's assume that login page is the first one that is shown on start
let loginPage = LoginPage(app: app)
let dashboardPage = loginPage
.checkLoginButtonIsDisabled()
.tapEmailTextField()
.typeInEmailTextField("[email protected]")
.tapPasswordTextField()
.typeInPasswordTextField("password")
.checkLoginButtonIsEnabled()
.tapLoginButton()
guard dashboardPage.view.waitForExistence(timeout: 20) else {
return XCTFail("Dashboard Scene must have been shown")
}
}
We can reuse these Page Objects
for different flows, we want to check in our UI Test, and if we make some changes in our UI, we only need to fix it in one place.
If you want to start writing UITests
in your project, start by creating Page Objects
. It will save you a lot of time and mental resources 😌 in the future.
Should you use a real server or a mocked one?
We decided to implement a mock service because of the following reasons:
We didn’t have a dedicated server that could be used for running tests only.
Our existing development servers had their state updated and often changed.
Supporting a dedicated server in an up-to-date state would require more time to communicate with the backend team.
The network request execution takes time on a real server, but with mocks, we receive the response almost instantly.
We created an entity called MockServer
that basically contained one function:
func handleRequest(request: NetworkRequest, completion: @escaping RequestCompletion)
It accepts the NetworkRequest
object, and closure is used for passing the request’s response.
In our case, NetworkRequest
is a simple structure containing all the necessary data to make requests (URL, HTTP method, parameters, body, etc.) and conforms to Equatable protocol (to be able to use it in the switch statement).
typealias RequestCompletion = (Data?, WirexAPIError?) -> Void
final class MockServer {
static let shared = MockServer()
private init() {
// function that clears local database on app start. I did not include it in the code snippet.
self.clearDataIfNeeded()
}
func handleRequest(request: NetworkRequest, completion: @escaping RequestCompletion) {
// Convenience method. Use it for error mocking
let sendError: (WirexAPIError) -> Void = { error in
self.processResponse(request: request, data: nil, error: error, completion: completion)
}
// Convenience method. Use it to mock response. Pass nil if only 200 is needed
let sendSuccess: (Data?) -> Void = { data in
self.processResponse(request: request, data: data, error: nil, completion: completion)
}
switch request {
case .postSignIn():
if !isDeviceConfirmed {
sendError(WirexAPIError.confirmSMSError)
} else {
sendSuccess(nil)
}
// all other cases
default:
sendSuccess(nil)
// This way we detect the requests, we forgot to mock or mocked wrong while writing ui test
print("Here request url, body and any needed info is printed")
}
}
private func processResponse(request: NetworkRequest,
data: Data?,
error: WirexAPIError?,
completion: @escaping RequestCompletion) {
if let data = data, let onSuccess = request.onSuccess, error == nil {
onSuccess(data, { completion(nil, $0) })
} else {
completion(data, error)
}
}
}
Mocked data is passed like this sendSuccess(ConfirmLoginMock.response)
. The mock itself is just converted to the data JSON string:
class ConfirmLoginMock {
static let response = """
{
"token": "mockedToken",
"expires_at": "2022-03-19T16:38:13.3490000+00:00"
}
""".data(using: .utf8)!
}
All that is left to do is to inject the handleRequest
function into the app’s network layer. In our project, we have an entity called NetworkManager that has a single point for all incoming requests. It accepts the same parameters as the above-described function:
func runRequest(request: NetworkRequest, completion: @escaping RequestCompletion) {
#if DEBUG
if isRunningUITests {
MockServer.shared.handleRequest(request: request, completion: completion)
return
}
#endif
// Here goes the code that makes actual API request and handles response
}
We use a constant isRunningUITests
to detect whether to run tests or not. And since we’re not able to pass the data between the main project and UI tests directly (UI tests are run in isolation), we need to use the launch arguments of the app.
// Every UI test can set up it's own launchArguments if needed
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments.append("UITests")
}
let isRunningUITests = ProcessInfo.processInfo.arguments.contains("UITests")