Introducing XCTest

Module 1 of 11 0%

Introducing XCTest

Now that we have a solid understanding of what unit tests are, as well as the benefits of writing them, let’s explore how to write unit tests in iOS using XCTest - Apple’s native testing framework.


Adding a Unit Testing Target:

Let’s start by taking a look at a simple XCode project:

image 3.png

The project is called CreativityHub, a platform for artists to upload and share their artwork to the world. In fact, the Login feature that we looked at in the previous chapter is part of this project.

But that’s not really important for us. What is important is that this is a standard XCode project with a single target - CreativityHub - which represents our application.

To add unit testing capabilities to our application, we must start by creating a new unit testing target.

Group 10.png

Tap on the + button at the bottom of the screen and a popup should appear, which will allow you to select a target template. Look for Unit Testing Bundle:

Group 11.png

Once the template is selected, we must then add some information about our newly added target. Most of it is pretty straightforward. Pay attention to the Product Name and Target to be Tested fields.

Group 12.png

Once everything is good, we tap on Finish to create our new target. We can now see that a new target was added to the list, as well as a new folder on the left side. This is where our test files will be added, as well as other test-specific helpers that we might need.

Group 13.png


Adding a Unit Test File:

Now that we have a unit testing target in place, let’s add some unit test files. In the previous chapter we took a look at how we could test the email field validation in our LoginViewModel, so let’s integrate this properly.

Create a new Swift file called LoginViewModelTests. It is a nice practice to mirror your test folder structure in a similar way to your development folders. In this example, since our LoginViewModel lives inside ModulesLogin, we’re going to create our LoginViewModelTests in the same nested directory.

Also, feel free to delete the CreativityHubTests file that was automatically created for us, since we don’t actually need it. It’s mostly added as an example:

Group 14.png


Writing our first test case:

First of all, here’s how to declare a new test case:

// 1.
import XCTest

// 2.
@testable import CreativityHub

// 3.
final class LoginViewModelTests: XCTestCase {
}
  1. We start by importing XCTest, the iOS framework for writing unit tests. This will give us access to all necessary methods and classes to be able to write our test cases.

  2. We must also import our CreativityHub target, which is required to access our LoginViewModel that needs to be tested. Notice the @testable keyword added at the start. By default, only public or open components (properties, methods, classes, etc.) can be accessed outside of a model. This is great for encapsulation, but it makes our testing more difficult. Adding the @testable keyword basically gives us access to all non-private components, which is very convenient.

  3. We create a new test class, called LoginViewModelTests. Note how our test class subclasses XCTestCase. This is a requirement - all our test cases must have this in order for them to work properly.

With our test class set up, let’s continue by writing our first test case:

import XCTest

@testable import CreativityHub

final class LoginViewModelTests: XCTestCase {

    @MainActor
    func test_givenEmailWithNoAtSymbol_whenIsEmailValidGet_thenReturnFalse() {

        // Given

        let sut = LoginViewModel()
        sut.email = "user.mail.com"

        // When

        let isEmailValid = sut.isEmailValid

        // Then

        XCTAssertFalse(isEmailValid)
    }
}

A test case is basically a function, but with some specific naming particularities. The naming convention is as follows:

To get the point across, here are a couple of examples:

// GIVEN: The API response is going to be successful
// WHEN:  We attempt to fetch users from the backend
// THEN:  The valid user list should be returned
func test_givenSuccessfulResponse_whenFetchingUsers_thenReturnUserList()

// GIVEN: The API response is going to fail
// WHEN:  We attempt to fetch users from the backend
// THEN:  The correct exception should be thrown
func test_givenFailedResponse_whenFetchingUsers_thenThrowCorrectException()

// GIVEN: The Email and Password fields are both valid
// WHEN:  We attempt to check the `isLoginEnabled` value
// THEN:  The result should be `true`
func test_givenEmailAndPasswordFieldsAreValid_whenIsLoginEnabledGet_thenReturnTrue()

Test Setup and Teardown

You might notice that for every single test case we have to initialize our LoginViewModel. Since it’s a single line of code, it’s not a big deal, but often we have to do larger code setups in the Given step. Doing this in every test case causes a lot of unnecessary duplication.

Let’s look at an example of this. The code itself doesn’t really matter that much, but pay attention at the duplication that we have to do for each test case:

final class TransactionViewModelTests: XCTestCase {

	func test_givenSuccessfulResponse_whenFetchingTransactions_thenReturnValidList() async throws {

		// Given

		let service = TransactionServiceMock()
		let router = RouterMock()

		let sut = TransactionViewModel(
			service: service,
			router: router
		)

		service.fetchTransactionsTask = Task {
			let mockedResponse = TransactionHistoryResponse()
			return mockedResponse.transactions
		}

		// When

		let transactions = try await = sut.onFetchTransaction()

		// Then

		XCTAssertFalse(transactions.isEmpty)
	}

	func test_givenFailedResponse_whenFetchingTransactions_thenThrowException() async {

		// Given

		let service = TransactionServiceMock()
		let router = RouterMock()

		let sut = TransactionViewModel(
			service: service,
			router: router
		)

		service.fetchTransactionsTask = Task {
			throw TransactionServiceError.invalidResponse
		}

		// When

		do {
			_ = try await sut.onFetchTransaction()
			XCTFail("Exception should be thrown for invalid API response.")
		} catch {

			// Then

			XCTAssertEqual(error as? TransactionServiceError, .invalidResponse)
		}
	}
}

I’ll give you a short overview of what we’re basically doing, but you shouldn’t worry if you don’t get it just yet. We’ll talk about mocking in the next chapter.

Basically, we have a TransactionViewModel which depends on a TransactionService and Router to work properly. We provide a TransactionServiceMock to be able to return fake responses instead of sending actual API requests.

In the first test case, we simulate a successful response which returns a list of transactions, while in the second, we simulate a failed scenario where an exception is thrown. Notice how in both test cases we have almost identical Given steps.

This is where test setup and teardown comes handy. Instead of declaring the same repeating logic in every test function, we have special functions called setUp and tearDown which run before and after each test case, and which we can use to put all of our setup logic.

With this in mind, let’s improve the code by adding these two methods and removing the duplication:

final class TransactionViewModelTests: XCTestCase {

	// MARK: - Properties

	private var service: TransactionServiceMock!
	private var router: Router!
	private var sut: TransactionViewModel!

	// MARK: - Test Setup

	override func setUp() async throws {
		try await super.setUp()
		service = TransactionServiceMock()
		router = RouterMock()
		sut = TransactionViewModel(
			service: service,
			router: router
		)
	}

	override func tearDown() async throws {
		service = nil
		router = nil
		sut = nil
		try await super.tearDown()
	}

	// MARK: - Test Cases

	func test_givenSuccessfulResponse_whenFetchingTransactions_thenReturnValidList() async throws {

		// Given

		service.fetchTransactionsTask = Task {
			let mockedResponse = TransactionHistoryResponse()
			return mockedResponse.transactions
		}

		// When

		let transactions = try await = sut.onFetchTransaction()

		// Then

		XCTAssertFalse(transactions.isEmpty)
	}

	func test_givenFailedResponse_whenFetchingTransactions_thenThrowException() async {

		// Given

		service.fetchTransactionsTask = Task {
			throw TransactionServiceError.invalidResponse
		}

		// When

		do {
			_ = try await sut.onFetchTransaction()
			XCTFail("Exception should be thrown for invalid API response.")
		} catch {

			// Then

			XCTAssertEqual(error as? TransactionServiceError, .invalidResponse)
		}
	}
}

Notice how we added two new methods to our test case and moved all of our duplicate initialization logic inside of it. The individual test methods are now cleaner, shorter and focus only on what matters.

Besides these two methods, we also have the class versions as well. Here’s the difference between them:

// Runs only once at the beginning of the test case.
override class func setUp() {}

// Runs before each test.
override func setUp() async throws {}

// Runs after each test.
override func tearDown() async throws {}

// Runs only once at the end, once all tests have been completed.
override class func tearDown() {}

The class version is not as commonly used, but it’s helpful to know that if need to set something that persists across all test functions, we have this option as well.


Common Assertions:

Before moving on, let’s take a look at some common assertions offered by XCTest.

Truth Assertions:

Validates that something is true or false.

XCTAssertTrue(isEmailValid)
XCTAssertNotTrue(isEmailValid)

Nil Assertions:

Validates that something is nil or not.

XCTAssertNil(email)
XCTAssertNotNil(email)

Equality Assertions:

Validates if two instances are equal or not.

XCTAssertEqual(country, Moldova)
XCTAssertNotEqual(userOne, userTwo)

Error Assertions:

Validates if a method throws an exception or not.

XCTAssertThrowsError(try fetchUsers())
XCTAssertNoThrow(try updateAccountDetails())

With error assertions, we can also add an optional closure to capture the exact error being thrown. We can then use XCTAssertEqual to check if the error thrown matches the one that we expect:

XCTAssertThrowsError(try request()) { error in
	XCTAssertEqual(error as? ApiError, .invalidResponse)
}

Failure Assertions:

Automatically fails the test.

XCTFail()

This can be combined with if, guard and switch statements to manually cause a test failure in case we encounter a behaviour that we do not expect.