Photo by Kristopher Roller on Unsplash.
During their Developer careers, coders may encounter a Wirex fintech app that combines traditional money and cryptocurrencies onto one platform.
For us in the know, it is crucial to understand what’s going on with the app from the user’s perspective; Especially when 4 million active clients trust it with their money.
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.
As a result, we will be able to chain our methods. Here is what the resulting test may look like:
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")
}
}
So it’s really quite simple, isn’t it?
We can chain our screens to create concise, readable UI Tests that are also easier to maintain.
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?
The second problem we’ve faced was the UI test’s communication with the backend API. We had to choose either to use our development server or to create mocks that imitate API requests.
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.
A server could be off or it could be used to test a new API, and some APIs might be broken at the time.
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).
This function contains the logic that decides whether the request should return a successful response or error:
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": "2021-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
}
Any request that will be passed to it will be either mocked or sent to an actual server.
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.
We can do it in two steps. The first is to set an argument before the start of the UI test:
// 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")
}
The second step is getting this argument somewhere in the main project.
let isRunningUITests = ProcessInfo.processInfo.arguments.contains("UITests")
Make sure you clear your local persistent storage, User Defaults, local caches, or any temporary data that influences your app behavior before the UI test is run.
Every test needs to start from the same app state because it may still succeed when run independently but may fail when run together with the rest of the tests if they share the same state.
Double-check your mock data, it may save you a lot of time and effort. We used the raw JSON data for the mocks. We always checked that it was a valid JSON before passing it into the codebase.
Keep your tests code clean. Although it’s not a production code, you may need to return to it in the future — if it’s a mess, it will be hard to work on.