下面的代码应该怎么写测试用例?

How should write the test case for the code below?

如果这段代码不适合编写测试代码,应该如何修改编写测试用例的代码?

class MyFileManager {
   static let shared = MyFileManager()
 
  func isStored(atPath path: String) -> Bool {
     return FileManager.default.fileExists(atPath: path)
 }

 func readData(atPath path: String) -> Data? {
      return try? Data(contentsOf: URL(fileURLWithPath: path))
  }
}

class SomeViewModel {
  func getCachedData() -> Data? {
      let path = "xxxxx"
 
      if MyFileManager.shared.isStored(atPath: path) {
          return MyFileManager.shared.readData(atPath: path)
      } else {
          return nil
      }
  }
}

class TestSomeViewModel: XCTestCase {
  func testGetCachedData() {
      let viewModel = SomeViewModel()
      // Need to cover SomeViewModel.getCachedData() method
  }
}

考虑将 class 的方法提取到一个单独的 protocol 中,这样我们就可以使实际的 class 和模拟的 class 都符合该协议,我们可以在单元测试中测试预期的功能,而不是在实际实现中执行代码。

/*
    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))
    }
}

SomeViewModelclass也可以通过依赖注入获取依赖

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

随意将上述所有 classes 和单元测试复制粘贴到单独的 playground 文件中。要 运行 Playground 中的两个单元测试,写 -

TestSomeViewModel.defaultTestSuite.run()

其他一些注意事项:-

  1. 建议先编写单元测试,运行 看看它是否失败,然后编写通过单元测试所需的最少代码量。这叫做Test Driven Development.
  2. 如果所有实现 classes 使用依赖注入,编写测试会更容易。
  3. 考虑避免使用单例。如果不小心使用单例,它们会使代码难以进行单元测试。欢迎阅读更多关于为什么我们应该谨慎使用单例的内容 here and here