[iOS] Some tips for writing Unit Test - Part 2

If you want to check part 1.

3. Testing with UserDefault

Saving data in UserDefaults.standard is one of the most common ways in iOS. But it is not easy to manage the data stored in that, and we also don’t want to have shared data between App and Test target.

So the solution is easy, let’s use UserDefaults.standard in the App target and use another UserDefault object in the Test target.

Here is what we usually do:

1
2
3
4
5
6
7
8
class DataManager {
func getSavedName() -> String? {
return UserDefaults.standard.value(forKey: "SavedName") as? String
}
}

let dataManager = DataManager()
dataManager.getSavedName() // -> How to control the output here?

Let’s refactor it a little bit with Dependency Injection, and create another UserDefaults object. The data will be stored in another file, and will not impact the data we saved in App target.

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
39
40
41
class DataManager {
private let userDefaults: UserDefaults

// Keep default value as UserDefaults.standard, so we don't need to pass this parameter in App target
init(userDefault: UserDefaults = UserDefaults.standard) {
self.userDefaults = userDefault
}

func getSavedName() -> String? {
return userDefaults.value(forKey: "SavedName") as? String
}
}

// App target
let dataManager = DataManager() // No need to pass userDefaults parameter
dataManager.getSavedName()


// Test target
class DataManagerTest: XCTestCase {
private var userDefaults: UserDefaults!
private var dataManager: DataManager!

override func setUpWithError() throws {
userDefaults = UserDefaults(suiteName: "TestingUserDefaults")
// Clear all data in TestingUserDefault before each Test case
userDefaults.removePersistentDomain(forName: "TestingUserDefaults")
userDefaults.synchronize()
// Inject TestingUserDefaults
dataManager = DataManager(userDefaults: userDefaults)
}

func testGetSavedName_Empty() {
XCTAssertNil(dataManager.getSavedName())
}

func testGetSavedName_HavingData() {
userDefault.set("ABC", forKey: "SavedName")
XCTAssertNotNil(dataManager.getSavedName())
}
}

Now we can easily change the output of getSavedName() function to verify all possible scenarios.

4. Current time testing

It is very easy to get the current time from the system, only need to call Date().

But how can we control the output of that function to have a reliable test?

Look at this example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Token {
let issueTime: TimeInterval

init(issueTime: TimeInterval) {
self.issueTime = issueTime
}

// The token will be expired after 3600s
var isExpired: Bool {
return Date().timeIntervalSince1970 - issueTime > 3600
}
}

let token = Token(issueTime: Date().timeIntervalSince1970)
token.isExpired // How can we verify if it works?

We will have 2 test cases here:

  • Token is expired
  • Token is not expired.

So in order to verify those test cases, we need a hardcoding issueTime, and control the output of Date().timeIntervalSince1970 depend on the test cases.

We can have the testing data as below.

  • issueTime = 1667894582
  • Token is expired: Date().timeIntervalSince1970 returns 1667894582 + 3601
  • Token is not expired: Date().timeIntervalSince1970 returns 1667894582 + 1000

So how to control the output of Date().timeIntervalSince1970? Again, using Dependency Injection, but with a closure to generate Date().

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class Token {
let issueTime: TimeInterval
private let dateGenerator: () -> Date

init(issueTime: TimeInterval, dateGenerator: @escaping () -> Date = Date.init) {
self.issueTime = issueTime
self.dateGenerator = dateGenerator
}

// The token will be expired after 3600s
var isExpired: Bool {
return dateGenerator().timeIntervalSince1970 - issueTime > 3600
}
}

// App target
// Just use default dateGenerator
let token = Token(issueTime: Date().timeIntervalSince1970)
token.isExpired

// Test target
// DateGenerator
class MockDateGenerator {
private var date = Date()

func setDate(from timeInterval: TimeInterval) {
date = Date(timeIntervalSince1970: timeInterval)
}

func generateDate() -> Date {
return date
}
}

class TokenTest: XCTestCase {
private var dateGenerator: MockDateGenerator!
private var token: Token!

override func setUpWithError() throws {
dateGenerator = MockDateGenerator()

token = Token(issueTime: TimeInterval(1667894582), dateGenerator: dateGenerator.generateDate)
}

func testIsExpired_True() {
dateGenerator.setDate(from: TimeInterval(1667894582 + 3601))
XCTAssertTrue(token.isExpired)
}

func testIsExpired_False() {
dateGenerator.setDate(from: TimeInterval(1667894582 + 1000))
XCTAssertFalse(token.isExpired)
}
}

Every time we call dateGenerator(), it will call the new Date.init and return the current time.

Pretty easy!!! Everything is in control now.