使用Xcode XCTest的TDD iOS网络API调用

测试驱动开发(TDD)是软件开发人员可以在软件开发中使用的方法之一。 在TDD中,开发人员计划要创建的软件功能,然后在编写功能实现之前,为软件的每个功能编写测试用例。

在开始时,测试用例显然会失败,因为代码尚未实现。 此阶段通常称为红色阶段。 然后,开发人员编写代码以确保测试用例成功通过,并且不会破坏任何组件或当前的测试用例,因此不必完全优化和高效地完成此阶段中的代码。 此阶段称为绿色阶段。 此后,开发人员应通过清理,维护代码库和优化代码效率来重构代码的实现。 然后,应在添加新的测试用例时重复此循环。 每个测试单元应做得尽可能小且隔离,以使其易于阅读和维护。

在本文中,我们将使用带有Xcode XCTest Framework的TDD构建一个简单的网络API单元测试。 Network API将对服务器的网络调用封装为以JSON格式获取电影列表,然后将其编码为Movie Swift类的数组。 网络测试需要在不发出实际网络请求的情况下快速执行,为此,我们将创建模拟对象和存根来模拟服务器调用和响应。

  • APIRepository类:此类封装了我们对服务器的网络请求调用,以获取电影列表
  • APIRepositoryTests类:XCTest子类,我们将使用该子类为APIRepository类编写测试用例
  • MockURLSession类:URLSession子类,充当模拟对象以测试传递的URL以及使用存根数据,URLResponse和Error对象创建的MockURLSessionDataTask
  • MockURLSessionDataTask类:URLSessionDataTask子类,充当模拟对象,用于存储网络调用中的存根数据,URLResponse,Error,completionHandler对象,它覆盖恢复调用并调用传递存根对象的completionHandler存根。

测试用例1 —从API获取电影按预期设置URL主机和路径

我们将创建的第一个测试用例是测试get films方法是否在正确的期望范围内设置了URL Host和Path。 首先在“测试”模块内创建APIRepositoryTests单元测试类。 不要忘记添加“ @testable import project-name”以将项目模块包含在测试模块中。 要在Xcode中运行测试,可以使用快捷键Command + U。

在函数中,我们设置实例化APIRepository类对象,此方法中创建了一个名为testGetMoviesWithExpectedURLHostAndPath()的方法,因为我们尚未输入APIRepository类,所以我们键入了有关未解析标识符APIRepository的编译器投诉。

  导入 XCTest 
@testable 导入 APITest APIRepositoryTests:XCTestCase { func testGetMoviesWithExpectedURLHostAndPath(){
apiRespository = APIRepository()
}
}

要编译测试,请创建一个包含APIRepository类的名为APIRepository.swift的新文件。

  导入基础 APIRepository {} 

接下来,在testGetMoviesWithExpectedURLHostAndPath内部,我们将调用APIRepository方法,以从通过完成处理程序的网络中获取电影。

  func testGetMoviesWithExpectedURLHostAndPath(){ 
apiRespository = APIRepository()
apiRepository.getMovies {电影,错误}
}

编译器会抱怨APIRepository没有成员getMovies,在APIRepository类内部将getMovies函数和Create Movie类添加到新File中以进行测试编译。

  导入基础班级电影:可编码{ 
标题:字符串
详细说明:字符串
} APIRepository {
func getMovies(completion: @escaping ([Movie] ?, Error?)-> Void){
}
}

为了测试URL主机和路径名,我们需要一种方法,当URLSession调用dataTask(带有:,completionHandler :)并传递包含我们测试中Server API的主机名和路径的URL时,缓存URL的方法。 为此,我们将创建一个MockURLSession类,该类将URLSession类作为子类,并添加包含URL的存储属性。 然后,我们重写dataTask(with:,completementHandler :)方法,并将url分配给instance属性。

  MockURLSession  :URLSession { 
var cachedUrl:URL? 覆盖 func dataTask(使用url:URL,completionHandler: @转义 (数据?,URLResponse ?、错误?)->无效)-> URLSessionDataTask {
自我 .cachedUrl =网址
返回 URLSessionDataTask()
}
}

在我们的测试案例中,我们将MockURLSession分配给APIRepository会话属性,然后在调用getMovies之后,使用XCTAssertEqual检查APIRepository会话的URL主机和期望的路径名。

  func testGetMoviesWithExpectedURLHostAndPath(){ 
apiRespository = APIRepository()
mockURLSession = MockURLSession()
apiRespository.session = mockURLSession
apiRespository.getMovies(){电影,错误}
XCTAssertEqual(mockURLSession.cachedUrl?.host,“ mymovieslist.com”)
XCTAssertEqual(mockURLSession.cachedUrl?.path,“ / topmovies”)
}

编译器将失败,使测试编译仅在APIRepository类内添加session属性。

   APIRepository { 
var会话:URLSession!
...
}

运行测试,由于XCTAssertEqual,测试将失败。 我们需要实现将正确的URL传递给sessionDataTask的getMovies方法,以通过测试。 之后,测试将最终成功通过。

  func getMovies(完成: @转义 ([电影] ?,错误?)->无效) 
警卫 url = URL(字符串:“ https://mymovieslist.com/topmovies”)
否则 {fatalError()}
session.dataTask(with:url){( ___in }
}

测试案例2-从API获取电影成功返回剧集列表

下一个测试是测试获取电影的API何时成功响应,应该调用完成处理程序并传递电影列表。 为了测试Xcode中的异步代码,我们可以使用Expectation和waitForExpectation函数在我们传递的指定超时内实现。 满足期望后,将调用完成处理程序,我们可以使用该处理程序从异步代码中声明实现的结果。 在APIRepositoryTestClass和以下代码中创建testGetMoviesSuccessReturnsMovies函数:

  func testGetMoviesSuccessReturnsMovies(){ 
apiRespository = APIRepository()
mockURLSession = MockURLSession()
apiRespository.session = mockURLSession
moviesExpectation =期望(描述:“电影”)
var filmsResponse:[Movie]?

apiRespository.getMovies {(电影,错误)
电影响应=电影
filmsExpectation.fulfill()
}
waitForExpectations(超时:1){(错误)
XCTAssertNotNil(moviesResponse)
}
}

该测试将失败,因为我们尚未实现getMovies完成处理程序以将JSON从响应序列化为Movie类。 但是,如何在没有实际的服务器返回数据的情况下进行测试呢? 我们可以使用MockDataTask并为数据传递存根。 MockDataTask是一个URLSessionDataTask子类,可以使用存根数据,URLResponse和Error对象进行初始化,然后将其缓存在实例属性中。 它还将completionHandler存储为实例属性,因此可以在调用override resume方法时调用它。

   MockTask:URLSessionDataTask { 
私人 出租数据:数据?
私人 urlResponse:URLResponse吗?
私人 错:错误?

var completeHandler:(((Data ?, URLResponse ?, Error?)->无效)
初始化 (数据:数据?,urlResponse:URLResponse ?,错误:错误?){
自我 .data =数据
自我 .urlResponse = urlResponse
自我。错误=错误
}
覆盖 func resume(){
DispatchQueue.main.async {
self .completionHandler?( self .data, self .urlResponse, self .error)
}
}
}

我们将MockTask作为MockURLSession类的实例属性,并且我们还将创建接受Data,URLResponse和Error对象的初始化程序,然后使用这些参数实例化模拟任务对象。 在dataTask覆盖方法内,我们还将完成处理程序分配给mockTask完成处理程序属性,以便在调用履历表时将其调用。

  MockURLSession  :URLSession { 
...
私人 mockTask:MockTask
初始化 (数据:数据?,urlResponse:URLResponse ?,错误:错误?){
mockTask = MockTask(数据:数据,urlResponse:urlResponse,错误:
错误)

覆盖func dataTask(使用url:URL,completionHandler: @转义 (数据?,URLResponse ?、错误?)->无效)-> URLSessionDataTask {
自我 .cachedUrl =网址
mockTask.completionHandler =完成处理器
返回 dataTask
}
}

现在在测试中,我们创建存根json数据,然后初始化传递存根数据的MockSession。 运行测试,该测试失败,因为我们尚未实现getMovies完成处理程序来处理响应并将JSON数据序列化为Movie对象。

  func testGetMoviesSuccessReturnsMovies(){ 
let jsonData =“ [{\” title \“:\”不可能发生的任务\“,\” detail \“:\”汤姆·克鲁斯电影“”]“。data(使用:.utf8)
apiRespository = APIRepository()
let mockURLSession = MockURLSession(data:jsonData,urlResponse: nil ,错误: nil
apiRespository.session = mockURLSession
moviesExpectation =期望(描述:“电影”)
var filmsResponse:[Movie]?

apiRespository.getMovies {(电影,错误)
电影响应=电影
filmsExpectation.fulfill()
}
waitForExpectations(超时:1){(错误)
XCTAssertNotNil(moviesResponse)
}
}

为了使测试成功通过,请在APIRepository类的获取电影中实现completionHandler:

  func getMovies(completion: @escaping ([Movie] ?, Error?)-> Void){ 
警卫 url = URL(字符串:“ https://mymovieslist.com/topmovies”)
else {fatalError()} session.dataTask(with:url){(数据,响应,错误)
守护 数据=数据其他 { 返回 }
电影= 尝试 ! JSONDecoder()。decode([Movie]。self,来自:数据)
完成(电影,
}。恢复()}

测试用例3 —从具有URL响应错误的API获取影片返回ErrorResponse

第三个测试用例是测试dataTask完成处理程序是否有错误,并返回ResponseError。 在APIRepositoryTests类中创建testGetMoviesWhenResponseErrorReturnsError方法:

  func testGetMoviesWhenResponseErrorReturnsError(){ 
apiRespository = APIRepository()
let error = NSError(domain:“ error”,代码:1234,userInfo: nil
let mockURLSession = MockURLSession(数据: nil ,urlResponse: nil ,错误:error)
apiRespository.session = mockURLSession
errorExpectation =期望(描述:“错误”)
var errorResponse:错误?
apiRespository.getMovies {(电影,错误)
errorResponse =错误
errorExpectation.fulfill()
}
waitForExpectations(超时:1){(错误)
XCTAssertNotNil(errorResponse)
}
}

测试失败,因为我们尚未在完成处理程序中处理实现或处理响应错误。 添加实现以使用guard检查错误是否为nil,并将错误传递给完成处理程序,如果存在则返回错误。 运行测试以确保它成功通过。

  func getMovies(completion: @escaping ([Movie] ?, Error?)-> Void){ 
警卫 url = URL(字符串:“ https://mymovieslist.com/topmovies”)
else {fatalError()} session.dataTask(with:url){(数据,响应,错误)
警卫错误== 其他 {
完成( ,错误)
返回
} 保护 let data = data else { return }
电影= 尝试 ! JSONDecoder()。decode([Movie]。self,来自:数据)
完成(电影,
}。恢复()}

测试案例4-使用空数据从API获取电影会返回错误

该测试将检查响应何时返回空数据,然后将通过Error对象调用完成处理程序。 在APIRepositoryTests类中创建testGetMoviesWhenEmptyDataReturnsError函数。

  func testGetMoviesWhenEmptyDataReturnsError(){ 
apiRespository = APIRepository()
let mockURLSession = MockURLSession(data: nil ,urlResponse: nil ,错误: nil
apiRespository.session = mockURLSession
errorExpectation =期望(描述:“错误”)
var errorResponse:错误?
apiRespository.getMovies {(电影,错误)
errorResponse =错误
errorExpectation.fulfill()
}
waitForExpectations(超时:1){(错误)
XCTAssertNotNil(errorResponse)
}
}

测试失败,我们需要使用警卫来检查数据,如果数据不存在,请使用Error对象调用完成处理程序。 运行测试以确保它通过。

  func getMovies(completion: @escaping ([Movie] ?, Error?)-> Void){ 
警卫 url = URL(字符串:“ https://mymovieslist.com/topmovies”)
else {fatalError()} session.dataTask(with:url){(数据,响应,错误)
警卫错误== 其他 {
完成( ,错误)
返回
} 保护数据=数据其他 {
完成( nil ,NSError(domain:“ no data”,代码:10,userInfo: nil ))
返回
电影= 尝试 ! JSONDecoder()。decode([Movie]。self,来自:数据)
完成(电影,
}。恢复()}

测试案例5 —使用无效的JSON从API获取电影会返回错误

获取电影API的最终测试是处理将无效JSON数据传递到对象序列化时返回Error的情况。 在APIRepositoryTests类中创建一个testGetMoviesInvalidJSONReturnsError。

  func testGetMoviesInvalidJSONReturnsError(){ 
jsonData =“ [{\” t \“}]”。data(使用:.utf8)
apiRespository = APIRepository()
let mockURLSession = MockURLSession(data:jsonData,urlResponse: nil ,错误: nil
apiRespository.session = mockURLSession
errorExpectation =期望(描述:“错误”)
var errorResponse:错误?
apiRespository.getMovies {(电影,错误)
errorResponse =错误
errorExpectation.fulfill()
}
waitForExpectations(超时:1){(错误)
XCTAssertNotNil(errorResponse)
}
}

运行测试,由于使用try,测试将崩溃! 处理JSONDecoder解码功能,以将JSON序列化为Movie Class。 我们需要使用do try catch块来重构代码,并在JSON解析中发生错误时返回错误。

  func getMovies(completion: @escaping ([Movie] ?, Error?)-> Void){ 
警卫 url = URL(字符串:“ https://mymovieslist.com/topmovies”)
else {fatalError()} session.dataTask(with:url){(数据,响应,错误)
警卫错误== 其他 {
完成( ,错误)
返回
} 保护数据=数据其他 {
完成( nil ,NSError(domain:“ no data”,代码:10,userInfo: nil ))
返回
} {
电影= 尝试 JSONDecoder()。decode([Movie]。self,来自:数据)
完成(电影,
} {
完成( ,错误)
}
}。恢复()}

运行测试以确保我们所有的测试通过都没有回归!

从长远来看,软件开发中的TDD会导致一个更健壮和可维护的软件,因为作为开发人员,我们始终可以在开发过程中再次运行测试,并通过检测代码的回归来添加新功能。 只要我们对整个模块都有良好的测试覆盖范围,TDD在我们的应用程序中产生的错误也应该更少。