如何对具体实现进行单元测试

How to unittest specific implementation

我阅读了很多关于(单元)测试的文章,并且我尝试在日常工作流程中尽可能多地实施,但不知何故我觉得我做错了什么。

假设我有一个函数,它接受一条路径,并根据这条路径的某些元素为新日志文件创建一个名称。路径可以是 C:/my_project/dir_1/message01,它应该将其转换为 dir_1_log_01.txt。函数的名称是 convertPathToLogfileName.

如果我想为此函数编写单元测试,它可能如下所示:

def test_convertPathToLogfileName():   
    path = "C:/my_project/dir_1/message01"

    expected = "dir_1_log_01.txt"
    actual = convertPathToLogFileName(path)

    assertEqual(expected, actual)

现在我可以编写一堆这样的测试来检查所有不同类型的输入,如果输出符合我的预期。

但是,如果在某一时刻我决定我选择的日志文件的命名约定不再是我想要的怎么办:我将更改函数以使其实现我的新要求并且所有测试都会失败。

这只是一个简单的例子,但我感觉经常是这样。当我在编程时,我想出了一种做某事的新方法,然后我的测试失败了。

这里有什么我遗漏的吗,我是不是测试错了?如果是这样,您将如何处理这种情况?或者这就是它的方式,我应该接受吗?

Is there something I am missing here, am I testing the wrong thing? And if so, how would you approach this situation? Or is this just the way it is and should I accept this?

不是真的。这是典型的 "incoming X leads to output Y" 情况。

你可以可能改变的一件事:也许你完成了 TDD 之后,你得到了所有的各种小测试验证被测函数的 in/out 合同的不同方面……您可以将其拉入 table。

意思是:这里很可能不需要有 20 种不同的测试方法(它们都做同样的事情)。为什么不使用包含 "X in" 对的简单列表应该导致 "Y out" 数据点。然后你有一个测试可以遍历该列表并测试这些对。

但回到您的主要问题:您没有测试实施。您的测试 做一个黑盒 in/out 测试:X 进去,Y 应该出来。

换句话说:您的测试验证的是合同,而不是实现实现以某种方式 为即将到来的特定 X 计算正确 Y 的代码。具体如何完成并不重要,对您的 "production users" 和您的测试用例都不重要!

因此:如果您的合同(完全)发生变化,那么您当前的所有测试都将无效。是的,当您遵循 TDD 时,这可能意味着您(或多或少)从头开始。

Is there something I am missing here?

几件事。

一个是您不一定需要所有测试来指定主题的确切行为。从 simplest thing that could possibly work 的意义上说,断言两个表示完全相等是一个很好的起点,但这不是您的唯一选择。拥有一组测试,每个测试都满足一些约束,这可能同样有效——然后当您对预期行为进行小的更改时,您只需要在测试之间进行小的更改。

再一个就是模块的设计;参见 [Parnas 1971]。这里的基本思想是每个模块都以一个决策为模型,如果我们改变一个决策,我们将替换该模块。模块边界就像变化的舱壁。

在你的例子中,可能至少有两个模块

path = "C:/my_project/dir_1/message01"
expected = "dir_1_log_01.txt"

这看起来很像您需要一个 parse 函数来从路径中提取有趣的信息,以及一些 apply to template 函数来对提取的信息做一些有趣的事情。

这可能允许你写一个像

这样的断言
assertEquals(
    applyTemplate("dir_1", "01"),
    convertPathToLogFileName(path)
)

然后你可能在其他地方有,比如

assertEquals(
    "dir_1_log_01.txt",
    applyTemplate("dir_1", "01")
)

当您以后决定更改拼写时,只需更改第二个断言。有关此想法的更多信息,请参阅 James Shore,Testing Without Mocks

在测试驱动的世界中经常发生的事情是,在发现我们需要改变一些行为后,我们将重构以围绕我们将要改变的决定创建一个模块,并引入一条路径,我们可以通过该路径可以配置哪个模块参与我们的系统——所有这些更改都可以在不破坏任何现有测试的情况下进行。然后我们开始引入新的测试来描述替换模块,以及它如何与您的解决方案的其余部分交互。

如果你看上面,你会发现我通过引入 applyTemplate 来暗示这一点,你只有 convertPathToLogFileName - 我重构 convertPath 以生成 applyTemplate 函数,现在我可以以本地包含的方式更改系统的行为。

当我们有大量 overfitted 测试时,这并不能挽救我们;我们不会永远被特定的测试实现所束缚。相反,我们会查看难以更改实现的测试,并考虑如何修改测试设计以使将来的更改更容易。

也就是说,预计会有一定数量的返工 -- 我们将来需要更改哪些代码的最佳证据是我们现在需要更改哪些代码 .不改变的功能将适用于过度拟合测试。我们希望将设计资金投入到我们定期修改的代码部分。