歡迎回來腹泌,這一節(jié)凉袱,我們基于之前實(shí)現(xiàn)的MockURLSession
和MockURLSessionDataTask
來測(cè)試WeatherDataManager
中和網(wǎng)絡(luò)通信相關(guān)的功能。
該怎么做呢专甩?
為了回答這個(gè)問題,我們首先應(yīng)該考慮的問題是:究竟想要測(cè)試什么钉稍?例如,在上一節(jié)中贡未,我們的目的是:測(cè)試resume()
方法被調(diào)用。那么俊卤,現(xiàn)在呢?我們可以從一個(gè)最簡(jiǎn)單的場(chǎng)景開始:確保服務(wù)器的返回結(jié)果不為nil
消恍。
為了測(cè)試這個(gè)結(jié)果,最簡(jiǎn)單的辦法當(dāng)然就是實(shí)際向DarkSky發(fā)送一個(gè)請(qǐng)求哺哼,然后測(cè)試返回值叼风,為此,我們就“跟著感覺”寫下了下面這個(gè)測(cè)試用例:
func test_weatherDataAt_gets_data() {
var data: WeatherData? = nil
WeatherDataManager.shared.weatherDataAt(
latitude: 52,
longitude: 100,
completion: { (response, error) in
data = response
})
XCTAssertNotNil(data)
}
執(zhí)行一下測(cè)試就會(huì)發(fā)現(xiàn)无宿,Xcode會(huì)直接告訴我們測(cè)試失敗了。這是因?yàn)槟跫Γ琗code的測(cè)試用例是單線程執(zhí)行的,它不會(huì)等待weatherDataAt
的回調(diào)函數(shù)執(zhí)行完彬碱。因此,在執(zhí)行XCTAssertNotNil
時(shí)巷疼,網(wǎng)絡(luò)請(qǐng)求還沒有完成,此時(shí)data
的值還是nil
嚼沿。于是,測(cè)試就失敗了瓷患。
該怎么辦呢?我們有兩種方法測(cè)試異步執(zhí)行的回調(diào)函數(shù)擅编。
使用Xcode expectation
第一種,是使用在Xcode 6時(shí)引入的一個(gè)功能爱态,叫做Expectation。簡(jiǎn)單來說肢藐,就是允許我們?cè)谝粋€(gè)時(shí)間范圍里吱韭,給Xcode設(shè)置一個(gè)“期望”,如果期望滿足了就表示測(cè)試成功理盆,如果超時(shí)了,就表示測(cè)試失敗猿规。
直接來看代碼。首先姨俩,我們用expectation
方法蘸拔,給“期望”添加一個(gè)描述:
func test_weatherDataAt_gets_data() {
let expect = expectation(
description: "Loading data from \(API.authenticatedURL)")
/// ...
}
其次,在我們之前編寫的代碼里调窍,在條件滿足的地方,調(diào)用fulfill()
方法通知Xcode:
func test_weatherDataAt_gets_data() {
let expect = expectation(description: "Loading data from \(API.authenticatedURL)")
var data: WeatherData?
WeatherDataManager.shared.weatherDataAt(
latitude: 52,
longitude: 100,
completion: { (response, error) in
data = response
expect.fulfill() // - Notify Xcode here
})
/// ...
}
最后邓萨,給“期望值”設(shè)置一個(gè)超時(shí)時(shí)間,并進(jìn)行測(cè)試:
func test_weatherDataAt_gets_data() {
/// ...
waitForExpectations(timeout: 5, handler: nil)
XCTAssertNotNil(data)
}
這樣缔恳,只要在5秒之內(nèi)得到了返回值,測(cè)試就會(huì)成功歉甚,否則,就會(huì)失敗铃芦。重新執(zhí)行一下測(cè)試,如果一切順利刃滓,我們就可以看到測(cè)試通過的結(jié)果了仁烹。
但是咧虎,通過Xcode expectation也只能部分解決我們的問題,對(duì)于測(cè)試異步執(zhí)行的代碼砰诵,這種方式仍有一些問題:
- 首先,測(cè)試結(jié)果仍舊取決于網(wǎng)絡(luò)狀況茁彭,因此我們很難保證多次測(cè)試結(jié)果的一致性;
- 其次理肺,當(dāng)我們要測(cè)試一個(gè)REST服務(wù)的時(shí)候,如果每個(gè)URL的測(cè)試都基于實(shí)際網(wǎng)絡(luò)訪問和超時(shí)的機(jī)制妹萨,將會(huì)顯著增加測(cè)試執(zhí)行的時(shí)間年枕;
為此乎完,我們需要需要第二種方法:把從網(wǎng)絡(luò)獲取到數(shù)據(jù)的部分mock出來,并且,讓異步執(zhí)行的代碼同步執(zhí)行桥状,這樣才可以精確管理測(cè)試用例的執(zhí)行過程。
借助于上一節(jié)實(shí)現(xiàn)的MockURLSession
和MockURLSessionDataTask
典格,我們可以很容易完成這兩個(gè)工作。
通過mock串行化異步執(zhí)行的代碼
為了控制從服務(wù)器得到的是正常的響應(yīng)或是發(fā)生了錯(cuò)誤耍缴,我們給MockURLSession
添加三個(gè)屬性:
class MockURLSession: URLSessionProtocol {
var responseData: Data?
var responseHeader: HTTPURLResponse?
var responseError: Error?
/// ...
}
這樣,我們就可以直接通過設(shè)置這三個(gè)屬性的值來模擬從服務(wù)器得到的返回結(jié)果了防嗡。接下來,我們還要調(diào)整MockURLSession.dataTask
的實(shí)現(xiàn):
class MockURLSession: URLSessionProtocol {
/// ...
func dataTask(
with request: URLRequest,
completionHandler: @escaping DataTaskHandler)
-> URLSessionDataTaskProtocol {
completionHandler(responseData, responseHeader, responseError)
return sessionDataTask
}
}
由于不用通過網(wǎng)絡(luò)請(qǐng)求數(shù)據(jù)了蚁趁,在這個(gè)“仿制”的版本里,我們直接調(diào)用dataTask
的回調(diào)函數(shù)就好了他嫡。這樣,就把一個(gè)異步回調(diào)方法钢属,在測(cè)試的過程中變成了一個(gè)同步方法徘熔。
重新審視weatherDataAt的實(shí)現(xiàn)
接下來淆党,在開始編寫測(cè)試用例之前,我們?cè)賮砜匆谎?code>weatherDataAt的代碼染乌。它是一個(gè)testable的方法么?
func weatherDataAt(
latitude: Double,
longitude: Double,
completion: @escaping CompletionHandler) {
/// ...
self.urlSession.dataTask(
with: request,
completionHandler: {
(data, response, error) in
DispatchQueue.main.async {
self.didFinishGettingWeatherData(
data: data,
response: response,
error: error,
completion: completion)
}
}).resume()
在當(dāng)初我們給dataTask
傳遞closure參數(shù)的時(shí)候說過荷憋,這部分代碼很可能會(huì)和更新UI相關(guān),因此勒庄,直接把它放在了主線程隊(duì)列中執(zhí)行。這看似沒什么不合理锅铅,但當(dāng)我們引入了單元測(cè)試之后酪呻,就有了新的發(fā)現(xiàn):
- 首先盐须,有了剛才的經(jīng)歷我們就會(huì)知道漆腌,這樣并不利于測(cè)試阶冈。盡管我們讓
dataTask
的回調(diào)函數(shù)本身變成了同步執(zhí)行,但在這個(gè)closure內(nèi)的代碼卻是異步執(zhí)行的塑径,因此我們?nèi)耘f無法可靠地獲取調(diào)用weatherDataAt
之后的結(jié)果; - 其次统舀,在面向?qū)ο蟮脑O(shè)計(jì)里,你也可能聽說過這樣的說法:盡可能把在設(shè)計(jì)上的決策推后到你真正需要它們的時(shí)候誉简。因?yàn)橐坏Q定了碉就,它就會(huì)成為制約你后面所有設(shè)計(jì)的一個(gè)限制闷串。那么,回過頭來想這個(gè)問題:我們一定會(huì)在這更新UI么烹吵?當(dāng)代碼日益復(fù)雜之后,我們?nèi)绾斡浀靡呀?jīng)把closure參數(shù)放在了主線程里呢肋拔?似乎我們也都沒有特別有信心的答案;
因此只损,基于上面兩點(diǎn)考慮一姿,我們不應(yīng)該限制dataTask
clousure的執(zhí)行環(huán)境:
self.urlSession.dataTask(
with: request,
completionHandler: {
(data, response, error) in
self.didFinishGettingWeatherData(
data: data,
response: response,
error: error,
completion: completion)
}).resume()
沒錯(cuò)跃惫,直接調(diào)用它就好了,如果要更新UI爆存,我們應(yīng)該明確在closure里指出讓代碼在主線程中執(zhí)行。這樣weatherDataAt
的實(shí)現(xiàn)先较,就可以在測(cè)試環(huán)境里,用同步的方式執(zhí)行了闲勺。
設(shè)計(jì)測(cè)試用例
一切都準(zhǔn)備就緒之后曾棕,我們來設(shè)計(jì)weatherDataAt
方法的測(cè)試用例菜循。
測(cè)試可以正確的處理請(qǐng)求錯(cuò)誤
第一個(gè)要測(cè)試的內(nèi)容,是可以處理錯(cuò)誤的請(qǐng)求。根據(jù)我們自己的實(shí)現(xiàn)衙耕,這種情況下,應(yīng)該可以得到DataManagerError.failedRequest
橙喘。在WeatherDataManagerTest
里,添加下面的代碼:
func test_weatherDataAt_handle_invalid_request() {
let session = MockURLSession()
session.responseError = NSError(
domain: "Invalid Request",
code: 100,
userInfo: nil)
let manager = WeatherDataManager(
baseURL: URL(string: "https://darksky.net")!,
urlSession: session)
var error: DataManagerError? = nil
manager.weatherDataAt(
latitude: 52,
longitude: 100,
completion: {
(_, e) in
error = e
})
XCTAssertEqual(error, DataManagerError.failedRequest)
}
測(cè)試可以正確處理服務(wù)器返回的狀態(tài)碼
第二個(gè)要測(cè)試的內(nèi)容厅瞎,是可以檢測(cè)到服務(wù)器返回的非200 HTTP狀態(tài)碼,對(duì)這種情況和簸,我們也會(huì)得到``DataManagerError.failedRequest`:
func test_weatherDataAt_handle_statuscode_not_equal_to_200() {
let url = URL(string: "https://darksky.net")!
let session = MockURLSession()
session.responseHeader = HTTPURLResponse(
url: url, statusCode: 400,
httpVersion: nil,
headerFields: nil)
let data = "{}".data(using: .utf8)!
session.responseData = data
var error: DataManagerError? = nil
let manager = WeatherDataManager(
baseURL: url, urlSession: session)
manager.weatherDataAt(
latitude: 52,
longitude: 100,
completion: {
(_, e) in
error = e
})
XCTAssertEqual(error, DataManagerError.failedRequest)
}
測(cè)試服務(wù)器返回的內(nèi)容不正確
下一個(gè)要測(cè)試的內(nèi)容,是服務(wù)器返回HTTP 200的時(shí)候比搭,附帶的數(shù)據(jù)不完整的情況冠跷。這次蜜托,我們故意創(chuàng)造一個(gè)非法的JSON字符串形式:{
。并期望得到DataManagerError.failedRequest
:
func test_weatherDataAt_handle_invalid_response() {
let url = URL(string: "https://darksky.net")!
let session = MockURLSession()
session.responseHeader = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil)
/// Make a invalid JSON response here
let data = "{".data(using: .utf8)!
session.responseData = data
var error: DataManagerError? = nil
let manager = WeatherDataManager(
baseURL: url, urlSession: session)
manager.weatherDataAt(
latitude: 52,
longitude: 100,
completion: {
(_, e) in
error = e
})
XCTAssertEqual(error, DataManagerError.invalidResponse)
}
測(cè)試可以正確解碼服務(wù)器返回值
最后橄务,應(yīng)該測(cè)試合法的情況了。我們測(cè)試服務(wù)器的返回值可以自動(dòng)解碼成model對(duì)象:
func test_weatherDataAt_handle_response_decode() {
let url = URL(string: "https://darksky.net")!
let session = MockURLSession()
session.responseHeader = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil)
let data = """
{
"longitude" : 100,
"latitude" : 52,
"currently" : {
"temperature" : 23,
"humidity" : 0.91,
"icon" : "snow",
"time" : 1507180335,
"summary" : "Light Snow"
}
}
""".data(using: .utf8)!
session.responseData = data
var decoded: WeatherData? = nil
let manager = WeatherDataManager(
baseURL: url, urlSession: session)
manager.weatherDataAt(
latitude: 52,
longitude: 100,
completion: {
(d, _) in
decoded = d
})
let expected = WeatherData(
latitude: 52,
longitude: 100,
currently: WeatherData.CurrentWeather(
time: Date(timeIntervalSince1970: 1507180335),
summary: "Light Snow",
icon: "snow",
temperature: 23,
humidity: 0.91))
XCTAssertEqual(decoded, expected)
}
雖然看著有點(diǎn)長(zhǎng)蜂挪,但是邏輯很簡(jiǎn)單,我們只是比較手工創(chuàng)建的WeatherData
對(duì)象和解碼出來的結(jié)果是否相同罷了嗓化。但為了上面的代碼可以通過測(cè)試,我們還得做一些修改刺覆。
首先,為了讓WeatherData
對(duì)象支持比較谦屑,我們得讓它遵從protocol Equatable
。在WeatherData.swift中氢橙,添加下面的代碼:
extension WeatherData.CurrentWeather: Equatable {
static func ==(
lhs: WeatherData.CurrentWeather,
rhs: WeatherData.CurrentWeather) -> Bool {
return lhs.time == rhs.time &&
lhs.summary == rhs.summary &&
lhs.icon == rhs.icon &&
lhs.temperature == rhs.temperature &&
lhs.humidity == rhs.humidity
}
}
extension WeatherData: Equatable {
static func ==(lhs: WeatherData,
rhs: WeatherData) -> Bool {
return lhs.latitude == rhs.latitude &&
lhs.longitude == rhs.longitude &&
lhs.currently == rhs.currently
}
}
都是直接比較屬性相等的代碼酝枢,很簡(jiǎn)單悍手。
其次喉磁,由于DarkSky返回的是UNIX時(shí)間戳,我們要在解碼的時(shí)候,設(shè)置一下Date
對(duì)象的解碼方式涝焙。把didFinishGettingWeatherData
中解碼的部分改成下面這樣:
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let weatherData = try decoder.decode(
WeatherData.self, from: data)
完成后,test_weatherData_handle_response_decode()
就應(yīng)該可以通過測(cè)試了仑撞。
Refactor the test case
最后赤兴,我們整理一下所有的測(cè)試用例隧哮,把其中公共的部分定義成屬性,把這些屬性的設(shè)置沮翔,統(tǒng)一放到setUp
方法里,Xcode會(huì)在執(zhí)行每一個(gè)測(cè)試方法前執(zhí)行這些代碼:
這也是implicitly unwrapped optional的一個(gè)典型的應(yīng)用場(chǎng)景采蚀。
class WeatherDataManagerTest: XCTestCase {
let url = URL(string: "https://darksky.net")!
var session: MockURLSession!
var manager: WeatherDataManager!
override func setUp() {
super.setUp()
// Put setup code here. This method is called before the invocation of each test method in the class.
self.session = MockURLSession()
self.manager = WeatherDataManager(baseURL: url, urlSession: session)
}
/// ...
}
下面,是基于這些調(diào)整之后的測(cè)試用例完整代碼:
func test_weatherDataAt_starts_the_session() {
let dataTask = MockURLSessionDataTask()
session.sessionDataTask = dataTask
manager.weatherDataAt(
latitude: 52,
longitude: 100,
completion: { _, _ in })
XCTAssert(session.sessionDataTask.isResumeCalled)
}
func test_weatherDataAt_handle_invalid_request() {
session.responseError = NSError(
domain: "Invalid Request", code: 100, userInfo: nil)
var error: DataManagerError? = nil
manager.weatherDataAt(latitude: 52, longitude: 100, completion: {
(_, e) in
error = e
})
XCTAssertEqual(error, DataManagerError.failedRequest)
}
func test_weatherDataAt_handle_statuscode_not_equal_to_200() {
session.responseHeader = HTTPURLResponse(
url: url, statusCode: 400, httpVersion: nil, headerFields: nil)
let data = "{}".data(using: .utf8)!
session.responseData = data
var error: DataManagerError? = nil
manager.weatherDataAt(latitude: 52, longitude: 100, completion: {
(_, e) in
error = e
})
XCTAssertEqual(error, DataManagerError.failedRequest)
}
func test_weatherDataAt_handle_invalid_response() {
session.responseHeader = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil)
let data = "{".data(using: .utf8)!
session.responseData = data
var error: DataManagerError? = nil
manager.weatherDataAt(
latitude: 52,
longitude: 100,
completion: {
(_, e) in
error = e
})
XCTAssertEqual(error, DataManagerError.invalidResponse)
}
func test_weatherDataAt_handle_response_decode() {
session.responseHeader = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil)
let data = """
{
"longitude" : 100,
"latitude" : 52,
"currently" : {
"temperature" : 23,
"humidity" : 0.91,
"icon" : "snow",
"time" : 1507180335,
"summary" : "Light Snow"
}
}
""".data(using: .utf8)!
session.responseData = data
var decoded: WeatherData? = nil
manager.weatherDataAt(
latitude: 52,
longitude: 100,
completion: {
(d, _) in
decoded = d
})
let expected = WeatherData(
latitude: 52,
longitude: 100,
currently: WeatherData.CurrentWeather(
time: Date(timeIntervalSince1970: 1507180335),
summary: "Light Snow",
icon: "snow",
temperature: 23,
humidity: 0.91))
XCTAssertEqual(decoded, expected)
}
至此榆鼠,我們就可以確定model的解碼以及manager都可以正常工作了。稍后妆够,當(dāng)我們編寫界面的時(shí)候,還會(huì)繼續(xù)討論UI測(cè)試的方法神妹。通過這個(gè)過程颓哮,我們可以看到鸵荠,單元測(cè)試,不僅可以有助于更早發(fā)現(xiàn)錯(cuò)誤腰鬼,也可以在某種程度上改進(jìn)代碼的質(zhì)量。現(xiàn)在熄赡,把測(cè)試的話題先放放姜挺。在下一節(jié)彼硫,我們來定義Sky的view controllers凌箕,并把它們和models關(guān)聯(lián)起來。