10 Tips for Efficient Unit Testing

Dos and Don'ts for Writing Good Unit Tests

Cristian

Cristian

Full-stack software engineer at Softvision
Cristian started his development adventure in Softvision over 10 years ago, by working on Mac apps. Not scared by the strange syntax Objective-C had, he continued accumulating knowledge in the Mac/iOS area. A detour for a few years on backend/frontend projects helped him grow his expertise in this area too. Currently, he's a technical lead on one of the iOS projects.
Cristian

Latest posts by Cristian

Unit Testing is becoming a bigger and bigger part of a developer’s life, even leading to some extreme situations where it becomes mandatory to write tests for the new code it in order to deliver.

Unit tests are the second line of defense against incorrect code, the first line being the actual code review. If some unintended changes escape through the code review process, then with a good set of unit tests, those incorrect changes can be quickly detected.

Unit tests also play a very important role when it comes to refactoring, as a unit with lots of tests can be refactored with less risks than one with less (or none) tests. Having as much coverage as possible gives developers the needed confidence to change parts of the code even if they are not familiar with that part of the code.

In today’s world, where almost every project benefits a CI setup, it would be a shame if one would not take advantage of this and set up a test scheme. Even if there are only a few tests at the beginning, having the unit tests as part of a delivery pipeline will definitely be useful later.

And last but not least, good unit tests tend to lead to more quality code, as poorly designed code is usually difficult to test.

However, writing efficient and resilient unit tests is not an easy task. Knowing how to write tests is not always enough. One should also evaluate what can be tested and if certain units need tests or not. In this article, I will list some dos and don’ts when it comes to writing good unit tests. Even though the examples below are written in Swift, the ideas are applicable to all platforms and programming languages.

1. Don’t mock too much

It’s tempting to mock every aspect of the system, this indeed achieves the unit isolation, however, you end up tightly coupling the unit test and the unit implementation. Why is this bad? It’s because in case of refactoring it’s likely you’ll also need to change what gets mocked, and this almost renders the unit tests useless since that what unit tests are intended for: to catch when you make mistakes when changing the implementation. Who’s going to guarantee that the new mocks are not incorrect and are hiding problems in the unit (e.g. leading to false positives or false negatives)?

As an example, let’s assume we have a Person class that has an age stored property and an is Underaged computed one:

@objcMembers class Person: NSObject {
    let age: Int
    
    override init() {
        age = 0
        super.init()
    }
    
    init(dictionary: [AnyHashable:Any]) {
        age = dictionary["age"] as? Int ?? 0
    }
    
    var isUnderaged: Bool {
        return age < 18
    }
}

Assuming we want to test isUnderaged, one could write the following test:

func test_isUnderaged_returnsTrueForChild() {
    let person = Person()
    person.stub(#selector(getter: age), ofClass: Person, andReturn: 10)
    
    XCTAssertTrue(person.isUnderaged)
}

Now, the above approach might seem tempting, as we can easily stub another value in order to exercise the unit in different situations (multiple ages in this example), this test is not forward compatible. It might be that the implementation of Person will change and use a stored dictionary as a reference, or maybe the isUnderaged computation will rely in the future on some other pieces of information than the age property.

Thus, a more robust test would create the Person with some predefined data, and let the object itself manage its internal state:

func test_isUnderaged_returnsTrueForChild() {
    let person = Person(dictionary: ["age": 10])
    XCTAssertTrue(person.isUnderaged)
}

2. Don’t invest too much in setup

If it takes too much code to setup a test, it’s likely that the unit you want to test is too complicated and would benefit from refactoring. The more code you need to write in the setup part of the unit test, the tighter coupling occurs between the test and its unit, which leads to maintenance headaches in case the unit code starts changing.

Example:

  func test_reauthentication() {
        mockApiClient.resetStubs()
        mockKeychain.removeEverything()
        mockUserDefaults.removeEverything()
        mockFileManager.removeAllAvatars()
        
        mockApiClient.configure("/login", toReturn: success("new_token"))
        mockCountryManager.setCountry("US")
        mockCountryManager.setAllowTokenRefresh(true)
        
        mockApiClient.expectCall(withEndpoint: "/refresh_token")
        mockKeyChain.expectStorage(for: "token", value: "new_token")
        mockUserDefaults.expectStorage(for: "isAuthenticated", value: true)
        mockFileManager.expectStorage(atPath: "avatar")
        
        let expectation = self.expectation(description: "reauthentication")
        authenticationService.reauthenticate { _ in expectation.fulfill() }
        
        waitForExpectations(timeout: 1.0)
    }

The authentication service from the above example tries to do too many things: API call, keychain and defaults update, user avatar download. This results in complicated setups, which in many cases only add noise as the interesting lines are only the last 7. It would be good if the class would be stripped from some of the other functionalities. Otherwise testing that everything goes smoothly might result in a very complicated test.

3. Don’t test UI components

Unless you’re doing snapshot tests, testing UI components don’t bring too much value. UI components are more suitable for other kinds of automated tests (UI tests, acceptance tests). Manual testing is also better for UI components since it’s likely that obvious issues like the wrong font or color for a text won’t be caught by unit testing that component. If you find yourself in the position of needing to test business logic parts of that component, it’s better to extract the business logic into another class and test that class instead.

4. Don’t insist too much on integration tests

Testing multiple units in integration, while it has some benefits, like catching wrong flows of data, can be proven to be difficult to maintain. One reason is that the number of tests needed to cover each integration scenario grows exponentially with the number of combined units you test. So, for example, if the first unit has 3 possible scenarios, the second 5, and the third 8, you’ll need 3 x 5 x 8 = 120 unit tests to make sure you cover all possibilities. Testing each component in isolation leads to 3 + 5 + 8 = 16 tests.

Integration tests add value, however, the first defense line should still be unit tests. The author of a class/method knows best which corner cases should be handled. And when testing in integration it might not be possible or easy to exercise all code paths.

5. Make unit tests part of your daily habit

Don’t let a day pass without writing at least one test. Start with smaller classes that have only business logic and simple computation functions. Once you master the infrastructure (XCTest, or Quick+Nimble) move to more advanced classes, that have dependencies and carry heavier logic.

Plan unit testing right from the start of the project. Even if due to tight deadlines you won’t be able to write tests in the first sprint(s), having the infrastructure configured from the beginning will make it easier to add tests later. With Xcode that’s quite an easy task to do, as when creating a new project by default it ticks the unit tests checkmark

swift

6. Don’t worry (much) about private members

Don’t worry too much about the private members – you don’t need to dedicate unit tests for them. Private members are anyhow indirectly tested when testing the public ones. The trick is to write enough tests on the public ones so that you have good coverage over the private ones.

For example, let’s say we have a dedicated class for formatting dates, which uses Foundation’s DateFormatter class:

class MyDateFormatter {
    private let monthFirstFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "mm, dd YYYY"
        return formatter
    }()
    
    private let dayFirstFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "dd mm YYYY"
        return formatter
    }()
    
    public func format(date: Date, dayFirst: Bool = false) -> String {
        if dayFirst {
            return dayFirstFormatter.string(from: date)
        } else {
            return monthFirstFormatter.string(from: date)
        }
    }
}

We do not need to worry about directly testing the two private properties. We can indirectly test both of them via the public function:

class MyDateFormatterTests: XCTestCase {
    
    var date: Date!
    
    override func setUp() {
        super.setUp()
        
        let dateComponents = DateComponents(year: 2020, month: 7, day: 19)
        date = Calendar.current.date(from: dateComponents)
    }
    
    func test_formatDate_correctlyFormats_whenDayFirstIsAsked() {
        // setup
        let formatter = MyDateFormatter()
        
        // execute
        let dateString = formatter.format(date: date, dayFirst: true)
        
        // assert
        XCTAssertEqual(dateString, "19 07 2020")
    }
    
    func test_formatDate_correctlyFormats_whenMonthFirstIsAsked() {
        // setup
        let formatter = MyDateFormatter()
        
        // execute
        let dateString = formatter.format(date: date, dayFirst: false)
        
        // assert
        XCTAssertEqual(dateString, "07, 19 2020")
    }
}

The above two tests will ensure that the private properties were created and configured as expected.

7. Assert against hardcoded values

As seen in the previous examples, we used hardcoded strings in the unit tests, instead of generating ones at runtime. This is an important aspect, as hardcoded values mean that we have a stable comparison point in the unit tests. Also, in the above example trying to use the current date would’ve brought troubles as we would’ve had to setup our own testing formatter, and the more code we need to write in unit tests, the more are the changes we introduce bugs in the unit testing code.

This applies to arrays also. If you’re validating some JSON decoding logic, try to assert against static values: e.g. that the result has exactly 3 elements instead of doing a JSON serialization() call along the way.

Keep in mind that in unit tests we have full control over the input data, thus we should be able to hardcode values that we assert against.

8. Test specifications, not implementation

Assert that the unit behaves as expected without caring how those specifications are actually implemented. This will pave the way to refactoring.

Unit testing is black-box testing, which means we don’t care about the details inside the box (unit), we only care if the outputs of that unit match our expectations under various scenarios. Now by outputs, we can test:

  • function/method return values – the most straightforward and robust way to write tests:
func test_add_worksForPositiveInputs() {
        let result = calculator.add(2,3)
        XCTAssertEqual(result, 5)
    }
  • object properties and performing some actions on them
func test_initWithDictionary_extractsTheName() {
        let person = Person(dictionary: ["name": "John Doe"])
        XCTAssertEqual(person.name, "John Doe")
    }
  • interaction with dependencies
func test_login_sendsRequestToCorrectEndpoint() {
        let apiClient = TestApiClient()
        let service = AuthenticationService(apiClient: apiClient)
        
        service.login(email: "test@email.com", password: "pass", completion: nil)
        
        XCTAssertEqual(apiClient.currentRequest?.url?.path, "/login")
    }

In the last example, the output of the unit is represented by the method call made to the dependency.

9. Isolate tests

Reduce the number of side effects to the global state (ideally to zero). This makes the unit highly testable, as we know how to reliably bring the unit to the state we need. UserManager.sharedInstance.loginWithEmailAndPassword() is an example of an implicit dependency that makes the code hard to test.

Using UserDefaults, Keychain, the disk can also introduce side effects which might result in your tests needing to run a certain order for all to pass. This is not good.

10. Avoid conditional compilation as much as possible

Limit the usage of #ifdef’s to a few classes that contain only checks like this. Remember that unit tests can’t run with macros being both defined and undefined, so using some helper classes that leverage this can result is some nice, injected code.

Let’s take an example:

#if BRAND1
alertText = "Brand1 server has problems";
#elseif BRAND2
alertText = "Brand2 server has problems";
#else
alertText = "Server has problems"
#endif

// vs.
class BrandChecker {
var isBrand1: Bool {
#if BRAND1
return true
#else
return false
#endif
}

var isBrand2 : Bool {
#if BRAND2
return true
#else
return false
#endif
}
}

 

// and later on
if brandChecker.isBrand1 {
alertText = "Brand1 server has problems";
} else if brandChecker.isBrand2 {
alertText = "Brand2 server has problems";
} else {
alertText = "Server has problems"
}

 

Which approach do you think allows you to test more code paths in the application?

Writing good unit tests requires some amount of practice, however, this is true for any new domain of work. By following some of the rules from the above list we can keep tests clean, easy to maintain and understand, and with a potential of generating good code coverage.

Have more tips regarding unit tests? Feel free to leave them in the comments. Happy unit testing 🙂

Share This Article


Cristian

Cristian

Full-stack software engineer at Softvision
Cristian started his development adventure in Softvision over 10 years ago, by working on Mac apps. Not scared by the strange syntax Objective-C had, he continued accumulating knowledge in the Mac/iOS area. A detour for a few years on backend/frontend projects helped him grow his expertise in this area too. Currently, he's a technical lead on one of the iOS projects.
Cristian

Latest posts by Cristian

No Comments

Post A Comment