iOS — How to write Unit tests, UI tests for SwiftUI applications

Jakir Hossain
9 min readOct 24, 2023

In the application development process, testing is a crucial aspect of the development process that ensures the quality, reliability, and correctness of an application. The benefits of testing include preventing bugs, reducing development costs, and improving performance before the app is released to the public.

Types of Testing in Software Development

Including iOS, there are several types of tests in the Software Development process, each serving a specific purpose:

  • Unit testing: A unit is the smallest testable component of an application. Unit testing involves testing individual components or units of code in isolation, such as functions or methods.
  • Integration testing: Integration testing focuses on testing how different components or modules of the application work together.
  • UI Testing: UI testing involves testing the user interface of the application to ensure that the user interface elements and interactions work correctly.
  • Functional testing: Functional testing ensures that the app’s features and functions work as intended, considering various scenarios and user interactions.
  • Performance testing: Testing how the software performs under different workloads such as low network connectivity or heavy usage. It helps identify bottlenecks, memory leaks, and other performance-related issues.
  • User Acceptance Testing: Verifying whether the whole system works as intended.
  • Regression testing: Regression testing ensures that new code changes or updates don’t introduce new bugs or break existing functionality.
  • Security Testing: Security testing focuses on identifying vulnerabilities and security risks in your app.
  • Accessibility Testing: Accessibility testing ensures that your app is usable by individuals with disabilities.

Testing in Software development is an iterative process that should be performed throughout the development lifecycle. To automate testing in iOS app development, we can use the XCTest framework to enable testing and write test cases for our project. We will focus on two main types of testing in iOS: Unit testing and UI testing.

Writing Unit test using XCTest for iOS project:

When we create a project in Xcode, we can Include Tests in our project. Xcode will generate a sample Test code for us. For now, we will skip and add XCTest manually.

Let’s say we are going to create an app to convert Bitcoin to USD. For UI, we can write something like this:

import SwiftUI

struct ContentView: View {

@State var input = ""
@State var result = "0.00"
let converter = Converter()

var body: some View {
VStack {
TextField("BTC", text: $input).font(.title)

Button("Convert") {
result = converter.btcToUsd(btc: Double(input) ?? 0.0 )
}.font(.title)

Divider()

Text(result).font(.title)
}
.padding()
}
}

#Preview {
ContentView()
}

Here is the output of the UI code:

UI of BTC to USD app

Here is the code for Converter.swift:

import Foundation

class Converter{

let btcToUsdRate = 30570.70

func btcToUsd(btc: Double) -> String {

if btc <= 0 {
return "Please enter a positive number."
} else{
return String(format: "%.2f", btc * btcToUsdRate)
}
}
}

This Converter is the main functional code of our app. We will write test code for btcToUsd function.

To enable unit tests in an existing project, navigate into File > New > Target... Here, search for Unit Test or just test. Select the Unit Testing Bundle and click Next.

You can change its name and other property. For now, leave as it is and click on finish. This will enable us to XCTest in our project. It will also generate bare-bones code for us. I will explain this code later. For now, remove all the code and write a test case for btcToUsd like this:

import XCTest

final class BTCtoUSDTests: XCTestCase {

func testBTCtoUSDcoverter() {
let converter = Converter()

// Arrange
let btc = 1.0

// Act
let usd = converter.btcToUsd(btc: btc)
let expected = "30570.70"

// Assert
XCTAssertEqual(usd, expected, "Test faild.")

}
}

You will get an error in let converter = Converter()

Cause this file does not know about Converter.swift file. Get back to Converter.swift file. From inspector > Target Membership, select AppNameTests like BTCtoUSDTests. Now you will able to access this Converter.swift file from the test class.

You can also enable test and import a project to test ing @testable identifier like this:

@testable import TestExample

Here is the BTCtoUSDTest.swift file:

import XCTest
@testable import TestExample

final class BTCtoUSDTests: XCTestCase {

func testBTCtoUSDcoverter() {
let converter = Converter()

// Arrange
let btc = 1.0

// Act
let usd = converter.btcToUsd(btc: btc)
let expected = "30570.70"

// Assert
XCTAssertEqual(usd, expected, "Test faild.")

}
}

Explanation of the code: We have to start a test function using the test keyword. After the test, we can use anything we want. The more declarative the name, the better. Each test case contains three main parts.

  • Data arrange
  • Act on those data
  • Assert or check the result.

In the arrangement, we need to set the data required to test the unit we are going to test. Act means we check what will happen if we pass the data. Using assert, we check what actually should happen. Hope you get the idea.

To test this test case, we can click on the Dimond icon near the function. If the function passes the test, it will turn green. Otherwise, it will give an error.

Let’s write another test case. This time, we will check what is happening if we write a negative value:

   func testBTCtoUSDcoverterForNegetive() {
let converter = Converter()

// Arrange
let btc = -5.0

// Act
let usd = converter.btcToUsd(btc: btc)
let expected = "Please enter a positive number."

// Assert
XCTAssertEqual(usd, expected, "Test faild.")

}

As we checked 0 or a negative value in our btcToUsdRate function, this test case also passes.

Now you may ask why we need to write a test case.

There are several reasons why we need to write test cases. Imagine you are working on a large project. Several developers are working on the same project with you. Someone came to your code and found some code that he thinks is not required, but actually, it is an important component of the app. Now if he tries to remove something, the test case will not pass and it will be easier to fix the error. Test also helps to automate testing of the project and helps to develop bug-free software.

As you can see, we can write our Converter function this way:

func btcToUsd(btc: Double) -> String {
return String(format: "%.2f", btc * btcToUsdRate)
}

If you run the app and try to convert some positing value, it will work fine. But if you run tests, the test will not pass. Cause we did not handle negative values.

For this test case, we checked the test case using XCTAssertEqual. We can also use XCTAssertTrue, XCTAssertFalse, XCTAssertNotEqual etc to compare what we get from the function and what we should we get.

Explanation of generated test class:

When we create a new project with Test or add a Test target to a project, we get something like this:

import XCTest

final class AppNameTests: XCTestCase {

override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}

override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}

func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
// Any test you write for XCTest can be annotated as throws and async.
// Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
// Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
}

func testPerformanceExample() throws {
// This is an example of a performance test case.
measure {
// Put the code you want to measure the time of here.
}
}

}

Here,

  1. override func setUpWithError() throws {…} start each time we run a test case. So we can create a new instance of a class that is required for the test case.
  2. override func tearDownWithError() throws {…} is called after the compilation of a method. So we can release any instance here. This is good for memory management.
  3. func testExample() throws {…} is an example test case. You can rename this or remove this and write your test case.
  4. func testPerformanceExample() throws {} — this method will give the total time of your test classes. You will see the result in the Xcode Console.

As we need a new instance of the Converter() class each time we run a test case, we can create the instance in tearDownWithError method. Then after completing running the test case, we can release the instance in tearDownWithError method. So we can write our test case like this:

import XCTest
//@testable import TestExample

final class BTCtoUSDTests: XCTestCase {

var converter : Converter!

override func setUpWithError() throws {
converter = Converter()
}
override func tearDownWithError() throws {
converter = nil
}


func testBTCtoUSDcoverter() {


// Arrange
let btc = 1.0

// Act
let usd = converter.btcToUsd(btc: btc)
let expected = "30570.70"

// Assert
XCTAssertEqual(usd, expected, "Test faild.")

}

func testBTCtoUSDcoverterForNegetive() {
let converter = Converter()

// Arrange
let btc = -5.0

// Act
let usd = converter.btcToUsd(btc: btc)
let expected = "Please enter a positive number."

// Assert
XCTAssertEqual(usd, expected, "Test faild.")

}

}

It doesn’t matter which order we write tearDownWithError ,setUpWithError or any methods.

Writing UI test for SwiftUI application

Writing a UI test is similar to writing a Unit test. To write a UI test, we have to include the UI Test Target in our app.

To enable UI Test Target in an existing project, navigate into File > New > Target... Here, search for UI Test or just test. Select the UI Testing Bundle and click on Next.

For now, leave as it is and click on finish. This will enable UI Tests in our project. It will also generate bare-bones code for us. This time there will be two files. One for App Launch Test. You can run test cases and check if the app is launching correctly. Another file is AppNameTest.swift. For now, we will remove all code and will write our own.

We can write various types of UI tests, such as checking the existence of UI elements. Our app requires three UI components to operate, and we can check the existence of these components like this:

import XCTest
@testable import BTCtoUSD

final class BTCtoUSDUITests: XCTestCase {

func testCheckAllUIFields(){
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()

XCTAssert(app.textFields["BTC"].waitForExistence(timeout: 0.5))
XCTAssert(app.buttons["Convert"].waitForExistence(timeout: 0.5))
XCTAssert(app.staticTexts["result"].waitForExistence(timeout: 0.5))
}
}

This code will check all UI component’s existence. If one is missing, the test will not pass.

Normally we can access a UI file using its string value. Or we can set an identifier using accessibilityIdentifier like this:

Text(result).font(.title.accessibilityIdentifier(“result”)

Now we can access this Text view using result identifier.

We can set a value in a TextField, tap a button and check outcomes automatically using UI tests. Let’s write another method to check if the converter is working correctly and showing the result in Text view:

    func testIsAppShowingResultCorrectly(){

let app = XCUIApplication()
app.launch()

let textField = app.textFields["BTC"]
textField.tap()
textField.typeText("1")

let convertButton = app.buttons["Convert"]
convertButton.tap()

let resultField = app.staticTexts["result"].label
XCTAssertEqual(resultField, "30570.70")

}

Here is the full code of the UI test:

import XCTest
@testable import BTCtoUSD

final class BTCtoUSDUITests: XCTestCase {

func testCheckAllUIFields(){
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()

XCTAssert(app.textFields["BTC"].waitForExistence(timeout: 0.5))
XCTAssert(app.buttons["Convert"].waitForExistence(timeout: 0.5))
XCTAssert(app.staticTexts["result"].waitForExistence(timeout: 0.5))
}


func testIsAppShowingResultCorrectly(){

let app = XCUIApplication()
app.launch()

let textField = app.textFields["BTC"]
textField.tap()
textField.typeText("1")

let convertButton = app.buttons["Convert"]
convertButton.tap()

let resultField = app.staticTexts["result"].label
XCTAssertEqual(resultField, "30570.70")

}


}

If we run the test, it will automatically launch the app and check test cases. You can get the project on GitHub.

--

--