Unit Testing your MVVM architecture in Swift

To be sure new code won’t break old one already implemented, it’s best practice to write unit tests. When it comes to app architectures, it can be a challenge to write those tests. Following an MVVM pattern, how to unit test a view and its viewModel? That’s what I would like to cover here using dependency injection.

The main advantage of MVVM architecture is decouple logic and keep a separation of concerns. Each class and files has a specific goal. The code stays modular, reusable and easy to test. The same logic should apply test cases: each test is written to cover one use case and one only, isolate the logic and be sure it’s working properly.

xcode-unit-test-swift-mvvm

In this example, I’ll keep the separation of concerns too, testing one element at a time. I mean here that for a MVVM pattern, I’ll have at least 3 test files: one for my model, one for my view and one for my viewModel.

But before diving into the code, if you’re not familiar with MVVM, I would encourage you to have a look into an introduction to implement MVVM pattern I recently wrote.

Prepare your viewModel

To be able to run test against my viewModel, I needed to be able to use another service, to be able to mock up my service by dependency injection. First step is to create a protocol for the service, then implementing it to the current service and finally update the view model.

I Here is what it looks like my protocol and service

protocol CurrencyServiceProtocol : class {
    func fetchConverter(_ completion: @escaping ((Result<Converter, ErrorResult>) -> Void))
}

final class CurrencyService : RequestHandler, CurrencyServiceProtocol {
...
}

Then I can use dependency injection and default parameter in my viewModel

weak var service: CurrencyServiceProtocol?

init(service: CurrencyServiceProtocol = CurrencyService.shared, dataSource : GenericDataSource<CurrencyRate>?) {
    self.dataSource = dataSource
    self.service = service
}

func fetchCurrencies(_ completion: ((Result<Bool, ErrorResult>) -> Void)? = nil) {
    guard let service = service else {
        completion?(Result.failure(ErrorResult.custom(string: "Missing service")))
        return
    }
...

Now we are ready to write our test.


ViewModel test cases

On my test side, first thing is to prepare the class for each specific test. As my viewModel can use a service and dataSource, I’ll mock up both here.

class CurrencyViewModelTests: XCTestCase {

    var viewModel : CurrencyViewModel!
    var dataSource : GenericDataSource<CurrencyRate>!
    fileprivate var service : MockCurrencyService!

    override func setUp() {
        super.setUp()
        self.service = MockCurrencyService()
        self.dataSource = GenericDataSource<CurrencyRate>()
        self.viewModel = CurrencyViewModel(service: service, dataSource: dataSource)
    }

    override func tearDown() {
        self.viewModel = nil
        self.dataSource = nil
        self.service = nil
        super.tearDown()
    }
}

To mockup my service, I implemented the same previous protocol, faking data with a local variable.

fileprivate class MockCurrencyService : CurrencyServiceProtocol {

    var converter : Converter?

    func fetchConverter(_ completion: @escaping ((Result<Converter, ErrorResult>) -> Void)) {

        if let converter = converter {
            completion(Result.success(converter))
        } else {
            completion(Result.failure(ErrorResult.custom(string: "No converter")))
        }
    }
}

Now we’re ready to run our first test

func testFetchWithNoService() {

    // giving no service to a view model
    viewModel.service = nil

    // expected to not be able to fetch currencies
    viewModel.fetchCurrencies { result in
        switch result {
        case .success(_) :
            XCTAssert(false, "ViewModel should not be able to fetch without service")
        default:
            break
        }
    }
}

View test cases

In my sample app, the view is represented by a UIViewController, however, there is not much to test in it except the actual UITableView and its datasource.

So in the same way I focus my effort on the relationship between those two elements. Here is a taste of it.

class CurrencyDataSourceTests: XCTestCase {

    var dataSource : CurrencyDataSource!

    override func setUp() {
        super.setUp()
        dataSource = CurrencyDataSource()
    }

    override func tearDown() {
        dataSource = nil
        super.tearDown()
    }

    func testValueInDataSource() {

        // giving data value
        let euroRate = CurrencyRate(currencyIso: "EUR", rate: 1.14)
        let dollarRate = CurrencyRate(currencyIso: "EUR", rate: 1.40)
        dataSource.data.value = [euroRate, dollarRate]

        let tableView = UITableView()
        tableView.dataSource = dataSource

        // expected one section
        XCTAssertEqual(dataSource.numberOfSections(in: tableView), 1, "Expected one section in table view")

        // expected two cells
        XCTAssertEqual(dataSource.tableView(tableView, numberOfRowsInSection: 0), 2, "Expected no cell in table view")
    }

Model test cases

Here best practice would be to test only parsing side of my model. However, my current iOS application doesn’t allow to inject a JSON data straight away to my model. So I cut a bit the corner there and test my main Parser service and model.

On your production app, it’s best to have two tests here, one for the parser, one for the model. The reason is quite simple: if you change your parser tomorrow, you don’t have to rewrite your model test cases too.

A small tip: to avoid calling any network, I just added a JSON file to my app to mockup the data coming from the server. It’s a good thing if you want to be retro compatible with older version of your api. However a new api version would need a new JSON file and test case for it too.

class CurrencyTests: XCTestCase {

    func testParseCurrency() {

        // giving a sample json file
        guard let data = FileManager.readJson(forResource: "sample") else {
            XCTAssert(false, "Can't get data from sample.json")
            return
        }

        // giving completion after parsing
        // expected valid converter with valid data
        let completion : ((Result<Converter, ErrorResult>) -> Void) = { result in
            switch result {
            case .failure(_):
                XCTAssert(false, "Expected valid converter")
            case .success(let converter):

                XCTAssertEqual(converter.base, "GBP", "Expected GBP base")
                XCTAssertEqual(converter.date, "2018-02-01", "Expected 2018-02-01 date")
                XCTAssertEqual(converter.rates.count, 32, "Expected 32 rates")
            }
        }

        ParserHelper.parse(data: data, completion: completion)
    }

In conclusion, with dependency injection approach and some small updates, we manage to test each part of our MVVM architecture, showing also how important it can be to keep a separation of concerns.

However, the content of my sample app is quite small and simple. Adding more screens and a navigation can make it harder to keep track of how to test. I had this same issues when I tried to unit test cells and calling UI methods out of its UI lifecycle(1).

I believe that’s where is the warning: if a function becomes to complex to test, it’s probably better to refactor it in small pieces easily maintainable.

This unit test examples are all available on Github.

(1) NSInternalInconsistencyException scrolling UITableViews · Issue #1007 · kif-framework/KIF · GitHub

© 2023 Benoit Pasquier. All Rights Reserved
Author's picture

Benoit Pasquier

Software Engineer 🇫🇷, writing about career development, mobile engineering and self-improvement

ShopBack 💰

Singapore 🇸🇬