如何为下面的代码编写测试用例?

3
如果这段代码不适合编写测试代码,那么应该如何修改代码以编写测试用例?
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
  }
}

你不能这样做,因为这段代码并没有以便于测试的方式编写。这就是为什么人们总是告诉你先写测试的原因 :) - Alexander
你需要两个测试,因为你需要测试文件存在和不存在的情况。除非在其他地方也使用了它,否则最好放弃 isStored 函数,并让 readData 在给定路径下没有文件时失败。 - Joakim Danielson
顺便提一下,不要检查存在性然后再尝试读取它,完全放弃 isStored,直接尝试读取它。如果文件不存在,那么 try? Data(contentsOf:) 会返回 nil,这正好是你在未缓存时返回的内容。你可以消除一个方法,消除 getCachedData 中的 if 语句等。 - Rob
我该如何修改代码以编写测试用例,@Alexander。 - emraz
@emraz 你需要使用依赖注入来注入你的依赖的模拟变量(例如,一个模拟 FileManager,它代替真实的 FileManager,不会真正地读/写磁盘,测试代码可以与之交互)。试图改变你的实现代码以使其可测试是很困难的。我建议你把这段代码放在一边,重新开始,先编写你的测试代码。一旦你有了测试代码作为规范,就可以填写实现代码。 - Alexander
关于你的API,我只是想评论一下:try ? 是一种反模式。你应该使用 func readData(atPath path: String) throws -> Data。这样调用者就可以使用 try ? 来忽略错误,但至少你没有将错误隐藏起来,如果他们需要的话,还可以获取到错误信息。 - Paulw11
1个回答

3
考虑将类的方法提取到一个单独的 协议 中,这样我们可以使实际类和模拟类都符合该协议,并且我们可以在单元测试中测试出预期的功能,而不是执行实际实现中的代码。
/*
    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()

一些需要记住的事情:
  1. 建议先编写单元测试,运行并看到失败情况,然后编写最少量的通过单元测试所需的代码。这被称为测试驱动开发
  2. 如果所有实现类都使用依赖注入,则更容易编写测试。
  3. 考虑避免使用单例模式。如果不小心使用单例模式,会使代码难以进行单元测试。可以在这里这里阅读有关为什么应该谨慎使用单例模式的更多信息。

1
太棒了(完全就是我会做的那样)!只有一个小备注:由于MyFileManager是引用类型,您也可以使用子类化而不是协议,但我个人更喜欢协议解决方案(因为SimpleViewModel不需要关心它的fileManager是类还是结构体)。此外,赞一个,因为单例确实可能存在问题,而不是直接排斥它们。 :) - Gero

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接