Swift Testing: Best Practices for Writing Tests in Swift Using XCTest and Other Frameworks


Testing is a critical part of software development, ensuring your code is reliable and functions as expected. This article delves into best practices for writing tests in Swift using the XCTest framework and other third-party libraries.

Getting Started with XCTest

XCTest is a powerful and flexible testing framework provided by Apple for all its platforms. It supports both unit tests, which test specific pieces of code, usually individual methods, and UI tests, which simulate user interaction with your app.

Here's a simple setup of an XCTest:

import XCTest
@testable import YourModule

class YourModuleTests: XCTestCase {

    // This is a sample test case
    func testExample() {
        XCTAssertEqual(2 + 2, 4, "Mathematics test failed!")
    }
}

In this code snippet:

  1. import XCTest - This imports the XCTest framework, which is necessary for setting up your test cases.

  2. @testable import YourModule - This line imports the module (your app or framework target) you're going to test. The @testable keyword makes the public and internal elements of the module accessible to your test case.

  3. class YourModuleTests: XCTestCase - Here, we declare a subclass of XCTestCase. Each XCTestCase subclass represents a test case in XCTest's parlance.

  4. func testExample() - This function is an individual test. XCTest automatically recognizes methods that start with test as test methods.

Best Practices for XCTest

When writing tests in XCTest, there are a few best practices to follow:

1. Test One Aspect per Test

Each test should verify only one aspect. This practice makes it clear what's wrong when a test fails and keeps your tests easy to read and maintain.

func testAddition() {
    XCTAssertEqual(2 + 2, 4, "Addition test failed!")
}

func testSubtraction() {
    XCTAssertEqual(2 - 2, 0, "Subtraction test failed!")
}

2. Use Descriptive Test Method Names

Your test method names should describe what they're testing. This helps when a test fails and you need to know what was expected to happen.

func testAdditionOfTwoPlusTwoEqualsFour() {
    XCTAssertEqual(2 + 2, 4, "Addition of 2 + 2 did not equal 4")
}

3. Utilize setUp and tearDown Methods

These methods can be overridden to execute custom code before and after each test method is called. This is helpful for setting up test conditions and cleaning up after tests.

override func setUp() {
    super.setUp()
    // Put setup code here.
}

override func tearDown() {
    // Put teardown code here.
    super.tearDown()
}

4. Write Both Positive and Negative Test Cases

In addition to testing that your code works as expected under normal conditions, you should also test edge cases and failure cases.

func testValidEmail() {
    XCTAssertTrue(isValidEmail("test@example.com"), "Valid email was marked as invalid")
}

func testInvalidEmail() {
    XCTAssertFalse(isValidEmail("testexample.com"), "Invalid email was marked as valid")
}

5. Mock Dependencies

For your tests to be reliable and independent, you should replace dependencies with mock objects. These mock objects should mimic the behavior of the real object but return predictable results and record how they're interacted with.

class NetworkServiceMock: NetworkService {
    var dataToReturn: Data?

    override func fetchData(from url: URL, completion: @escaping (Data?, Error?) -> Void) {
        completion(dataToReturn, nil)
    }
}

func testFetchData() {
    let mockService = NetworkServiceMock()
    mockService.dataToReturn = Data([0, 1, 2, 3])

    let objectUnderTest = MyClass(service: mockService)

    var result: Data?
    objectUnderTest.fetch { data in
        result = data
    }

    XCTAssertEqual(result, mockService.dataToReturn, "Data was not correctly fetched from the service")
}

Other Frameworks

There are several third-party testing frameworks that offer additional features beyond XCTest:

  1. Quick/Nimble: Quick is a behavior-driven development framework for Swift and Objective-C. Nimble is a matcher framework, which makes your tests more readable and descriptive. They can be used together for powerful and expressive tests.

  2. OHHTTPStubs: This library helps you stub network requests. It's useful for testing networking code where you want to control the responses to HTTP requests.

  3. Cuckoo: Cuckoo is a mocking framework for Swift. It helps you create mock objects that can mimic complex behaviors.

Building Testable Code in Swift

In addition to utilizing XCTest and third-party libraries, writing testable code is equally crucial for ensuring the maintainability and robustness of your applications.

1. Single Responsibility Principle

Each class or module in your codebase should have responsibility over a single part of the functionality provided by the software, and that responsibility should be entirely encapsulated by the class. When your classes adhere to the Single Responsibility Principle, they become more natural to test.

class Calculator {
    func add(_ a: Int, _ b: Int) -> Int {
        return a + b
    }

    func subtract(_ a: Int, _ b: Int) -> Int {
        return a - b
    }
}

In this Calculator class, it only performs calculator operations, making it easy to test.

2. Dependency Injection

Dependency Injection is a way of removing hard-coded dependencies among objects, making it easier to replace an object's dependencies, either for testing or to change run-time behavior.

class NetworkClient {
    private let urlSession: URLSession

    init(urlSession: URLSession = URLSession.shared) {
        self.urlSession = urlSession
    }

    func fetchData(from url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) {
        let task = urlSession.dataTask(with: url, completionHandler: completionHandler)
        task.resume()
    }
}

In this code, we pass the URLSession into NetworkClient. This way, we can pass a mock URL session for testing.

3. Avoiding Singletons

Singletons are often used in iOS apps for things like shared resource access (e.g., network requests). However, they can make testing challenging due to shared state and the inability to swap the singleton with a mock implementation. Instead, consider passing these shared resources to your objects via the initializer.

Writing UI Tests

In addition to unit tests, which test individual units of code, XCTest also allows you to write UI tests, which simulate user interaction with the app.

class MyAppUITests: XCTestCase {
    func testExample() {
        // UI tests must launch the application that they test.
        let app = XCUIApplication()
        app.launch()

        // Use recording to get started writing UI tests.
        // Use XCTAssert and related functions to verify your tests produce the correct results.
        let firstButton = app.buttons["FirstButton"]
        XCTAssertTrue(firstButton.exists, "First button does not exist")
        firstButton.tap()

        let helloLabel = app.staticTexts["Hello, World!"]
        XCTAssertTrue(helloLabel.exists, "Label does not exist after button tap")
    }
}

This example tests that tapping a button in the app causes a label with the text "Hello, World!" to appear. UI tests execute in a separate process from your app, so they interact with the app's UI in the same way a user would.

Continuously Running Tests

As your test suite grows, it becomes crucial to set up continuous integration to automatically run your tests. Xcode Server, Jenkins, Travis CI, and GitHub Actions are all good options for continuous integration with Swift and XCTest.

Overall, writing good tests is a skill that comes with practice. While it might seem like an upfront cost, the investment pays off by catching bugs early and providing documentation of expected behavior, making future changes safer and easier. Be mindful of the best practices, keep your tests clean and well-organized, and happy testing!

1
Subscribe to my newsletter

Read articles from Lorenzo Balderrama directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Lorenzo Balderrama
Lorenzo Balderrama

I am an implementation engineer who is loving learning cloud development. I am interested in medicine, biology, physics, and general technology! Got a new framework? Need tests run? I am there!