Swift — Essential tips for writing testable code

TDD and Unit testing are fundamental for writing quality code
Jan 5 2023 · 8 min read

Background

Test Early, Test Often, to Stay Away from Broken Software

Many developers have a hate relationship with testing. But once you start writing it, you may fall in love with writing tests, and maybe after that, you won’t like to implement any feature without writing its test, that’s the reality.

However, the leading cause is code, which is highly coupled and difficult to test. But like with anything in life, it’s all about practice, and eventually, you will love it.

Writing testable code is an important practice for any software developer. It ensures that your code is reliable, maintainable, and easy to improve.

In this post, I tried to share a few important Dos and Don’ts points for writing testable Swift code that we need to consider at the time of writing the test.

By following these best practices, you can write code that is easy to test and maintain, and that delivers the desired results.

Sponsored

Get Started

1. Use dependency injection

Dependency injection is a software design pattern that allows a class to receive its dependencies from external sources rather than having the object create or retrieve them itself.

import Swinject

class MyClass {
    let name: String
    init(name: String) {
        self.name = name
    }
}

class SuperClass {
    let subClass: MyClass
    init(subClass: MyClass) {
        self.subClass = subClass
    }
}

let container = Container()
container.register(MyClass.self) { _ in MyClass(name: "Dependency Injection") }
container.register(SuperClass.self) { r in
    SuperClass(subClass: r.resolve(MyClass.self)!)
}

let superClass = container.resolve(SuperClass.self)
print(superClass.subClass.name)  // Prints "Dependency Injection"

It allows you to replace the real dependencies of a class with mock versions that you can control in your tests.

This can be especially helpful if the real dependencies are difficult to set up or have side effects that you don’t want to happen during the test.

2. Use appropriate app architecture

Choosing the right app architecture is an important consideration in software development because it can have a big impact on the maintainability, testability, and overall quality of your code.

There are a few architectures that are generally considered to be choices for writing unit tests: MVVM, MVP, or MVC.

According to my experience using the MVVM architecture pattern is a good choice that helps you write cleaner, more testable code, which can lead to fewer bugs and a better user experience.

It separates the responsibilities for each feature by that the business logic from the user interface, you can write unit tests that focus on the ViewModel and the Model without having to worry about the View.

This can make your tests faster and more reliable because you don’t have to deal with the complexity of the user interface.

3. Avoid using singletons

Singletons can be difficult to test because they are global and cannot be easily replaced with mock objects.

// Singleton object example
class Singleton {
    static let sharedInstance = Singleton()
    private init() {}
}

let singleton = Singleton.sharedInstance

/* It is generally a good idea to avoid using singletons because they can make 
 it difficult to understand the relationships between objects in your code and
 can make it harder to find out the state of your program. */

// Without singleton creates new object each time
class MyClass {
    private init() {}
}

let instance = MyClass()

Instead, consider using dependency injection to pass in any shared dependencies for making an object. For that, we have a few libraries such as Swinject that we can use as the given example in the first point of DI.

4. Write tests for every feature

It’s important to test every feature of your code to ensure it is working correctly.

This includes both positive and negative tests to ensure your code is handling both expected and unexpected input. This means you have to write tests for each success and failure case of added class functions.

5. Write testable code from the start

It’s much easier to write testable code from the start than it is to retroactively add tests to untestable code. As you write your code, think about how it could be tested and structure your code accordingly.

6. Use the mocking library

There are several mocking libraries available for Swift that can make it easier to mock existing or external dependencies for the test.

These Mock objects are simulated objects that mimic the behavior of real objects in your code. They can be used to test how your code interacts with its dependencies, without relying on the actual dependencies.

Some popular options include Cuckoo and Mockingbird.

Let’s understand this by example,

// Example without use of mocking library
class UserRepository {
    func getUsers() -> [User] {
        // Make a network request to retrieve a list of users from a server
        let users = // parse JSON response and create array of User objects
        return users
    }
}

// Create mock class manually
class MockUserRepository: UserRepository {
    var users: [User]

    init(users: [User]) {
        self.users = users
    }

    override func getUsers() -> [User] {
        return users
    }
}

class UserListViewModel {
    let userRepository: UserRepository

    init(userRepository: UserRepository) {
        self.userRepository = userRepository
    }

    func loadUsers() {
        let users = userRepository.getUsers()
        // Update the UI with the list of users
    }
}

let mockUserRepository = MockUserRepository(users: [User(name: "Alice"), User(name: "Bob")])
let userListViewModel = UserListViewModel(userRepository: mockUserRepository)
userListViewModel.loadUsers()

If we use an external library for mocking classes or repositories then it creates mock automatically and we can use it directly, no need to write mock classes manually.

import XCTest
import Cuckoo

class UserListViewModelTests: XCTestCase {
    var mockUserRepository: MockUserRepository!
    var userListViewModel: UserListViewModel!

    override func setUp() {
        super.setUp()

        mockUserRepository = MockUserRepository(users: [User(name: "Alice"), User(name: "Bob")])
        userListViewModel = UserListViewModel(userRepository: mockUserRepository)
    }

    func testLoadUsers() {
        // Set up the mock user repository to return the expected list of users
        stub(mockUserRepository) { stub in
            when(stub.getUsers()).thenReturn([User(name: "Alice"), User(name: "Bob")])
        }

        // Call the loadUsers() method on the view model
        userListViewModel.loadUsers()

        // Verify that the mock user repository's getUsers() method was called
        verify(mockUserRepository).getUsers()
    }
}

This can make it easier to test your code in isolation, and can also make your tests faster and more reliable.

7. Use testing frameworks

There are several testing frameworks available for Swift that can make it easier to write and run tests.

Some popular options include XCTest, Quick, and Nimble.

8. Avoid using magic values

Magic values are hardcoded values that are used throughout your code. These can be difficult to test because they are not easily configurable.

Instead, consider using constants or variables to store values that might change, or that need to be used in multiple places.

func processData(data: [Int]) -> Bool {
    if data.count < 5 {
        return false
    }
    return true
}

class MyTests: XCTestCase {
    func testProcessData() {
        // This test will pass because the input data has fewer than 5 elements
        XCTAssertFalse(processData(data: [1, 2, 3, 4]))

        // This test will fail because the input data has exactly 5 elements
        XCTAssertFalse(processData(data: [1, 2, 3, 4, 5]))
    }
}

If we do not use a magic number and add a constant in the same class then the class and test will look like this,

let MIN_DATA_COUNT = 5

func processData(data: [Int]) -> Bool {
    if data.count < MIN_DATA_COUNT {
        return false
    }
    return true
}

class MyTests: XCTestCase {
    func testProcessData() {
        // This test will pass because the input data has fewer than 5 elements
        XCTAssertFalse(processData(data: [1, 2, 3, 4]))

        // This test will also pass because the input data has exactly 5 elements
        XCTAssertTrue(processData(data: [1, 2, 3, 4, 5]))
    }
}

By using a named constant or an enum value, it is easier to understand what the code is doing and to write unit tests that fully exercise the code. This can make your code easier to understand and maintain.

9. Avoid using force unwrapping

Force unwrapping (“!” operator) can cause your code to crash because of the runtime error if the optional value is nil.

This can make it difficult to write unit tests for the code because you may not know what input will cause a crash and you may not be able to predict the output of the code in all cases.

Instead, consider using optional binding(?) or the nil coalescing operator (??) to safely unwrap optional values.

10. Write clear, descriptive test names

It’s important to write clear and descriptive names for your tests so that it is easy to understand what each test is doing.

This can make it easier to debug failed tests, and can also make it easier for others to understand your tests.

// Not clearly describe the feature or behavior that is being tested
class UserTests: XCTestCase {
    func test1() {
        let user = User(name: "Alice", age: 25)
        XCTAssertThrowsError(try user.setAge(-1)) { error in
            XCTAssertEqual(error as? User.Error, User.Error.invalidAge)
        }
    }
}

// Instead, this name clearly describes the behaviour that is being tested
class UserTests: XCTestCase {
    func testSettingAgeToNegativeNumberThrowsError() {
        let user = User(name: "Alice", age: 25)
        XCTAssertThrowsError(try user.setAge(-1)) { error in
            XCTAssertEqual(error as? User.Error, User.Error.invalidAge)
        }
    }
}

11. Keep your tests up to date

As you make changes to your code, it’s important to update your tests to ensure that they are still relevant and accurate.

This means that every time you make changes to your code, you should also update your tests to ensure that they are still testing the correct behavior.

This can help you catch regressions and ensure that your code is always working as intended.

12. Avoid testing implementation details

Tests should focus on the behavior of your code, rather than its implementation details. This can make your tests more flexible to change and easier to maintain.

class UserTests: XCTestCase {
    func testSettingNameUpdatesPrivateNameVariable() {
        var user = User(name: "Alice")
        user.name = "Bob"
        XCTAssertEqual(user.name, "Bob")
    }
}

// OR

func testNamePropertyIsPrivate() {
    let user = User(name: "Alice")
    XCTAssertTrue(user.name == nil)
}

Instead of testing implementation details, it is generally better to test the public interface and behavior of your code. This allows you to focus on testing the features and behaviors that are important to your users.

class UserTests: XCTestCase {
    func testSettingNameUpdatesNameProperty() {
        var user = User(name: "Alice")
        user.name = "Bob"
        XCTAssertEqual(user.name, "Bob")
    }
}

13. Use continuous integration

Continuous integration (CI) is the practice of automatically building and testing code changes. By using a CI system, you can ensure that your code is always tested and that any regressions are caught quickly.

14. Avoid using time-based testing

Time-based testing involves verifying that something happens within a certain time. This can be difficult to test because it can be affected by factors such as the speed of the machine running the tests.

Its test relies on the passage of time to determine whether a feature or behavior is working correctly and can be affected by factors like the speed of the computer running the tests, the workload of the system, or the accuracy of the system clock.

class TimeConsumingMethodTests: XCTestCase {
    func testMethodTakesAtLeastOneSecondToExecute() {
        let startTime = Date()
        timeConsumingMethod()
        let endTime = Date()
        XCTAssertTrue(endTime.timeIntervalSince(startTime) >= 1.0)
    }

    func testMethodCompletesWithinOneSecond() {
        let startTime = Date()
        asyncMethod { result in
            let endTime = Date()
            XCTAssertTrue(endTime.timeIntervalSince(startTime) <= 1.0)
        }
    }
}

Instead, consider using mock objects or other techniques to test time-dependent behavior.

class TimeConsumingMethodTests: XCTestCase {
    func testMethodCompletesSuccessfully() {
        let result = timeConsumingMethod()
        XCTAssertEqual(result, .success)
    }

    func testMethodCompletedWithSuccess() {
        asyncMethod { result in
            XCTAssertEqual(result, .success)
        }
    }
}

15. Avoid using hardcoded data

Hardcoded data can make it difficult to write flexible, maintainable tests because it limits your ability to test different inputs and scenarios. It can also make it difficult to understand the purpose and behavior of your tests because the data being used is not immediately visible from the test code.

class UserTests: XCTestCase {
    func testInitializingUserWithValidNameSetsNameProperty() {
        let user = User(name: "Alice")
        XCTAssertEqual(user.name, "Alice")
    }
}

Instead, consider using data from a file or database, or using mock objects to supply test data.

class UserTests: XCTestCase {
    func testInitializingUserWithValidNameSetsNameProperty() {
        let database = Database()
        let name = database.getRandomName()
        let user = User(name: name)
        XCTAssertEqual(user.name, name)
    }
}

That’s it.

Thanks for reading the full list! Hope it will help you. 🍻

Conclusion

In conclusion, writing testable Swift code is an important practice for any software developer. By following these best practices, you can ensure that your code is thoroughly tested and that it is delivering the desired results.

You can also use testing frameworks to automate the process of running your tests and ensure that your code is thoroughly tested.

Additionally, consider using techniques such as test-driven development, test isolation, performance testing, debugging, error handling, and code review to improve the testability and quality of your code.

Remember, testing is an ongoing process that should be integrated into your development workflow. By making testing a priority, you can write code that is more reliable, maintainable, and easy to improve.

So go forth and test your way to better Swift code!

Happy testing!!!

Related Popular Article


amisha-i image
Amisha Italiya
iOS developer @canopas | Sharing knowledge of iOS development


amisha-i image
Amisha Italiya
iOS developer @canopas | Sharing knowledge of iOS development


Talk to an expert
get intouch
Our team is happy to answer your questions. Fill out the form and we’ll get back to you as soon as possible
footer
Subscribe Here!
Follow us on
2024 Canopas Software LLP. All rights reserved.