Developing iOS Applications without ready-to-use backend API

Emin DENİZ
Orion Innovation techClub
27 min readDec 20, 2022

--

Smarter Test Driven Development iOS App Development

During the client development process, it is expected to have a stable backend API. Most of the tutorials on the internet use ready-to-use APIs like MovieDB or OpenWeatherAPI. But in the real world, you don’t always have a ready-to-use backend API. Both backend and client teams can develop a feature simultaneously during agile development.

Even if your backend API is ready-to-use there might be some stability/accessibility problems with API. A few years ago I experienced stability problems in the testing environment of a big bank for more than a few weeks. I barely can receive successful responses from the test environment.

I can hear you saying “So I can’t do anything in such cases. I have to wait on the development process”. You are not wrong, but in the real-life development process, you need to find a way to continue.

What if I say to you “You don’t have to stop development in such cases”?

The answer is easy and most of you probably heard of it, Test Driven Development (TDD).

TDD (Test Driven Development)

“Test-driven development (TDD) is a software development process relying on software requirements being converted to test cases before the software is fully developed, and tracking all software development by repeatedly testing the software against all test cases.”

As Wikipedia clearly explains in the sentence above, in the TDD process first you write tests, then you implement the actual code. During a regular development process, any client developer inspects the API doc and designs the application according to that API. So if the backend API is not ready-to-use or not available at the point of development, we can simulate the same requests and responses in our client using unit tests.

Whaaaat?

Admit it, some of you already responded like that :) Let’s visualize it. Assume that we are fetching weather data from an API. Regular flow is asking for the weather data with a rest request then expect to have a JSON response that has the weather data.

Regular API usage

Let’s see how we can achieve this without backend API in unit tests. In the unit tests, you should be mocking your network layer and returning your expected response. The mocking term here is indicating that “Don’t use actual networking layer, use the fake returning mechanism you implemented in your tests.”.

Simulating expected data in unit tests with sample response.

So, if I can return the expected response somehow then I don’t need to stop developing without the backend.

Test Driven iOS Development

At this point, we understand the logic behind the TDD. Let’s see how we can do it on iOS development.

Designing Dependency Injectable Networking Layer

In short words, dependency injection is a technic for creating an object independent of dependencies. From the unit test perspective, dependency injection allows us to use mock data. You can imagine this technic similar to sending a spy to an unknown territory that you want to discover and measure. To be able to send a spy to some territory, that country first needs to allow that person to travel. Likewise, if we want our mock object to use in the tests, we first need to allow our mock object to pass into a real object by dependency injection.

You can not learn anything without getting your hands dirty. Let’s get our hands dirty with an example project.

Countries App

You can find a lot of free-to-use APIs on the internet, for this tutorial purpose I choose Countries API. You can fetch country-related data like; name, region, flag, etc. So let’s create a new Xcode project and add two folders called Networking and Scenes.

Base project structure

Good start! In the networking layer, we need to have a class that executes API requests, let’s call it RequestHandler. As you already know an API can serve tens or even hundreds (possibly much more) of endpoints. But the simplicity of this practices let us focus on 2 APIs;

  • Get the list of all available countries (getAllCountries)
  • Querry details of the country(querryCountryByName)

To manage these endpoints lets create an enum file called APIRoute. APIRoute will create endpoints for use easy to use manner.

Network Layer Architecture

Let’s begin to create APIRoute.

import Foundation

enum APIRoute {
// Endpoints that view layers will call.
case getAllCountries
case querryCountryByName(name:String)

// This func creates request with the given parameters.
func asRequest() -> URLRequest? {
return nil
}
}

So as we agreed we created an enum called APIRoute. At this point, we will only add the enum cases and asRequest method header without any actual implementation. Because we are developing as TDD suggest us. Now we can implement the RequestHandler.

import Foundation

protocol RequestHandling {
func request<T>(service: APIRoute, completion: @escaping (Result<T, APIError>) -> Void) where T:Decodable
}

/// Service Provider manages URLSession process
import Foundation

protocol RequestHandling {
func request<T>(service: APIRoute, completion: @escaping (Result<T, APIError>) -> Void) where T:Decodable
}


/// RequestHandler manages URLSession process
class RequestHandler: RequestHandling {
var urlSession:URLSession

init(urlSession: URLSession = .shared ) {
self.urlSession = urlSession
}


/// Starts resuest flow given service with required parameters and returns result in completion block.
/// - Parameters:
/// - service: Service Type
/// - decodeType: Decoder Type to return response
/// - completion: Completion with Service Result
func request<T>(service: APIRoute, completion: @escaping (Result<T, APIError>) -> Void) where T : Decodable {
// There is no actual implemantation so far.
}
}


/// Customized APIErrors for the app
enum APIError: Error {
case jsonConversionFailure
case invalidData
case invalidRequest
case responseUnsuccessful(Error)
var localizedDescription: String {
switch self {
case .invalidData: return "Invalid Data"
case .responseUnsuccessful: return "Response Unsuccessful"
case .invalidRequest: return "Invalid Request Type"
case .jsonConversionFailure: return "JSON Conversion Failure"
}
}
}

First, we add RequestHandling protocol for defining the function header. Then we add RequestHandler class. RequestHandler class has to implement request function but at this point, we only add headers.

You probably realize that the init function is getting the URLSession with default. This is a sample of dependency injection. We are allowing the upper layers that we will implement later can inject their own URLSession. Let’s add the services now.

The last part of this class has an enum for managing errors easily.

So we completed our Dependency Injectable Networking Layer. Let’s create response models with the help of QuickType.


import Foundation

// MARK: - AllCountriesResponseModelElement
struct AllCountriesResponseModelElement: Codable {
let name: Name?
let tld: [String]?
let cca2, ccn3, cca3, cioc: String?
let independent: Bool?
let status: Status?
let unMember: Bool?
let idd: Idd?
let capital, altSpellings: [String]?
let region: Region?
let subregion: String?
let languages: [String: String]?
let translations: [String: Translation]?
let latlng: [Double]?
let landlocked: Bool?
let borders: [String]?
let area: Double?
let demonyms: Demonyms?
let flag: String?
let maps: Maps?
let population: Int?
let gini: [String: Double]?
let fifa: String?
let car: Car?
let timezones: [String]?
let continents: [Continent]?
let flags, coatOfArms: CoatOfArms?
let startOfWeek: StartOfWeek?
let capitalInfo: CapitalInfo?
let postalCode: PostalCode?
}

enum Side: String, Codable {
case sideLeft = "left"
case sideRight = "right"
}

enum Continent: String, Codable {
case africa = "Africa"
case antarctica = "Antarctica"
case asia = "Asia"
case europe = "Europe"
case northAmerica = "North America"
case oceania = "Oceania"
case southAmerica = "South America"
}


// MARK: - Aed
struct Aed: Codable {
let name, symbol: String?
}

// MARK: - BAM
struct BAM: Codable {
let name: String?
}

enum Region: String, Codable {
case africa = "Africa"
case americas = "Americas"
case antarctic = "Antarctic"
case asia = "Asia"
case europe = "Europe"
case oceania = "Oceania"
}

enum StartOfWeek: String, Codable {
case monday = "monday"
case sunday = "sunday"
case turday = "turday"
}

enum Status: String, Codable {
case officiallyAssigned = "officially-assigned"
case userAssigned = "user-assigned"
}

typealias AllCountriesResponseModel = [AllCountriesResponseModelElement]
import Foundation

// MARK: - CountryResponseModelElement
struct CountryResponseModelElement: Codable {
let name: Name?
let tld: [String]?
let cca2, ccn3, cca3, cioc: String?
let independent: Bool?
let status: String?
let unMember: Bool?
let idd: Idd?
let capital, altSpellings: [String]?
let region, subregion: String?
let languages: Languages?
let translations: [String: Translation]?
let latlng: [Int]?
let landlocked: Bool?
let borders: [String]?
let area: Int?
let demonyms: Demonyms?
let flag: String?
let maps: Maps?
let population: Int?
let gini: Gini?
let car: Car?
let timezones, continents: [String]?
let flags, coatOfArms: CoatOfArms?
let startOfWeek: String?
let capitalInfo: CapitalInfo?
let postalCode: PostalCode?
}

// MARK: - CapitalInfo
struct CapitalInfo: Codable {
let latlng: [Double]?
}

// MARK: - Car
struct Car: Codable {
let signs: [String]?
let side: String?
}

// MARK: - CoatOfArms
struct CoatOfArms: Codable {
let png: String?
let svg: String?
}

// MARK: - Gbp
struct Gbp: Codable {
let name, symbol: String?
}

// MARK: - Demonyms
struct Demonyms: Codable {
let eng, fra: Eng?
}

// MARK: - Eng
struct Eng: Codable {
let f, m: String?
}

// MARK: - Gini
struct Gini: Codable {
let the2017: Double?

enum CodingKeys: String, CodingKey {
case the2017 = "2017"
}
}

// MARK: - Idd
struct Idd: Codable {
let root: String?
let suffixes: [String]?
}

// MARK: - Languages
struct Languages: Codable {
let eng: String?
}

// MARK: - Maps
struct Maps: Codable {
let googleMaps, openStreetMaps: String?
}

// MARK: - Name
struct Name: Codable {
let common, official: String?
let nativeName: NativeName?
}

// MARK: - NativeName
struct NativeName: Codable {
let eng: Translation?
}

// MARK: - Translation
struct Translation: Codable {
let official, common: String?
}

// MARK: - PostalCode
struct PostalCode: Codable {
let format, regex: String?
}

typealias CountryResponseModel = [CountryResponseModelElement]

So we have a testable networking layer without actual logic. That means we can start writing unit tests. Let’s do it!

Create 2 groups under CountriesAppTests group, TestUtilities and ServiceTests.

Create a file a file called JSONTestHelper in TestUtilities group and add the codes below.

import Foundation


/// This is a helper class for Unit Tests to load required response
class JSONTestHelper {


/// Reads local json file from test resources
/// - Parameter name: File name without extension
/// - Returns: Data represantation of file
func readLocalFile(name: String) -> Data? {
do {
let bundle = Bundle(for: type(of: self))
if let filePath = bundle.path(forResource: name, ofType: "json"){
let jsonData = try String(contentsOfFile: filePath).data(using: .utf8)
return jsonData
}
} catch {
fatalError("Failed to get json")
}
return nil
}

/// Decodes given jsonData to desired object
/// - Parameters:
/// - decodeType: Generic Decodable type
/// - jsonData: JSON Data
/// - Returns: Generic Decodable Type
func decode<T>(decodeType:T.Type, jsonData:Data) -> T where T:Decodable {
let decoder = JSONDecoder()

do {
let response = try decoder.decode(T.self, from: jsonData)
return response
} catch {
fatalError("Failed to get decodable type")
}
}

/// Reads json file and converts it to desired object
/// - Parameters:
/// - decodeType: Generic Decodable type
/// - name: File name without extension
/// - Returns: Generic Decodable Type
func readAndDecodeFile<T>(decodeType:T.Type, name: String) -> T where T:Decodable {
guard let data = readLocalFile(name: name) else {
fatalError("Data is nil")
}
return decode(decodeType: decodeType, jsonData: data)
}
}

The purpose of this helper is pretty straightforward. We will read local JSON responses in our test and this helper makes our job easy.

Create another file called MockURLProtocol in TestUtilities group and add the codes below.

import Foundation

class MockURL: URLProtocol {
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))?

override class func canInit(with request: URLRequest) -> Bool {
// To check if this protocol can handle the given request.
return true
}

override class func canonicalRequest(for request: URLRequest) -> URLRequest {
// Here you return the canonical version of the request but most of the time you pass the orignal one.
return request
}

override func startLoading() {

guard let handler = MockURLProtocol.requestHandler else {
fatalError("Handler is unavailable.")
}

do {
// 2. Call handler with received request and capture the tuple of response and data.
let (response, data) = try handler(request)

// 3. Send received response to the client.
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)

if let data = data {
// 4. Send received data to the client.
client?.urlProtocol(self, didLoad: data)
}

// 5. Notify request has been finished.
client?.urlProtocolDidFinishLoading(self)
} catch {
// 6. Notify received error.
client?.urlProtocol(self, didFailWithError: error)
}
}

override func stopLoading() {
// This is called if the request gets canceled or completed.
}
}

As the name implies, the purpose of this class is to create a mock URLRequest class to use in our tests. We are just implementing the URLProtocol class of the foundation framework here. Using this mock class, we won’t send actual requests and we can return expected responses in our tests. Now we are ready, let’s write some unit tests.

All Countries List

Let’s start with the all countries list API. Create a new group called ServiceTests in CountriesAppTests then create a new group called AllCountries in it. Now create an empty file called AllCountriesSuccessResponse.json and put the actual response from API. If I put raw response from AllCountries response in Medium, you can’t read this article because it has more than 50.000 lines. I will get just 3 of the countries from the which is more than enough now.

[
{
"name": {
"common": "Bulgaria",
"official": "Republic of Bulgaria",
"nativeName": {
"bul": {
"official": "Република България",
"common": "България"
}
}
},
"tld": [
".bg"
],
"cca2": "BG",
"ccn3": "100",
"cca3": "BGR",
"cioc": "BUL",
"independent": true,
"status": "officially-assigned",
"unMember": true,
"currencies": {
"BGN": {
"name": "Bulgarian lev",
"symbol": "лв"
}
},
"idd": {
"root": "+3",
"suffixes": [
"59"
]
},
"capital": [
"Sofia"
],
"altSpellings": [
"BG",
"Republic of Bulgaria",
"Република България"
],
"region": "Europe",
"subregion": "Southeast Europe",
"languages": {
"bul": "Bulgarian"
},
"translations": {
"ara": {
"official": "جمهورية بلغاريا",
"common": "بلغاريا"
},
"bre": {
"official": "Republik Bulgaria",
"common": "Bulgaria"
},
"ces": {
"official": "Bulharská republika",
"common": "Bulharsko"
},
"cym": {
"official": "Gweriniaeth Bwlgaria",
"common": "Bwlgaria"
},
"deu": {
"official": "Republik Bulgarien",
"common": "Bulgarien"
},
"est": {
"official": "Bulgaaria Vabariik",
"common": "Bulgaaria"
},
"fin": {
"official": "Bulgarian tasavalta",
"common": "Bulgaria"
},
"fra": {
"official": "République de Bulgarie",
"common": "Bulgarie"
},
"hrv": {
"official": "Republika Bugarska",
"common": "Bugarska"
},
"hun": {
"official": "Bolgár Köztársaság",
"common": "Bulgária"
},
"ita": {
"official": "Repubblica di Bulgaria",
"common": "Bulgaria"
},
"jpn": {
"official": "ブルガリア共和国",
"common": "ブルガリア"
},
"kor": {
"official": "불가리아 공화국",
"common": "불가리아"
},
"nld": {
"official": "Republiek Bulgarije",
"common": "Bulgarije"
},
"per": {
"official": "جمهوری بلغارستان",
"common": "بلغارستان"
},
"pol": {
"official": "Republika Bułgarii",
"common": "Bułgaria"
},
"por": {
"official": "República da Bulgária",
"common": "Bulgária"
},
"rus": {
"official": "Республика Болгария",
"common": "Болгария"
},
"slk": {
"official": "Bulharská republika",
"common": "Bulharsko"
},
"spa": {
"official": "República de Bulgaria",
"common": "Bulgaria"
},
"swe": {
"official": "Republiken Bulgarien",
"common": "Bulgarien"
},
"tur": {
"official": "Bulgaristan Cumhuriyeti",
"common": "Bulgaristan"
},
"urd": {
"official": "جمہوریہ بلغاریہ",
"common": "بلغاریہ"
},
"zho": {
"official": "保加利亚共和国",
"common": "保加利亚"
}
},
"latlng": [
43.0,
25.0
],
"landlocked": false,
"borders": [
"GRC",
"MKD",
"ROU",
"SRB",
"TUR"
],
"area": 110879.0,
"demonyms": {
"eng": {
"f": "Bulgarian",
"m": "Bulgarian"
},
"fra": {
"f": "Bulgare",
"m": "Bulgare"
}
},
"flag": "\uD83C\uDDE7\uD83C\uDDEC",
"maps": {
"googleMaps": "https://goo.gl/maps/F5uAhDGWzc3BrHfm9",
"openStreetMaps": "https://www.openstreetmap.org/relation/186382"
},
"population": 6927288,
"gini": {
"2018": 41.3
},
"fifa": "BUL",
"car": {
"signs": [
"BG"
],
"side": "right"
},
"timezones": [
"UTC+02:00"
],
"continents": [
"Europe"
],
"flags": {
"png": "https://flagcdn.com/w320/bg.png",
"svg": "https://flagcdn.com/bg.svg"
},
"coatOfArms": {
"png": "https://mainfacts.com/media/images/coats_of_arms/bg.png",
"svg": "https://mainfacts.com/media/images/coats_of_arms/bg.svg"
},
"startOfWeek": "monday",
"capitalInfo": {
"latlng": [
42.68,
23.32
]
},
"postalCode": {
"format": "####",
"regex": "^(\\d{4})$"
}
},
{
"name": {
"common": "Botswana",
"official": "Republic of Botswana",
"nativeName": {
"eng": {
"official": "Republic of Botswana",
"common": "Botswana"
},
"tsn": {
"official": "Lefatshe la Botswana",
"common": "Botswana"
}
}
},
"tld": [
".bw"
],
"cca2": "BW",
"ccn3": "072",
"cca3": "BWA",
"cioc": "BOT",
"independent": true,
"status": "officially-assigned",
"unMember": true,
"currencies": {
"BWP": {
"name": "Botswana pula",
"symbol": "P"
}
},
"idd": {
"root": "+2",
"suffixes": [
"67"
]
},
"capital": [
"Gaborone"
],
"altSpellings": [
"BW",
"Republic of Botswana",
"Lefatshe la Botswana"
],
"region": "Africa",
"subregion": "Southern Africa",
"languages": {
"eng": "English",
"tsn": "Tswana"
},
"translations": {
"ara": {
"official": "جمهورية بوتسوانا",
"common": "بوتسوانا"
},
"bre": {
"official": "Republik Botswana",
"common": "Botswana"
},
"ces": {
"official": "Botswanská republika",
"common": "Botswana"
},
"cym": {
"official": "Republic of Botswana",
"common": "Botswana"
},
"deu": {
"official": "Republik Botsuana",
"common": "Botswana"
},
"est": {
"official": "Botswana Vabariik",
"common": "Botswana"
},
"fin": {
"official": "Botswanan tasavalta",
"common": "Botswana"
},
"fra": {
"official": "République du Botswana",
"common": "Botswana"
},
"hrv": {
"official": "Republika Bocvana",
"common": "Bocvana"
},
"hun": {
"official": "Botswanai Köztársaság",
"common": "Botswana"
},
"ita": {
"official": "Repubblica del Botswana",
"common": "Botswana"
},
"jpn": {
"official": "ボツワナ共和国",
"common": "ボツワナ"
},
"kor": {
"official": "보츠와나 공화국",
"common": "보츠와나"
},
"nld": {
"official": "Republiek Botswana",
"common": "Botswana"
},
"per": {
"official": "جمهوری بوتسوانا",
"common": "بوتسوانا"
},
"pol": {
"official": "Republika Botswany",
"common": "Botswana"
},
"por": {
"official": "República do Botswana",
"common": "Botswana"
},
"rus": {
"official": "Республика Ботсвана",
"common": "Ботсвана"
},
"slk": {
"official": "Botswanská republika",
"common": "Botswana"
},
"spa": {
"official": "República de Botswana",
"common": "Botswana"
},
"swe": {
"official": "Republiken Botswana",
"common": "Botswana"
},
"tur": {
"official": "Botsvana Cumhuriyeti",
"common": "Botsvana"
},
"urd": {
"official": "جمہوریہ بوٹسوانا",
"common": "بوٹسوانا"
},
"zho": {
"official": "博茨瓦纳共和国",
"common": "博茨瓦纳"
}
},
"latlng": [
-22.0,
24.0
],
"landlocked": true,
"borders": [
"NAM",
"ZAF",
"ZMB",
"ZWE"
],
"area": 582000.0,
"demonyms": {
"eng": {
"f": "Motswana",
"m": "Motswana"
},
"fra": {
"f": "Botswanaise",
"m": "Botswanais"
}
},
"flag": "\uD83C\uDDE7\uD83C\uDDFC",
"maps": {
"googleMaps": "https://goo.gl/maps/E364KeLy6N4JwxwQ8",
"openStreetMaps": "https://www.openstreetmap.org/relation/1889339"
},
"population": 2351625,
"gini": {
"2015": 53.3
},
"fifa": "BOT",
"car": {
"signs": [
"BW"
],
"side": "left"
},
"timezones": [
"UTC+02:00"
],
"continents": [
"Africa"
],
"flags": {
"png": "https://flagcdn.com/w320/bw.png",
"svg": "https://flagcdn.com/bw.svg"
},
"coatOfArms": {
"png": "https://mainfacts.com/media/images/coats_of_arms/bw.png",
"svg": "https://mainfacts.com/media/images/coats_of_arms/bw.svg"
},
"startOfWeek": "monday",
"capitalInfo": {
"latlng": [
-24.63,
25.9
]
}
},
{
"name": {
"common": "Guinea",
"official": "Republic of Guinea",
"nativeName": {
"fra": {
"official": "République de Guinée",
"common": "Guinée"
}
}
},
"tld": [
".gn"
],
"cca2": "GN",
"ccn3": "324",
"cca3": "GIN",
"cioc": "GUI",
"independent": true,
"status": "officially-assigned",
"unMember": true,
"currencies": {
"GNF": {
"name": "Guinean franc",
"symbol": "Fr"
}
},
"idd": {
"root": "+2",
"suffixes": [
"24"
]
},
"capital": [
"Conakry"
],
"altSpellings": [
"GN",
"Republic of Guinea",
"République de Guinée"
],
"region": "Africa",
"subregion": "Western Africa",
"languages": {
"fra": "French"
},
"translations": {
"ara": {
"official": "جمهورية غينيا",
"common": "غينيا"
},
"bre": {
"official": "Republik Ginea",
"common": "Ginea"
},
"ces": {
"official": "Guinejská republika",
"common": "Guinea"
},
"cym": {
"official": "Republic of Guinea",
"common": "Guinea"
},
"deu": {
"official": "Republik Guinea",
"common": "Guinea"
},
"est": {
"official": "Guinea Vabariik",
"common": "Guinea"
},
"fin": {
"official": "Guinean tasavalta",
"common": "Guinea"
},
"fra": {
"official": "République de Guinée",
"common": "Guinée"
},
"hrv": {
"official": "Republika Gvineja",
"common": "Gvineja"
},
"hun": {
"official": "Guineai Köztársaság",
"common": "Guinea"
},
"ita": {
"official": "Repubblica di Guinea",
"common": "Guinea"
},
"jpn": {
"official": "ギニア共和国",
"common": "ギニア"
},
"kor": {
"official": "기니 공화국",
"common": "기니"
},
"nld": {
"official": "Republiek Guinee",
"common": "Guinee"
},
"per": {
"official": "مملکت مستقل پاپوآ گینه نو",
"common": "پاپوآ گینه نو"
},
"pol": {
"official": "Republika Gwinei",
"common": "Gwinea"
},
"por": {
"official": "República da Guiné",
"common": "Guiné"
},
"rus": {
"official": "Республика Гвинея",
"common": "Гвинея"
},
"slk": {
"official": "Guinejská republika",
"common": "Guinea"
},
"spa": {
"official": "República de Guinea",
"common": "Guinea"
},
"swe": {
"official": "Republiken Guinea",
"common": "Guinea"
},
"tur": {
"official": "Gine Cumhuriyeti",
"common": "Gine"
},
"urd": {
"official": "جمہوریہ گنی",
"common": "گنی"
},
"zho": {
"official": "几内亚共和国",
"common": "几内亚"
}
},
"latlng": [
11.0,
-10.0
],
"landlocked": false,
"borders": [
"CIV",
"GNB",
"LBR",
"MLI",
"SEN",
"SLE"
],
"area": 245857.0,
"demonyms": {
"eng": {
"f": "Guinean",
"m": "Guinean"
},
"fra": {
"f": "Guinéenne",
"m": "Guinéen"
}
},
"flag": "\uD83C\uDDEC\uD83C\uDDF3",
"maps": {
"googleMaps": "https://goo.gl/maps/8J5oM5sA4Ayr1ZYGA",
"openStreetMaps": "https://www.openstreetmap.org/relation/192778"
},
"population": 13132792,
"gini": {
"2012": 33.7
},
"fifa": "GUI",
"car": {
"signs": [
"RG"
],
"side": "right"
},
"timezones": [
"UTC"
],
"continents": [
"Africa"
],
"flags": {
"png": "https://flagcdn.com/w320/gn.png",
"svg": "https://flagcdn.com/gn.svg"
},
"coatOfArms": {
"png": "https://mainfacts.com/media/images/coats_of_arms/gn.png",
"svg": "https://mainfacts.com/media/images/coats_of_arms/gn.svg"
},
"startOfWeek": "monday",
"capitalInfo": {
"latlng": [
9.5,
-13.7
]
}
}
]

Now we can create the actual test class. On the AllCountries group double click to create a file and select the ‘Unit Test Case Class’ as in the image below. Then name it as AllCountriesServiceTests.

Xcode automatically adds a few boilerplate functions to our test class like this:

import XCTest

final class AllCountriesServiceTests: XCTestCase {

override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}

override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}

func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
// Any test you write for XCTest can be annotated as throws and async.
// Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
// Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
}

func testPerformanceExample() throws {
// This is an example of a performance test case.
self.measure {
// Put the code you want to measure the time of here.
}
}

}

Comments are explaining the functions pretty clear, but let’s go over the functions.

  • setupWithError will be called before each test. In case we want to prepare something before each test execution we should put in this function.
  • tearDownWithError will be called after each test. In case we want to clear something after each test execution we should put in this function.
  • testExample is our test function. This the place we add each test case.
  • testPerformanceExample is an example test that measure time spend during execution.

Let’s modify the test class like this.

// 1
import XCTest
@testable import Countries

final class AllCountriesServiceTests: XCTestCase {
// 2
var sut: RequestHandling!
// 3
var expectation: XCTestExpectation!
// 4
let apiURL = URL(string: "https://restcountries.com/v3.1/all?")!

override func setUpWithError() throws {
// 5
let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [MockURLProtocol.self]
let urlSession = URLSession.init(configuration: configuration)
// 6
sut = RequestHandler(urlSession: urlSession)
// 7
expectation = expectation(description: "Test Expectation")
}

override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}

// 8
func test_givenAllCountriesRequest_whenResponseSuccessfull_thenShouldContainRequiredResponseModel() throws {
// 9
let successData = JSONTestHelper().readLocalFile(name: "AllCountriesResponse")

// 10
MockURLProtocol.requestHandler = { request in
guard let url = request.url, url == self.apiURL else {
// 11
throw fatalError("URLS are not matching. Expected: \(self.apiURL), Found: \(request.url)")
}
// 12
let response = HTTPURLResponse(url: self.apiURL, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, successData)
}

// 13
sut.request(service: .getAllCountries) {
(result : Result<AllCountriesResponseModel, APIError>) in
switch result {

case .success(let response):
// 14
XCTAssertEqual(response.count, 3, "Total number of countries should be 250")
let firstCountry = response[0]
XCTAssertEqual(firstCountry.name?.common, "Bulgaria", "First country name is not matching")
XCTAssertEqual(firstCountry.region, "Europe", "First country region is not matching")
XCTAssertEqual(firstCountry.startOfWeek, "monday", "Start of the week not matching")
case .failure(let error):
// 15
XCTFail("Error was not expected: \(error.localizedDescription)")
}
// 16
self.expectation.fulfill()
}
// 17
wait(for: [expectation], timeout: 1.0)

}

// 18
func test_givenAllCountriesRequest_whenResponseFails_thenShouldReturnFail() throws {

MockURLProtocol.requestHandler = { request in
// 19 For error case we can use empty data
let emptyData = Data()
let response = HTTPURLResponse(url: self.apiURL, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, emptyData)
}

// 20
sut.request(service: .getAllCountries) {
(result : Result<AllCountriesResponseModel, APIError>) in
switch result {

case .success(_):
// 21
XCTFail("Success was not expected")
case .failure(let error):
// 22
XCTAssertEqual(error.localizedDescription, APIError.jsonConversionFailure.localizedDescription)
}
// 23
self.expectation.fulfill()
}
// 24
wait(for: [expectation], timeout: 1.0)
}

}

There are a lot of codes above. Let’s investigate them one by one by comment numbers.

  1. We should addimport XCTTestto use the XCTest framework. Also, we should import the application as testable piece of software with @testable import <App Name> format.
  2. Use ‘sut’ (software under test) for the variable that we want to test. This is common practice to highlight our test scope. In this test class we are focusing on testing of RequestHandling protocol, thats why its reference is named as sut.
  3. Expectations are powerful tool of XCTest framework. In case we want to test asynchronous code expectations allow us to wait it to complete. This is very useful especially for completion blocks.
  4. This is the url path that we want to send the request. In our tests we will use this to verify ‘Are we sending requests to the correct path?’ question.
  5. This is the place our MockURLProtocol does the magic. First we are creating URLSessionConfiguration with default properties. Then we are saying that URLSessionConfiguration will use our MockURLProtocol as the protocolClass. So this will lead all the requests pass to MockURLProtocol class (In MockURLProtocol we are acting like it is sent to internet but in reality it is not). After that we are creating urlSession with the configuration that configuration.
  6. Initializing the ReqesutHandler with urlSession (we can say it is mock urlSession) we just created.
  7. Initializing the expectation with a description.
  8. We have to start test functions withtest prefix in XCTTest, it is mandatory. We can just leave the test function name like testExample but this not a recomended way. There can be hundreds of test case in a single project. If any of those test fails we should understand the reason quickly. Recommended way to define test functions with the Given-When-Then pattern. For this example we are saying that ‘given with all countries request’, ‘when we receive successful response’, ‘then expect to see required response model’. This is a good pattern to understand the test cases.
  9. We are using the JSONTestHelper to load json file we created with the sample successful response.
  10. At this point we are simulating the request. This block will be triggered when URLSession should send the request.
  11. At this point we are just ensuring that URL path is correct. In case we called APIRoute with right enum case, this should match the apiURL that we defined at the beginning of the test class.
  12. We are creating success response with the sample success data that we loaded from json.
  13. Now we are calling the actual function with the sut object. At this point execution of actual code begins.
  14. A unit test should have assertions to verify outputs are right or wrong. Our request function should contact to API and receive the json data, then it should give us decoded response (AllCountriesResponseModel). If the request function is working we should have AllCountriesResponseModel object with required properties. Like name, region or startOfTheWeek. So we can assert those properties and expect them to equal the JSON data we provided. For example, we provided a JSON array with 3 objects, so the response count should be 3. The first country we have in the JSON is the Bulgaria, so the first object of the array should have a common name as Bulgaria. Using this approach we can assert and verify multiple properties.
  15. We shouldn’t have error results with the success JSON data. XCTFail ensure that we shouldn’t receive a failure result where we expect to have success result.
  16. We should fulfill the expectation when we receive a failure or success case. Every expectation should be fulfilled in the XCTest. If the expectations are not fulfilled XCode will show us a fail message.
  17. Now we are saying that we expect that this test should wait for 1 sec and receive a fulfill before 1 sec completed. With this step and the step above we allow XCTest to wait asynchronous operation completed.
  18. At this step we are creating another test case. We created a test for the success case, now should have at least one for the fail. Again we are using Given-When-Then pattern.
  19. We again use MockProtocol to simulate URLSession. Only difference is we don’t need successful data. We can just pass empty data to fail the case. In case empty data provided our function should have a json conversation failure.
  20. Again we are calling the actual function with the sut object.
  21. In this case we don’t expect success response. If we receive success response we should fail the test using XCTFail.
  22. We should assert the error message to verify if we have the correct or not. In this test wer provided empty data, so we should have jsonConversationFailure as error message.
  23. Again fulfilling the expactations.
  24. Again waiting for expectations.

If you reach at this point, you are the hero !

Now it is time to run the tests. It is easy to run tests in Xcode. In each class definition line you will see a diamond shape. This is the button that run all the test cases in that class. Also each test has a run button to run single test case.

Test runners

After you run the tests you will see a red screen with “Test Failed” message like below.

Failed test sample

Don’t worry this was expected because we haven’t implemented any actual logic in the application. Let’s implement the logics now.

First change the APIRoute enum as follows:

import Foundation

enum APIRoute {
// Endpoints that view layers will call.
case getAllCountries
case querryCountryByName(name:String)

// Base url of the Countries API
private var baseURLString: String { "https://restcountries.com/v3.1/" }

// Computed property for URL generation
// Required parameters appending to this property.
private var url: URL? {
switch self {
case .getAllCountries:
// Adding 'all' to the base url provide us the path we want.
// https://restcountries.com/v3.1/all
return URL(string: baseURLString + "all")
case .querryCountryByName(name: let name):
//TODO: This is incomplete now
return URL(string: "")
}
}

// URL body params. (In this article we won't use it)
private var parameters: [URLQueryItem] {
switch self {
default:
return []
}
}

// This func creates request with the given parameters.
// We will use this request in the RequestHandler to execute requests.
func asRequest() -> URLRequest? {
guard let url = url else {
print("Missing URL for route: \(self)")
return nil
}

var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.queryItems = parameters

guard let parametrizedURL = components?.url else {
print("Missing URL with parameters for url: \(url)")
return nil
}

return URLRequest(url: parametrizedURL)
}
}

I think the code above is straight forward. The computed url property will create the required paths. asRequest function will create the required URLRequest with the given URL path. We can use the asRequest function to create required URLRequests now.

Now it is time to update RequestHandler:

import Foundation

protocol RequestHandling {
func request<T>(service: APIRoute, completion: @escaping (Result<T, APIError>) -> Void) where T:Decodable
}


/// Service Provider manages URLSession process
class RequestHandler: RequestHandling {
var urlSession:URLSession

init(urlSession: URLSession = .shared ) {
self.urlSession = urlSession
}


/// Starts resuest flow given service with required parameters and returns result in completion block.
/// - Parameters:
/// - service: Service Type
/// - decodeType: Decoder Type to return response
/// - completion: Completion with Service Result
func request<T>(service: APIRoute, completion: @escaping (Result<T, APIError>) -> Void) where T : Decodable {
// 1. Create the request using APIRoute
guard let request = service.asRequest() else {
// 2. Return error in case we don't have such request
completion(.failure(.invalidRequest))
return
}
// 3. Call the execute function to execute the request.
execute(request) { result in
switch result {
case .success(let data):
let decoder = JSONDecoder()
do {
// 4. Decode the success response with generic Decodable.
let response = try decoder.decode(T.self, from: data)
print("Successfull response received, response:\(response)")
// 5. Return the success completion
completion(.success(response))
}
catch let error{
print("Failed to decode received data :\(error)")
// 6. Return the fail completion with jsonConversionFailure
completion(.failure(.jsonConversionFailure))
}
case .failure(let error):
// 7. Return the fail completion with error we receive from execute function.
completion(.failure(error))
}
}
}



/// Executes given request.
/// - Parameters:
/// - request: URLRequest
/// - deliveryQueue: DispatchQueue of the request, default is main.
/// - completion: Completion block.
private func execute(_ request:URLRequest,
deliveryQueue:DispatchQueue = DispatchQueue.main,
completion: @escaping ((Result<Data, APIError>) -> Void)) {

// 8. Start the data task using URLSession
urlSession.dataTask(with: request) { data, response , error in

if let error = error {
deliveryQueue.async{
print("Error recevied on request, error:\(error)")
// 9. In case we receive error from API return with responseUnsuccessful error
completion(.failure(.responseUnsuccessful(error)))
}
}else if let data = data {
deliveryQueue.async{
completion(.success(data))
}
}else {
deliveryQueue.async{
print("Invalid data received, response:\(response)")
// 11. In case we don't receive a data return with invalidData error
completion(.failure(.invalidData))
}
}
}.resume()
}

}

/// Customized APIErrors for the app
enum APIError: Error {
case jsonConversionFailure
case invalidData
case invalidRequest
case responseUnsuccessful(Error)
var localizedDescription: String {
switch self {
case .invalidData: return "Invalid Data"
case .responseUnsuccessful: return "Response Unsuccessful"
case .invalidRequest: return "Invalid Request Type"
case .jsonConversionFailure: return "JSON Conversion Failure"
}
}
}

I added the required comments to the code above. The short story is we now have an actually working class for network requests. So we have implemented the actual request function, now let’s re-run the tests. Here are the results.

As you can see our tests are passed now. In case our code is incorrect in any manner we won’t see the green results.

Life is good when all the tests are green!

Country Details

We have completed the test and implementation part for the all countries list. Now it is time to do the same exercise for country details. Don’t worry it will be quicker.

Create a new group called CountryDetaillsTests in ServiceTests. Now create an empty file called DetailSuccessResponse.json and put the actual response from API.

[
{
"name": {
"common": "Turkey",
"official": "Republic of Turkey",
"nativeName": {
"tur": {
"official": "Türkiye Cumhuriyeti",
"common": "Türkiye"
}
}
},
"tld": [
".tr"
],
"cca2": "TR",
"ccn3": "792",
"cca3": "TUR",
"cioc": "TUR",
"independent": true,
"status": "officially-assigned",
"unMember": true,
"currencies": {
"TRY": {
"name": "Turkish lira",
"symbol": "₺"
}
},
"idd": {
"root": "+9",
"suffixes": [
"0"
]
},
"capital": [
"Ankara"
],
"altSpellings": [
"TR",
"Turkiye",
"Republic of Turkey",
"Türkiye Cumhuriyeti"
],
"region": "Asia",
"subregion": "Western Asia",
"languages": {
"tur": "Turkish"
},
"translations": {
"ara": {
"official": "الجمهورية التركية",
"common": "تركيا"
},
"bre": {
"official": "Republik Turkia",
"common": "Turkia"
},
"ces": {
"official": "Turecká republika",
"common": "Turecko"
},
"cym": {
"official": "Republic of Turkey",
"common": "Turkey"
},
"deu": {
"official": "Republik Türkei",
"common": "Türkei"
},
"est": {
"official": "Türgi Vabariik",
"common": "Türgi"
},
"fin": {
"official": "Turkin tasavalta",
"common": "Turkki"
},
"fra": {
"official": "République de Turquie",
"common": "Turquie"
},
"hrv": {
"official": "Republika Turska",
"common": "Turska"
},
"hun": {
"official": "Török Köztársaság",
"common": "Törökország"
},
"ita": {
"official": "Repubblica di Turchia",
"common": "Turchia"
},
"jpn": {
"official": "トルコ共和国",
"common": "トルコ"
},
"kor": {
"official": "터키 공화국",
"common": "터키"
},
"nld": {
"official": "Republiek Turkije",
"common": "Turkije"
},
"per": {
"official": "جمهوری ترکیه",
"common": "ترکیه"
},
"pol": {
"official": "Republika Turcji",
"common": "Turcja"
},
"por": {
"official": "República da Turquia",
"common": "Turquia"
},
"rus": {
"official": "Республика Турции",
"common": "Турция"
},
"slk": {
"official": "Turecká republika",
"common": "Turecko"
},
"spa": {
"official": "República de Turquía",
"common": "Turquía"
},
"swe": {
"official": "Republiken Turkiet",
"common": "Turkiet"
},
"tur": {
"official": "Türkiye Cumhuriyeti",
"common": "Türkiye"
},
"urd": {
"official": "جمہوریہ ترکی",
"common": "ترکی"
},
"zho": {
"official": "土耳其共和国",
"common": "土耳其"
}
},
"latlng": [
39.0,
35.0
],
"landlocked": false,
"borders": [
"ARM",
"AZE",
"BGR",
"GEO",
"GRC",
"IRN",
"IRQ",
"SYR"
],
"area": 783562.0,
"demonyms": {
"eng": {
"f": "Turkish",
"m": "Turkish"
},
"fra": {
"f": "Turque",
"m": "Turc"
}
},
"flag": "🇹🇷",
"maps": {
"googleMaps": "https://goo.gl/maps/dXFFraiUDfcB6Quk6",
"openStreetMaps": "https://www.openstreetmap.org/relation/174737"
},
"population": 84339067,
"gini": {
"2019": 41.9
},
"fifa": "TUR",
"car": {
"signs": [
"TR"
],
"side": "right"
},
"timezones": [
"UTC+03:00"
],
"continents": [
"Asia"
],
"flags": {
"png": "https://flagcdn.com/w320/tr.png",
"svg": "https://flagcdn.com/tr.svg"
},
"coatOfArms": {
"png": "https://mainfacts.com/media/images/coats_of_arms/tr.png",
"svg": "https://mainfacts.com/media/images/coats_of_arms/tr.svg"
},
"startOfWeek": "monday",
"capitalInfo": {
"latlng": [
39.93,
32.87
]
},
"postalCode": {
"format": "#####",
"regex": "^(\\d{5})$"
}
}
]

Now we can create the test class. Let’s call it DetailsServiceTests and put the codes below in it.

import XCTest
@testable import Countries


final class DetailsServiceTests: XCTestCase {

var sut: RequestHandling!
var expectation: XCTestExpectation!

let apiURL = URL(string: "https://restcountries.com/v3.1/name/turkey?")!
let countryName = "turkey"

override func setUpWithError() throws {
// Configuration required to Mock API requests.
let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [MockURLProtocol.self]
let urlSession = URLSession.init(configuration: configuration)

sut = RequestHandler(urlSession: urlSession)
expectation = expectation(description: "Expectation")
}

override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}


/// In this case we are inserting success data via mock and expect decoded response.
func test_givenDetailsRequest_whenResponseSuccessfull_thenShouldContainRequiredResponseModel() throws {
// Name of the sample response
let data = JSONTestHelper().readLocalFile(name: "DetailSuccessResponse")

MockURLProtocol.requestHandler = { request in
guard let url = request.url, url == self.apiURL else {
throw fatalError("URLS are not matching. Expected: \(self.apiURL), Found: \(request.url)")
}

let response = HTTPURLResponse(url: self.apiURL, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, data)
}

// Start test by calling the actual function
sut.request(service: .querryCountryByName(name: countryName)) {
(result : Result<CountryResponseModel, APIError>) in

switch result {

case .success(let response):
XCTAssertEqual(response.first?.name?.common, "Turkey", "Common names are not matching")
XCTAssertEqual(response.first?.name?.official, "Republic of Turkey", "Official names are not matching")
case .failure(let error):
XCTFail("Error was not expected: \(error.localizedDescription)")
}
self.expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
}


/// In this case we are inserting fail case via mock and expect fail return.
func test_givenDetailsRequest_whenResponseFailed_thenShouldReturnFail() throws {
// For error case we can use empty data
let data = Data()

MockURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(url: self.apiURL, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, data)
}

sut.request(service: .querryCountryByName(name: countryName)) {
(result : Result<AllCountriesResponseModel, APIError>) in
switch result {

case .success(_):
XCTFail("Success was not expected")
case .failure(let error):
XCTAssertEqual(error.localizedDescription, APIError.jsonConversionFailure.localizedDescription)
}
self.expectation.fulfill()
}

wait(for: [expectation], timeout: 1.0)
}

}

You already noticed that steps are almost same. Only difference is I am using .querryCountryByNameas APIRoute and expect to see CountryResponseModel as decoded response. Let’s run the tests.

Xcode shouts that our tests failed because when I was expecting a successful result I am getting a fail result. And the error is ‘Invalid Request Type’. Remember that we are throwing this error in RequestHandler in case we APIRoute’s asRequest function can not create the request. Let’s look at the APIRoute’s computed URL parameter.

    private var url: URL? {
switch self {

case .getAllCountries:
return URL(string: baseURLString + "all")
case .querryCountryByName(name: let name):
//TODO: This is incomplete now
return URL(string: "")
}
}

Do you see the TODO comment. We haven’t define the path of querryCountryByName case. Let’s fix that

    private var url: URL? {
switch self {

case .getAllCountries:
return URL(string: baseURLString + "all")
case .querryCountryByName(name: let name):
return URL(string: baseURLString + "name/" + name)
}
}

Let’s run the tests one more time.

We have the successful results for details 🥳

Conclusion

This practice was a long and educational journey for us. But believe me today you started to learn precious methodology to develop an iOS application. I will continue to develop this app with the TDD approach in the upcoming article.

Final form of the project already in my GitHub Repository.

Also, you can find the presentation that I made about TDD in GDG İstanbul DevFest 2022 about TDD in my website.

Take care till we meet again!

--

--