Navigate programmatically outside of a view in SwiftUI
April 1, 2021

Unit Testing state of mind in Swift

When I started with Unit Testing I found some tutorials and it was very easy to understand how it works. But the problem was not how to test but what to test and how to build an architecture to let my code be fully testable. I kept searching the web and didn’t found the answer. So now I will try to give a short answer for what to test and how to build architecture.

What to test?!?

It is always a big question and it very depends on how much time you have for the feature you developing. Ideally, we what to cover much code as possible but first of all we should pay attention to places where:

  1. The code may confuse another developer. Of course, we shouldn’t write confusing code at all, but it can happen when the feature itself is confusing even if you leave a lot of comments.
  2. Bug or crash. When you fix some bug or crash it is also an ideal place to create a test to prevent regressions in the future.
  3. Sensitive places. In-app purchases, login, and places that new user sees the first time when comes to your app, the statistics tell that user will remove the app almost immediately if will see some weird bug in the first moments of use.

Those are a few places when I think it necessary to create tests but in a long perspective test everything you can.

How to build an architecture?!?

When you building the architecture you need to separate your every module and use dependency injection.

Let’s say we have a cloud music app, listening to some albums is a paid feature, so each time user wants to listen and doesn’t have a required tier we should present an upsell with a proposition to purchase this tier. It is very important to avoid bugs here so let’s test it.

To demonstrate this I will try to use very simple code with the small architecture as much as I can just to let you understand the concept.

I will create a project with the following dependencies:

  1. RequestManager: receives parameters, makes an HTTPS request, and returns data.
  2. Repository: receives requests from object’s providers and returns object
  3. AlbumProvider: receives requests to get the specific album from the repository and returns the album.

This is how my Album object looks like:

struct Album: Codable {
     let isAllowed: Bool
     let requiredPackageName: String
     let requiredPackageId: String
}

This is how my AlbumProvider looks like:

protocol AlbumProviderProtocol {
    func getAlbum(completion: @escaping (Result<Album,AlbumError>)->Void)
}

enum AlbumError: Error {
    case apiError(Error), notAllowed
}

class AlbumProvider {
    
    let repository: RepositoryProtocol
    
    init(repository: RepositoryProtocol) {
        self.repository = repository
    }
}

extension AlbumProvider: AlbumProviderProtocol {
    
    func getAlbum(completion: @escaping (Result<Album,AlbumError>)->Void) {
        let requestConfiguration = RequestConfiguration(url: URL(string: "http://www.adamofsky.com/album.json")!, httpMethod: .get)
        repository.request(requestConfiguration) { (result: Result<Album,Error>) in
            switch result {
            case .success(let album):
                guard album.isAllowed == true else {
                    completion(.failure(.notAllowed))
                    return
                }
                completion(.success(album))
            case .failure(let error):
                completion(.failure(.apiError(error)))
            }
        }
    }
}

As you can see it must be initialized with Repository and implement AlbumProviderProtocol with a function that just requests an album from the repository and returns the album or an error in a case where the user does not have permission and needs to purchase the required tier or any other error.

This is how my Repository looks like:

protocol RepositoryProtocol {
    func request<T:Codable>(_ requestValue: RequestConfiguration, completion: @escaping (Result<T, Error>)->Void)
}

struct RequestConfiguration {
    var url: URL
    var httpMethod: RequestManager.HTTPMethod
}

class Repository: RepositoryProtocol {
    
    private let requestManager = RequestManager()
    
    func request<T:Codable>(_ requestConfiguration: RequestConfiguration, completion: @escaping (Result<T, Error>)->Void) {
        requestManager.request(with: requestConfiguration.url, httpMethod: requestConfiguration.httpMethod) { (result) in
            switch result {
            case .success(let data):
                let decoder = JSONDecoder()
                do {
                    let object = try decoder.decode(T.self, from: data)
                    completion(.success(object))
                } catch let error {
                    completion(.failure(error))
                }
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
    
}

If you don’t familiar with a repository pattern I recommend you to research it, but for now, as you can see in our case it just provides communication between the provider and request manager and returns a decoded object if the API call succeeded.

Well so this is our simple flow: view model requests the album from the provider, provider requests from the repository, and repository requests from the request manager and returns the result. The provider receives the final result and the view model by this result decides to let access to the album or not.

Let’s test our provider.

  1. If you have a unit test target skip this step, otherwise go to File -> New -> Target, and chose Unit Testing Bunde.
  2. Select the unit-tests folder and go to File -> New -> File and choose Unit Test Case Class, I will call this class AlbumProviderTests
  3. Create a function testHandleNotAllowedAlbum

Here we should create our test.

Since I want to test the provider I need to initialize him here, for initializing I need a repository, but I do not want that this repository will request an API call I want the repository to return me some mock data.
For this, I create a class AlbumRepositoryMock and it looks like this:

class AlbumRepositoryMock: RepositoryProtocol {
    
    func request<T:Codable>(_ requestConfiguration: RequestConfiguration, completion: @escaping (Result<T, Error>)->Void) {
        let album = Album(isAllowed: false, requiredPackageName: "Professional", requiredPackageId: "pro")
        completion(.success(album as! T))
    }
    
}

Here the request function simulates the response I want, the response I want to be handled correctly by my provider.

So my testHandleNotAllowedAlbum function will look like this:

    func testHandleNotAllowedAlbum() throws {
        let repository = AlbumRepositoryMock()
        let albumProvider = AlbumProvider(repository: repository)
        
        albumProvider.getAlbum(completion: { (result) in
            switch result {
            case .success(_):
                XCTAssert(false)
            case .failure(let error):
                switch error {
                case .apiError(_):
                    XCTAssert(false)
                case .notAllowed:
                    XCTAssert(true)
                }
            }
        })
    }

As you can see our test will succeed only when our code will reach the .notAllowed case, otherwise will fail.

You are welcome to clap if you like it.

4

1 Comment

  1. Anonymous says:

    nice tutorial !
    i just started with unit tests and in the phase that i should bit refactor and this is exactly what i search
    great work man !