Swift中的单元测试
作者:Matt North —高级IOS工程师
let greeting = “Hello!”
在Grindr,测试是我们开发工作流程的关键部分。 像许多公司一样,我们正在将iOS代码库过渡到Swift。 这是从几个方面进行的调整,Swift中的单元测试提出了特殊的挑战。 但是我们坚信单元测试所带来的价值。 那么,我们如何在Grindr编写Swift测试?
最佳实务
面对现实吧,对任何人来说,开始单元测试都可能是一项艰巨的任务。 对于Swift开发人员而言,更糟糕的是,Apple几乎没有文档说明如何编写可测试的代码。 无论您是重构现有的代码库以使其可测试,还是要着眼于可测试性来开始一个新项目,编写可测试代码都需要一个好的计划。
我将介绍2种技术/原理,这些技术/原理将有助于使您的代码更具可测试性:
- 依赖注入/协议驱动开发
- 函数式编程技术
依赖注入
依赖注入不是一个新主意,但在iOS社区中并不普遍。 依赖注入是在创建类时提供类的依赖(通常在初始化器中)的一种做法。 这样做可以使您的类可重用和自定义。 除了可重用代码的许多优点外,在编写单元测试时,这一点尤其重要。
让我们看一个例子:
错误的方法
class UserRepository {
static let shared = UserRepository()
private let dbContext = NSManagedObjectContext()
// returns an array of users
func getUsers() -> [User] {
let fetchRequest = NSFetchRequest(entityName: "User")
let users = try! dbContext.fetch(fetchRequest)
return users
}
}class UserService {
// Dependency to the repository singleton is baked in.
// Testing this would mean hitting a real instance of Core Data.
func getUsers() -> [User] {
return UserRepository.shared.getUsers()
}
}
在这里我们定义了一个共享的存储类来返回用户列表。然后,我们的UserService
将调用该单例以返回用户列表。在我们尝试对UserService
类进行单元测试之前,这一切似乎非常简单。 如果不使用UserRepository
的真实实例来击打UserService
,我们将无法提供模拟数据进行测试。 此外,以这种方式编写的任何测试都不再是单元测试:现在,我们正在与UserService
一起测试基础存储库功能。
使用依赖注入,我们可以使其更易于管理:
正确的方式
// 1: Protocol defines interface
protocol UserDataSource {
func getUsers() -> [User]
}// 2: Conform to UserDataSource protocol
class UserRepository: UserDataSource {
static let shared = UserRepository()
private let dbContext = NSManagedObjectContext()
// returns an array of users
func getUsers() -> [User] {
let fetchRequest = NSFetchRequest(entityName: "User")
let users = try! dbContext.fetch(fetchRequest)
return users
}
}class UserService {
// 3: Break out baked-in dependency into a property
var dataSource: UserDataSource?
func getUsers() -> [user] {
if let dataSource = dataSource {
return dataSource.getUsers()
}
return []
}
// 4: Allow creators of this class to inject any class/struct
// that conforms to the UserDataSource protocol
init(dataSource: UserDataSource = UserRepository.shared) {
self.dataSource = dataSource
}
}
这里发生了一些事情,所以让我们分解一下:
- 从具体类中提取功能并将其包装在协议声明中。 为了打破对
UserRepository
依赖,我们仅抽象化我们正在使用的功能并将其放入协议中。 - 更改
UserRepository
的类声明,使其符合我们在上一步中所做的协议。 - 我们声明了一个
UserDataSource
类型的实例变量,而不是直接使用UserRepository
,以便我们可以在创建对象时注入数据源。 - 添加一个接受
UserDataSource
的初始化程序,并将我们的实例变量设置为传递给该初始化程序的值。 这就是实际的“注入”发生的地方。 此外,如果您希望最大程度地减少此更改的影响,则可以为dataSource
值提供默认值,以便此类的创建者可以选择不注入任何自定义数据源。 这可以使向该模式的迁移更易于管理。
现在,我们已经在用户服务中添加了依赖注入,让我们看一下如何为它编写单元测试:
class UserServiceTests {
func testGetUsers() {
let mockDataSource = MockDataSource()
let userService = UserService(dataSource: mockDataSource)
let users = userService.getUsers()
if users[0].name == "Chris Lattner" {
print("test passed!!")
} else {
print("test failed")
}
}
}// Here we define our test class, and our mock data.
class MockDataSource: UserDataSource {
func getUsers() -> [User] {
var testUser = User()
testUser.name = "Chris Lattner"
return [testUser]
}
}
由于依赖注入,我们的用户服务类现在可以完全测试了! 我们创建一个符合UserDataSource
协议的模拟数据源,并将其注入到我们的用户服务中。 现在, UserService
UserRepository
使用UserRepository
单例,而是使用我们在测试中定义的模拟数据源类。 除此之外,我们的代码现在更加灵活—如果我们想以其他方式存储用户,则只需更改数据源实现,而不必重写所有与用户打交道的组件。
如何模拟我无法控制的类?
我们所有人都依赖Apple提供给我们的一些标准类。 自然,我们很快就会遇到这样的情况:我们的类依赖于在标准库之一中声明的某个类。 如何将这些类中的功能抽象到协议中?
幸运的是,这在Swift中非常简单! 让我们UserRepository
一下UserRepository
类,该类使用提供的类之一NSManagedObjectContext
class UserRepository {
static let shared = UserRepository()
private let dbContext = NSManagedObjectContext()
// returns an array of users
func getUsers() -> [User] {
let fetchRequest = NSFetchRequest(entityName: "User")
let users = try! dbContext.fetch(fetchRequest)
return users
}
}
为了使此类灵活,我需要在我所依赖的NSManagedObjectContext
上定义功能,并将其包装在协议中。
我使用的方法的签名是:
func fetch(_ request: NSFetchRequest) throws -> [Any]
因此,我只是将这个确切的签名添加到我的协议中,然后扩展NSManagedObjectContext
以使其符合我的协议。
protocol UserStorageProtocol {
func fetch(_ request: NSFetchRequest) throws -> [Any]
}extension NSManagedObjectContext: UserStorageProtocol { /* nothing else needed! */}
现在,可以像在UserService:
一样注入此依赖项UserService:
class UserRepository {
static let shared = UserRepository()
private var repository: UserStorageProtocol
// returns an array of users
func getUsers() -> [User] {
let fetchRequest = NSFetchRequest(entityName: "User")
let users = try! dbContext.fetch(fetchRequest)
return users
}
init(_ repository: UserStorageProtocol) {
self.repository = repository
}
}
那模拟框架呢?
如果您过去使用Objective-C进行过一些单元测试,您可能已经注意到,我还没有接触过模拟框架。 原因是:Swift尚不支持适当的自省/反思。 这些机制是OCMock和其他提供智能模拟的框架的核心。
这确实意味着您将必须在测试中编写自己的模拟对象,但是一段时间后,我发现这是变相的祝福。 当您拥有像OCMock这样的功能强大的框架时,很容易过度依赖它。 没有此功能,您将不得不编写真正模块化的代码。 即使没有单元测试的好处,拥有模块化代码本身也很重要。
功能编程
Swift被设计为比Objective-C功能更多的语言。 深入探讨函数式编程将是另一天的主题。 但是,我想谈一谈其核心原则之一:无状态代码。
无状态代码
当类的隐藏状态影响其行为方式时,测试单个组件将变得非常困难。 让我们看一下UIViewController
的理论子类。
// Dog is a class with properties of name and rank
class TopDogController: UIViewController {
private var dogs: [Dog] = DogService.getUnsortedDogs()
@IBOutlet var topDogLabel: UILabel!
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.dogs.sort()
if let topDogName = dogs.first?.name {
topDogLabel.text = topDogName
}
}
}extension Dog: Comparable {
static func == (lhs: Dog, rhs: Dog) -> Bool {
return lhs.rank == rhs.rank
}
static func Bool {
return lhs.rank < rhs.rank
}
}
可以说我想编写一个测试来验证此控制器是否正确显示了顶级狗。 直接测试UI代码可能很棘手,因此可以通过验证排序来间接测试它。
class TopDogControllerTests {
// Top dog should be a husky.
func testTopDog() {
var mockDogs: [Dog] = [Pug, Poodle, Husky]
mockDogs.sort()
if let topDogName = mockDogs.first!.name {
if topDogName == "Husky" {
print("Test passed!!")
} else {
print("Test failed!!")
}
}
}
}// output: test passed!!
结论
单元测试是软件开发的关键部分! 没有它,随着项目的不断发展,很难保持其质量。 通过使用依赖注入和函数式编程,可以使代码可重用,并且易于测试。 接下来,您知道您的代码将被完全覆盖,并且您可以放心快速交付新功能! 🎯
感谢您的阅读,并随时留下任何反馈或评论!