[iOS] Some tips for writing Unit Test

In order to have qualified code, writing Unit Test is an important skill that every developer needs to know. Here are some tips for writing it easier.

1. Using Dependency Injection and Protocols:

Let’s see how can we write Unit Test for class User here.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class DataProvider {
func getListUsers(completion: @escaping (Data?, Error?) -> Void) {
let request = URLRequest(url: URL(string: "url-to-get-list-users")!)
let task = URLSession.shared.dataTask(with: request) { data, _, error in
completion(data, error)
}
task.resume()
}
}


class User {
func listActiveIDs(completion: @escaping ([String]?) -> Void) {
DataProvider().getListUsers { data, error in
guard let data = data else {
completion(nil)
return
}

completion(data.activeUserIDs())
}
}
}

There are 2 cases need to be tested:

  • listActiveIDs_havingData
  • listActiveIDs_noData

But how can we control the output of DataProvider().getListUsers?

That is time for you to use Dependency Injection and Protocols to control the output.
We can refactor the code above a little bit.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Protocol
protocol DataProviderProtocol {
func getListUsers(completion: @escaping (Data?, Error?) -> Void)
}


// Real Data Provider
class DataProvider: DataProviderProtocol {
func getListUsers(completion: @escaping (Data?, Error?) -> Void) {
let request = URLRequest(url: URL(string: "url-to-get-list-users")!)
let task = URLSession.shared.dataTask(with: request) { data, _, error in
completion(data, error)
}
task.resume()
}
}


class User {
let _dataProdiver: DataProviderProtocol

// Dependency Injection
init(dataProdiver: DataProviderProtocol)
{
_dataProdiver = dataProdiver
}

func listActiveIDs(completion: @escaping ([String]?) -> Void) {
_dataProdiver.getListUsers { data, error in
guard let data = data else {
completion(nil)
return
}

completion(data.activeUserIDs())
}
}
}

See? Now in Test Target, we can use another mock DataProvider class to control the outputs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Mock of Data Provider with a property passed when init
class MockDataProvider: DataProvider {
let _data: Data?

init(data: Data?) {
_data = data
}

override func getListUsers(completion: @escaping (Data?, Error?) -> Void) {
completion(_data, nil)
}
}

class UserTest: XCTestCase {
func testListActiveIDs_havingData() {
// Setup waiting and other stuffs
// You can control output of DataProvider by passing that value in init func
let mockDataProvider = MockDataProvider(data: Data.mockUsers())
let user = User(dataProdiver: mockDataProvider)
user.listActiveIDs { ids in
XCTAssertNotNil(ids)
}
}

func testListActiveIDs_noData() {
// Setup waiting and other stuffs
// You can control output of DataProvider by passing that value in init func
let mockDataProvider = MockDataProvider(data: nil)
let user = User(dataProdiver: mockDataProvider)
user.listActiveIDs { ids in
XCTAssertNil(ids)
}
}
}

It’s very easy and clean.
You should be aware of this pattern from the beginning, then you don’t need to refactor your code just to write Unit Test.

2. Mock system/framework functions:

In a real application, we always need to use functions from system or framework. Because they are all not able to be modified, we need another solution to mock it.

See this example, we need to test whether the device is an iPad.

1
2
3
4
5
6
7
class DeviceHelper {
func isIPad() -> Bool {
return UIDevice.current.userInterfaceIdiom == .pad
}
}

let isIPad = DeviceHelper().isIPad()

Because that value will be returned from Apple framework, so you cannot directly modify it.

How to control output of UIDevice.current.userInterfaceIdiom?

We will use a protocol that has the same function as the framework.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Define protocol with the same function as framework
protocol DeviceSupportProtocol {
var userInterfaceIdiom: UIUserInterfaceIdiom { get }
}

// Let framework class conform our protocol
extension UIDevice: DeviceSupportProtocol {}

class DeviceHelper {
let _device: DeviceSupportProtocol

// Dependency Injection
init(device: DeviceSupportProtocol) {
_device = device
}

func isIPad() -> Bool {
return _device.userInterfaceIdiom == .pad
}
}

let isIPad = DeviceHelper(device: UIDevice.current).isIPad()

// Mock Device
class MockDevice: DeviceSupportProtocol {
var userInterfaceIdiom: UIUserInterfaceIdiom {
// Control the output
return .phone
}
}

Because in the framework, UIDevice already defined the function in the protocol, so we don’t need to write anything when conforming to the protocol.

You can get the exact function by entering Apple framework. (Copy function name except open access control).

Now you just need to use Dependency Injection. Done ╰ (▔∀▔) ╯