Swift — Essential tips for writing testable code

TDD and Unit testing are fundamental for writing quality code

Amisha I
Canopas

--

Designed By Canopas

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.

Let’s start

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)
}
}
}

This post is originally published on canopas.com.

To read the full version if testing tips please visit this blog.

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!!!

Thanks for the love you’re showing!

If you like what you read, be sure you won’t miss a chance to give 👏 👏👏 below — as a writer it means the world!

Also, feedbacks are most welcome as always, drop them below in the comments section.

Follow Canopas to get updates on interesting articles!

--

--