考虑将类的方法提取到单独的protocol 中,这样我们就可以使实际类和模拟类都符合该协议,并且我们可以在单元测试中测试预期的功能而不是执行实际实现中的代码。
/*
Extract the 2 methods of MyFileManager into a separate protocol.
Now we can create a mock class which also conforms to this same protocol,
which will help us in writing unit tests.
*/
protocol FileManagerProtocol {
func isStored(atPath path: String) -> Bool
func readData(atPath path: String) -> Data?
}
class MyFileManager: FileManagerProtocol {
static let shared = MyFileManager()
// To make a singleton instance, we have to make its initializer private.
private init() {
}
func isStored(atPath path: String) -> Bool {
//ideally, even FileManager.default instance should be "injected" into this class via dependency injection.
return FileManager.default.fileExists(atPath: path)
}
func readData(atPath path: String) -> Data? {
return try? Data(contentsOf: URL(fileURLWithPath: path))
}
}
SomeViewModel 类也可以通过依赖注入获取其依赖项。
class SomeViewModel {
var fileManager: FileManagerProtocol?
// We can now inject a "mocked" version of MyFileManager for unit tests.
// This "mocked" version will confirm to FileManagerProtocol which we created earlier.
init(fileManager: FileManagerProtocol = MyFileManager.shared) {
self.fileManager = fileManager
}
/*
I've made a small change to the below method.
I've added the path as an argument to this method below,
just to demonstrate the kind of unit tests we can write.
*/
func getCachedData(path: String = "xxxxx") -> Data? {
if let doesFileExist = self.fileManager?.isStored(atPath: path),
doesFileExist {
return self.fileManager?.readData(atPath: path)
}
return nil
}
}
上述实现的单元测试看起来类似于下面写的。
class TestSomeViewModel: XCTestCase {
var mockFileManager: MockFileManager!
override func setUp() {
mockFileManager = MockFileManager()
}
override func tearDown() {
mockFileManager = nil
}
func testGetCachedData_WhenPathIsXXXXX() {
let viewModel = SomeViewModel(fileManager: self.mockFileManager)
XCTAssertNotNil(viewModel.getCachedData(), "When the path is xxxxx, the getCachedData() method should not return nil.")
XCTAssertTrue(mockFileManager.isStoredMethodCalled, "When the path is xxxxx, the isStored() method should be called.")
XCTAssertTrue(mockFileManager.isReadDataMethodCalled, "When the path is xxxxx, the readData() method should be called.")
}
func testGetCachedData_WhenPathIsNotXXXXX() {
let viewModel = SomeViewModel(fileManager: self.mockFileManager)
XCTAssertNil(viewModel.getCachedData(path: "abcde"), "When the path is anything apart from xxxxx, the getCachedData() method should return nil.")
XCTAssertTrue(mockFileManager.isStoredMethodCalled, "When the path is anything apart from xxxxx, the isStored() method should be called.")
XCTAssertFalse(mockFileManager.isReadDataMethodCalled, "When the path is anything apart from xxxxx, the readData() method should not be called.")
}
}
// MockFileManager is the mocked implementation of FileManager.
// Since it conforms to FileManagerProtocol, we can implement the
// methods of FileManagerProtocol with a different implementation
// for the assertions in the unit tests.
class MockFileManager: FileManagerProtocol {
private(set) var isStoredMethodCalled = false
private(set) var isReadDataMethodCalled = false
func isStored(atPath path: String) -> Bool {
isStoredMethodCalled = true
if path.elementsEqual("xxxxx") {
return true
}
return false
}
func readData(atPath path: String) -> Data? {
isReadDataMethodCalled = true
if path.elementsEqual("xxxxx") {
return Data()
}
return nil
}
}
您可以随意将上述所有类和单元测试复制粘贴到单独的 Playground 文件中。要在 Playground 中运行这两个单元测试,请编写 -
TestSomeViewModel.defaultTestSuite.run()
需要记住的其他一些事项:-
- 建议先编写单元测试,运行它并看到它失败,然后编写通过单元测试所需的最少代码量。这称为Test Driven Development。
- 如果所有实现类都使用依赖注入,编写测试会更容易。
- 考虑避免单例。如果不小心使用单例,它们会使代码难以进行单元测试。欢迎阅读更多关于为什么我们应该谨慎使用单例 here 和 here。