Starting Unit Testing with Model layer
Today we are going to touch the completely new topic on my blog, and it is Unit Testing. Most of us heard about the pros of Unit Testing. I want to show how easily you can start with Unit Testing by covering your model layer. So let’s start with the definition.
The all-in-one macOS app that enhances App Store Connect, supercharging your app updates, localization, and ASO with AI-powered tools. Save 25% and try now!
Unit testing is a software testing method by which individual units of source code, sets of one or more computer program modules together with associated control data, usage procedures, and operating procedures, are tested to determine whether they are fit for use.
In other words, Unit Test is a code which tests individual unit of your codebase.
I think the model layer is the best place to start writing Unit Tests. Assume that you are working on the Github client for iOS where you have a bunch of model structs which represents data fetched from Github API. Let’s take a look at structs which define repository search results.
import Foundation
struct SearchResponse: Codable {
let totalCount: Int
let incompleteResults: Bool
let items: [Repository]
}
struct Repository: Codable {
let id: Int
let name: String
let owner: User
let description: String
let fork: Bool
let url: String
let homepage: String
let stargazersCount: Int
let watchersCount: Int
let forksCount: Int
let openIssuesCount: Int
}
struct User: Codable {
let login: String
let id: Int
let avatarUrl: String
let gravatarId: String
let url String
}
Here we can see three structs: SearchResponse, Repository, and User. Every field of these structs represents an associated value from JSON which fetched during the search endpoint request. Next step is fetching and deserializing downloaded data into these structs.
class SearchLoader {
typealias Handler = (Result<SearchResponse, Error>) -> Void
private let session: URLSession
private let decoder: JSONDecoder
init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
self.session = session
self.decoder = decoder
}
func search(with query: String, handler: @escaping Handler) {
session.dataTask(with: makeRequest(for: query)) { [weak self] data, _, error in
guard let self = self else {
return
}
do {
let response = try self.decoder.decode(SearchResponse.self, from: data ?? Data())
handler(.success(response))
} catch {
handler(.failure(error))
}
}
}
}
In the code sample above we have SearchLoader class which make an API request to Github’s search endpoint and convert the data to SearchResponse struct. First of all, I want to cover with tests these data manipulations. Let’s start with creating a Unit Test target in Xcode project(File -> New -> Target -> iOS Unit Testing bundle). Xcode should create it by default if you do not disable it during the project forming process.
Now we have to add JSON file with search endpoint response as a content to a testing target. We will use it to mock network request and speed up our test by faking real network request.
{
"total_count": 40,
"incomplete_results": false,
"items": [
{
"id": 3081286,
"node_id": "MDEwOlJlcG9zaXRvcnkzMDgxMjg2",
"name": "Tetris",
"full_name": "dtrupenn/Tetris",
"owner": {
"login": "dtrupenn",
"id": 872147,
"node_id": "MDQ6VXNlcjg3MjE0Nw==",
"avatar_url": "https://secure.gravatar.com/avatar/e7956084e75f239de85d3a31bc172ace?d=https://a248.e.akamai.net/assets.github.com%2Fimages%2Fgravatars%2Fgravatar-user-420.png",
"gravatar_id": "",
"url": "https://api.github.com/users/dtrupenn",
"received_events_url": "https://api.github.com/users/dtrupenn/received_events",
"type": "User"
},
"private": false,
"html_url": "https://github.com/dtrupenn/Tetris",
"description": "A C implementation of Tetris using Pennsim through LC4",
"fork": false,
"url": "https://api.github.com/repos/dtrupenn/Tetris",
"created_at": "2012-01-01T00:31:50Z",
"updated_at": "2013-01-05T17:58:47Z",
"pushed_at": "2012-01-01T00:37:02Z",
"homepage": "",
"size": 524,
"stargazers_count": 1,
"watchers_count": 1,
"language": "Assembly",
"forks_count": 0,
"open_issues_count": 0,
"master_branch": "master",
"default_branch": "master",
"score": 10.309712
}
]
}
Finally, it is time to write our first Unit Test for the project. Let’s create new file from Unit Test template (File -> New -> File -> Unit Test Case Class). Xcode can identify test methods by the name. It should start with text prefix. Here is a sample Unit Test on SearchResponse.
import XCTest
@testable import Github
class GithubTests: XCTestCase {
func testSearchResponse() throws {
guard
let path = Bundle(for: self).path(forResource: "search", ofType: "json")
else { fatalError("Can't find search.json file") }
let data = try Data(contentsOf: URL(fileURLWithPath: path))
let response = try JSONDecoder().decode(SearchResponse.self, from: data)
XCTAssertEqual(response.totalCount, 40)
XCTAssertTrue(response.incompleteResults)
XCTAssertEqual(response.items.count, 1)
let repo = response.items.first
XCTAssertEqual(repo.id, 3081286)
XCTAssertEqual(repo.forksCount, 0)
XCTAssertEqual(repo.name, "Tetris")
XCTAssertFalse(repo.fork)
let owner = response.item.first.owner
XCTAssertEqual(owner.login, "dtrupenn")
XCTAssertEqual(owner.id, 872147)
XCTAssertEqual(owner.gravatarId, "")
}
}
The important thing here is @testable import, which makes possible to access to internal fields of SearchResponse inside the testing target. By importing XCTest, we get the XCTestCase, which is base class for all of our tests. XCTest framework also includes a bunch of helper methods to assert values. I didn’t assert every field to keep it as short as possible, but in real project it is nice to have all fields covered. Now we can run our tests by pressing CMD + U and check the result.
Conclusion
Today we discussed how to start with Unit Testing in any project which has a model layer. I think it is the most comfortable place to start. Don’t hesitate and start today, you will see a lot of benefits like safe refactoring, keeping codebase stable during adding new features which can break something that you have working before, and much more.
Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next week!