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
}
}

这里发生了一些事情,所以让我们分解一下:

  1. 从具体类中提取功能并将其包装在协议声明中。 为了打破对UserRepository依赖,我们仅抽象化我们正在使用的功能并将其放入协议中。
  2. 更改UserRepository的类声明,使其符合我们在上一步中所做的协议。
  3. 我们声明了一个UserDataSource类型的实例变量,而不是直接使用UserRepository ,以便我们可以在创建对象时注入数据源。
  4. 添加一个接受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!!

结论

单元测试是软件开发的关键部分! 没有它,随着项目的不断发展,很难保持其质量。 通过使用依赖注入和函数式编程,可以使代码可重用,并且易于测试。 接下来,您知道您的代码将被完全覆盖,并且您可以放心快速交付新功能! 🎯

感谢您的阅读,并随时留下任何反馈或评论!

Interesting Posts