如何对将文件保存到磁盘进行单元测试?

28
我知道强烈建议在运行单元测试时将其与文件系统分开,因为如果您在测试中触及文件系统,则还会测试文件系统本身。好的,这是合理的。
我的问题是,如果我想测试将文件保存到磁盘上,该怎么办?就像数据库一样,我会分离负责数据库访问的接口,然后为我的测试创建另一个实现吗?或者可能还有其他方法吗?

1
创建一个访问文件系统的接口。然后创建它的模拟,可以使用模拟框架或手动创建。 - P.K
非常感谢有用的评论和答案,我想我会坚持使用模拟框架。 - chester89
1
单元测试不使用任何系统。它不是集成测试。单元测试不测试最后一层,即将文件保存到磁盘的层。您需要进行集成测试以保存文件。在这种情况下,您只需保存文件,然后检查它是否已创建。这是一种不同类型的测试,不同的方法和不同的故事。大多数下面的答案都是错误的,因为它们谈论单元测试,但试图干涉集成测试实践。 - Artem A
3个回答

阿里云服务器只需要99元/年,新老用户同享,点击查看详情
46
我的方法非常倚重于我刚读过的Growing Object-Oriented Software Guided by Tests(GOOS)书籍,但这是我今天所知道的最好的方法。具体来说:
  • 创建一个接口,将文件系统与代码分离。在需要该类作为协作者/依赖项的地方进行模拟。这样可以使您的单元测试快速且反馈迅速。
  • 创建集成测试,测试接口的实际实现。即验证调用Save()是否实际将文件保存到磁盘并具有正确的内容(使用参考文件或解析它以包含一些应该包含的内容)
  • 创建验收测试,测试整个系统-从头到尾。在这里,您可能只需验证是否创建了一个文件-此测试的目的是确认真正的实现是否被正确连接/插入。

评论者更新:

如果您正在阅读结构化数据(例如书籍对象)(如果没有,请将字符串替换为IEnumerable)

interface BookRepository
{
  IEnumerable<Books> LoadFrom(string filePath);
  void SaveTo(string filePath, IEnumerable<Books> books);
}
现在,您可以使用构造函数注入来将模拟对象注入到客户类中。客户类的单元测试因此变得快速,不会触发文件系统。它们只验证依赖项上调用了正确的方法(例如,Load/Save)。
var testSubject = new Client(new Mock<BookRepository>.Object);

接下来,您需要创建一个真正的BookRepository实现,它可以使用文件(或明天如果您想要的话,可以使用Sql DB)。其他人不必知道。 为FileBasedBookRepository编写集成测试(实现上述角色),并测试使用引用文件调用Load是否提供正确的对象,以及使用已知列表调用Save是否将它们保存到磁盘。即使用真实文件 进行测试。这些测试会比较慢,因此请标记它们或将其移到单独的套件中。

[TestFixture]
[Category("Integration - Slow")]
public class FileBasedBookRepository 
{
  [Test]
  public void CanLoadBooksFromFileOnDisk() {...}
  [Test]
  public void CanWriteBooksToFileOnDisk() {...}
}

最后应该有一个或多个验收测试来测试负载和保存功能。


只是好奇,GOOS书是什么?我搞不清楚。 - Mathias
2
如果可以的话,你能否提供一些关于第1点和第2点的代码呢?我会非常感激的。 - Thang Pham

8

有一个普遍的规则,要小心编写进行文件I/O的单元测试,因为它们往往太慢了。但是在单元测试中进行文件I/O并没有绝对的禁止。

在您的单元测试中设置和拆除一个临时目录,并在该临时目录中创建测试文件和目录。是的,您的测试速度将比纯CPU测试慢,但仍然很快。JUnit甚至提供了支持代码来帮助处理这种情况:在TemporaryFolder上的@Rule

话虽如此,大多数文件写入代码采用以下形式:

  1. 打开一个输出流到文件。此代码必须处理缺少文件或文件权限问题。您需要测试它是否打开了文件并处理了这些失败条件。
  2. 向输出流写入内容。这必须以正确的格式编写,这是最复杂的部分,需要进行最多的测试。它必须处理在向输出流写入内容时发生的I/O错误,尽管这些错误在实践中很少见。
  3. 关闭输出流。这必须处理在关闭流时发生的I/O错误,尽管这些错误在实践中很少见。

只有第一部分真正涉及文件系统。其余部分只是使用输出流。

因此,您可以将中间和最后一部分提取到自己的方法(函数)中,该方法操作给定的输出流,而不是命名文件。然后,您可以模拟该输出流以对该方法进行单元测试。这些单元测试将非常快速。大多数编程语言已经提供了适当的输出流类。

只剩下第一部分需要进行单元测试。您只需要进行少量测试,因此您的测试套件仍然可以接受地快速运行。


1
请参见https://dev59.com/OXVC5IYBdhLWcg3wpS7g。 - Raedwald

6
你可以将一个流(Stream)、TextWriter或类似的东西传递给你的保存函数,而不是传递文件名。这样,在测试时,你可以传递一个基于内存的实现,并验证正确的字节被写入,而不实际写入任何磁盘。 为了测试问题和异常,你可以看一下模拟框架。这可以帮助你在保存过程的特定点人工生成特定的异常,并测试你的代码是否适当地处理它。

1
我在想@chester89是否也希望测试处理“无效路径”、“文件冲突”、“锁定文件”、“无效文件名”、“保存中断”等问题。 我认为所有这些都可以被模拟。 - scunliffe
@scunliffe,是的,我也想测试这些东西。我该如何模拟这些行为呢? - chester89
@chester: 你可以看一下模拟框架。这有助于在适当的时间生成这些异常。在开始测试每个可能的错误情况并从测试中覆盖100%的代码之前,请仔细考虑。这通常比它所值得的更加昂贵。 - Mark Byers
在走向测试每个可能的错误条件和测试代码覆盖率达到100%的道路之前,请注意这往往比它所值得的更昂贵。为什么呢?您能解释一下吗?我不同意您的意见。特别是在这种情况下,追求100%的覆盖率是值得的。chester89不一定需要使用模拟框架,他也可以手动创建模拟对象。 - P.K
8
@tugga:你需要权衡测试每个案例的好处和开发成本。如果使用现有的文件选择对话框,大多数无效路径问题已经由组件正确处理。如果你有偏执症,可以想象一些情况下文件选择器会返回一个无效的文件名,但这些情况非常罕见,通常是框架中的错误。如果你花费90%的开发时间来测试很少发生的事情,那么只有10%的时间用于开发人们愿意付钱的功能。 - Mark Byers

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