当TDD没有帮助时,如何避免代码中的逻辑错误?


67

最近,我正在编写一小段代码,以人类友好的方式指示事件的年代。例如,它可能表明该事件发生在“三周前”或“一个月前”或“昨天”。

需求相对明确,这是测试驱动开发的完美案例。我一个接一个地编写了测试,实现了通过每个测试的代码,一切似乎都正常运行。直到生产中出现错误为止。

这是相关的代码段:

now = datetime.datetime.utcnow()
today = now.date()
if event_date.date() == today:
    return "Today"

yesterday = today - datetime.timedelta(1)
if event_date.date() == yesterday:
    return "Yesterday"

delta = (now - event_date).days

if delta < 7:
    return _number_to_text(delta) + " days ago"

if delta < 30:
    weeks = math.floor(delta / 7)
    if weeks == 1:
        return "A week ago"

    return _number_to_text(weeks) + " weeks ago"

if delta < 365:
    ... # Handle months and years in similar manner.

测试正在检查今天,昨天,四天前,两周前,一周前等事件发生的情况,并相应地构建了代码。

我错过的是,某事件可能在前一天发生,而可能是一天前发生:例如,发生在26小时前的事件可能是一天前发生,而如果现在是凌晨1点,则可能不是昨天,更确切地说,这是一点的东西,但由于delta是整数,所以它将只是一个。在这种情况下,应用程序显示“一天前”,这显然是意外的,并且在代码中未处理。可以通过添加以下内容进行修复:

if delta == 1:
    return "A day ago"

在计算完之后delta

尽管该错误的唯一负面影响是我浪费了半个小时才想知道这种情况的发生方式(并且相信它与时区有关,尽管在代码中统一使用了UTC),但它的存在却使我感到困扰。它表明:

  • 即使在这样简单的源代码中,犯逻辑错误也是非常容易的。
  • 测试驱动的开发无济于事。

同样令人担忧的是,我看不到如何避免此类错误。除了在编写代码之前要多思考之外,我唯一能想到的方法是为我认为永远不会发生的情况添加很多断言(例如我认为一天前一定是昨天),然后逐一遍历在过去的十年中,检查是否有任何违反声明的情况,这似乎太复杂了。

我如何才能避免一开始就创建此错误?


38
通过一个测试用例吗?这似乎是您后来发现它的方式,并与TDD啮合。
世纪

63
您刚刚体验了为什么我不喜欢测试驱动的开发-根据我的经验,生产中捕获的大多数错误都是没人想到的场景。测试驱动的开发和单元测试对此无能为力。(不过,单元测试在检测通过将来的编辑引入的错误中
很有用

102
在我之后重复:“没有包括TDD在内的银弹。” 没有过程,没有规则集,没有可以自动生成完美代码的算法。如果有的话,我们可以使整个过程自动化并完成它。
jpmc26

43
恭喜,您重新发现了古老的智慧,即没有任何测试可以证明没有错误。但是,如果您正在寻找技术来更好地覆盖可能的输入域,则需要对域,边缘情况和该域的等价类进行彻底的分析。在术语TDD发明之前,所有早已众所周知的古老技术。
布朗

80
我并不是想打招呼,但是您的问题似乎可以改写为“我怎么想那些我没有想到的事情?”。不确定与TDD有什么关系。
贾里德·史密斯

Answers:


57

这些是您通常在红色/绿色/重构的重构步骤中发现的错误类型。不要忘记这一步!考虑如下重构(未调试):

def pluralize(num, unit):
    if num == 1:
        return unit
    else:
        return unit + "s"

def convert_to_unit(delta, unit):
    factor = 1
    if unit == "week":
        factor = 7 
    elif unit == "month":
        factor = 30
    elif unit == "year":
        factor = 365
    return delta // factor

def best_unit(delta):
    if delta < 7:
        return "day"
    elif delta < 30:
        return "week"
    elif delta < 365:
        return "month"
    else:
        return "year"

def human_friendly(event_date):
    date = event_date.date()
    today = now.date()
    yesterday = today - datetime.timedelta(1)
    if date == today:
        return "Today"
    elif date == yesterday:
        return "Yesterday"
    else:
        delta = (now - event_date).days
        unit = best_unit(delta)
        converted = convert_to_unit(delta, unit)
        pluralized = pluralize(converted, unit)
        return "{} {} ago".format(converted, pluralized)

在这里,您在较低的抽象级别上创建了3个函数,这些函数具有更大的内聚力,并且更容易进行隔离测试。如果您遗漏了您想要的时间跨度,它将在简单的辅助函数中像拇指一样伸出来。此外,通过删除重复项,可以减少出错的可能性。实际上,您实际上必须添加代码来实现破损的情况。

在查看这样的重构形式时,也更容易想到其他更微妙的测试用例。例如,best_unit如果delta为负怎么办?

换句话说,重构不仅是为了使其美观。它使人类更容易发现编译器无法发现的错误。


12
下一步是国际化,pluralize只为一部分英语单词工作将是一种责任。
重复数据删除器

@Deduplicator肯定,但随后这取决于语言/文化,你的目标,则可能是只修改脱身pluralize使用num,并unit建立某种形式的钥匙拉一些表/资源文件的格式字符串。否则,您可能需要完全重写逻辑,因为您需要不同的单元;-)
绿巨人

4
即使进行了这种重构,仍然存在一个问题,那就是“昨天”在早上非常凌晨(凌晨12:01之后)没有太大意义。用人类友好的术语来说,当时钟经过午夜时,在晚上11:59发生的事情不会突然从“今天”更改为“昨天”。而是从“ 1分钟前”更改为“ 2分钟前”。就今天之前发生的事情而言,“今天”太粗糙了,“昨天”充满了夜猫子的麻烦。
大卫·哈门

@DavidHammen这是一个可用性问题,取决于您需要达到的精确度。当您想至少了解小时时,我认为“昨天”不是很好。“ 24小时前”更为清晰,是强调小时数的常用人类表达方式。试图“人类友好”的计算机几乎总是会出错,并将其概括化为“昨天”,这太含糊了。但是要知道这一点,您需要采访用户以了解他们的想法。对于某些事情,您确实需要确切的日期和时间,因此“昨天”总是错误的。
布兰丁

149

测试驱动的开发无济于事。

似乎确实有帮助,只是您没有针对“一天前”的情况进行测试。大概是在发现这种情况后,您添加了一个测试;这仍然是TDD,因为发现错误后,您可以编写单元测试来检测错误,然后进行修复。

如果您忘记编写行为测试,那么TDD并没有什么帮助您的;您忘了编写测试,因此不编写实现。


2
可以指出的是,如果开发人员未使用tdd,那么他们也很可能会错过其他情况。
加勒布

75
而且,最重要的是,考虑一下他们在修复错误时节省了多少时间?通过进行现有测试,他们可以立即知道自己的更改不会破坏现有行为。他们可以自由添加新的测试用例并进行重构,而无需随后进行大量的手动测试。
加勒布

15
TDD仅与编写的测试一样好。
Mindwin '18年

另一个观察结果:针对这种情况添加测试将通过迫使我们将其datetime.utcnow()从函数中移出并改为now(可重现的)参数来改进设计。
Toby Speight

114

发生在26小时前的事件将是一天前

如果问题定义不明确,测试将无济于事。显然,您将日历天与以小时计算的天数混合在一起。如果您坚持日历日,那么26小时前的凌晨1点不是昨天。如果您坚持几个小时,那么26小时前的时间将舍入到1天之前的时间,而不考虑时间。


45
这是一个很好的观点。缺少需求并不一定意味着您的实施过程失败了。这仅表示该要求没有明确定义。(或者您只是犯下了人为的错误,有时会发生)
Caleb

这是我想做的答案。我将规范定义为“如果事件是该日历日,则以小时为单位显示变化量。其他使用日期仅用于确定变化量”。
Baldrickk

1
我喜欢这个答案,因为它指出了真正的问题:时间和日期是两个不同的数量。它们是相关的,但是当您开始比较它们时,事情进展很快。在编程中,日期和时间逻辑是最难解决的问题。我真的不喜欢很多日期实现基本上将日期存储为0:00时间点。这引起很多混乱。
Pieter B

38

你不能 TDD非常适合保护您免受可能发现的问题的影响。如果遇到从未考虑过的问题,这将无济于事。最好的选择是让其他人测试系统,他们可能会找到您从未考虑过的极端情况。

相关阅读: 大型软件是否有可能达到绝对零错误状态?


2
由开发人员以外的其他人编写测试始终是一个好主意,这意味着双方都需要忽略相同的输入条件,以便使该bug投入生产。
Michael Kay

35

我通常会采用两种方法可以找到帮助。

首先,我寻找边缘情况。这些是行为改变的地方。在您的情况下,行为会沿着正整数天的顺序在几个点发生变化。有一个边缘案例为零,一个,七个,等等,然后我将在边缘案例及其周围编写测试案例。我要在-1天,0天,1小时,23小时,24小时,25小时,6天,7天,8天等时间使用测试用例。

我要寻找的第二件事是行为模式。按照您数周的逻辑,您需要进行一周的特殊处理。在未显示的其他每个间隔中,您可能都有类似的逻辑。这个逻辑是存在的日子里,虽然。我会怀疑地看待这个问题,直到我可以证实地解释为什么这种情况有所不同,或者我添加了逻辑。


9
这是TDD的一个非常重要的部分,经常被忽略,而且在文章和指南中我很少见到过讨论- 测试边缘情况和边界条件非常重要,因为我发现这是90%的错误的根源- -酮的错误,在和下溢,该月的最后一天,一年的最后一个月,闰年等等等等
GoatInTheMachine

2
@GoatInTheMachine-90%的bug中的90%都在夏令时左右转换.....哈哈哈
Caleb

1
您可以先将等价类划分为可能的输入,然后在类的边界处确定边缘情况。在我们当中,这可能比开发工作还大。这是否值得,取决于尽可能无错误地交付软件的重要性,截止日期是多少以及您拥有多少金钱和耐心。
彼得·施耐德

2
这是正确的答案。许多业务规则要求您将值的范围划分为间隔,在这种情况下,要用不同的方式处理它们。
abuzittin gillifirca

14

不能赶上存在于与TDD您的要求的逻辑错误。但是,TDD仍然可以提供帮助。毕竟,您发现了错误,并添加了一个测试用例。但从根本上说,TDD 确保代码符合您的思维模型。如果您的思维模式存在缺陷,那么测试用例将无法抓住它们。

但是请记住,在修复错误的同时,您已经确保没有破坏现有的运行行为的测试用例。这非常重要,很容易修复一个错误,但要引入另一个错误。

为了事先发现那些错误,您通常尝试使用基于等效类的测试用例。使用该原理,您可以从每个等价类中选择一个案例,然后再选择所有边缘案例。

您可以选择今天,昨天,几天前,一周前和几周前的日期作为每个等效类的示例。在测试日期时,还应确保测试使用系统日期,而是使用预定日期进行比较。这也将突出显示一些极端情况:您将确保在一天中的任意时间运行测试,直接在午夜之后,午夜之前甚至午夜直接运行测试。这意味着对于每个测试,将有四个基准时间被测试。

然后,您可以系统地将边缘情况添加到所有其他类中。您今天有考试。因此,应在行为切换前后添加一个时间。昨天也一样。一个星期前的情况相同,依此类推。

很有可能,通过系统地枚举所有边缘情况并为它们写下测试用例,您会发现您的规范缺少一些细节并添加它。请注意,处理日期是人们经常会犯错的事情,因为人们经常忘记编写测试,以便可以在不同的时间运行它们。

但是请注意,我写的大部分内容与TDD无关。关于写下等效类并确保您自己的规范足够详细。是最小化逻辑错误的过程。TDD只是确保您的代码符合您的思维模型。

提出测试用例很难。基于等价类的测试还不是全部,并且在某些情况下,它可以大大增加测试用例的数量。在现实世界中,添加所有这些测试通常在经济上不可行(即使从理论上讲,也应该这样做)。


12

我能想到的唯一方法是为我认为永远不会发生的情况添加很多断言(例如我相信一天前一定是昨天),然后在过去十年中每秒循环浏览一次,以检查任何断言违规,这似乎太复杂了。

为什么不?这听起来是个不错的主意!

在代码中添加合同(断言)是提高其正确性的非常可靠的方法。通常,我们将它们添加为函数输入的前提条件,并作为函数返回的后置条件。例如,我们可以添加一个后置条件,即所有返回的值都采用 “ A [unit] ago”或“ [number] [unit] s ago”的形式。如果以一种有条理的方式完成工作,这将导致按合同进行设计,这是编写高保证代码的最常见方法之一。

至关重要的是,这些合同并不打算进行测试。它们和测试一样,都是代码规范。但是,您可以通过合同进行测试:在测试中调用代码,如果没有合同引发错误,则测试通过。通过循环过去十年的第二个有点多。但是我们可以利用另一种称为基于属性的测试的测试样式。

在PBT中,您无需测试代码的特定输出,而是测试输出是否符合某些属性。例如,一个特性reverse()功能,对于任何名单lreverse(reverse(l)) = l。这样编写测试的好处是,您可以让PBT引擎生成数百个任意列表(以及一些病理列表),并检查它们是否都具有此属性。如果没有,引擎会“缩小”失败的情况,以找到破坏代码的最小列表。看起来您正在编写Python,它具有假设作为主要的PBT框架。

因此,如果您希望找到一种您可能不会想到的棘手的极端情况的好方法,将合同和基于属性的测试一起使用将大有帮助。当然,这并不能代替编写单元测试,但确实可以增加它,这确实是我们作为工程师可以做到的最好的选择。


2
这正是解决此类问题的正确方法。有效输出集易于定义(您可以非常简单地给出正则表达式,类似/(today)|(yesterday)|([2-6] days ago)|...),然后可以使用随机选择的输入运行该过程,直到找到不在预期输出集中的输入。采用这种方法已经抓住了这个错误,并且不会要求在意识到错误可能事先存在。
朱尔斯

@Jules另请参阅属性检查/测试。我通常在开发过程中编写属性测试,以涵盖尽可能多的意外情况,并迫使我考虑一般的属性/不变量。我保存了一次回归测试,以及此类回归测试(作者的问题是其中的一个例子)
Warbo

1
如果您在测试中进行如此多的循环,则将花费很长时间,这将破坏单元测试的主要目标之一:快速运行测试!
CJ丹尼斯

5

这是一个示例,其中添加一些模块化将很有用。如果多次使用容易出错的代码段,则最好将其包装在函数中。

def time_ago(delta, unit):
    delta_str = _number_to_text(delta) + " " + unit;
    if delta == 1:
        return delta_str + " ago"
    else:
        return delta_str = "s ago"

now = datetime.datetime.utcnow()
today = now.date()
if event_date.date() == today:
    return "Today"

yesterday = today - datetime.timedelta(1)
if event_date.date() == yesterday:
    return "Yesterday"

delta = (now - event_date).days

if delta < 7:
    return time_ago(delta, "day")

if delta < 30:
    weeks = math.floor(delta / 7)
    return time_ago(weeks, "week")

if delta < 365:
    months = math.floor(delta / 31)
    return time_ago(months, "month")

5

测试驱动的开发无济于事。

如果编写测试的人是对抗性的,则TDD作为一种技术最为有效。如果您不是成对编程,那么这将很困难,因此考虑此问题的另一种方法是:

  • 不要编写测试来确认正在测试的功能是否正常运行。编写故意破坏它的测试。

这是另一种技术,适用于在有或没有TDD的情况下编写正确的代码,并且可能与实际编写代码一样复杂(如果不是那么复杂的话)。它是您需要练习的东西,而它没有一个简单,简单的答案。

编写功能强大的软件的核心技术,也是了解如何编写有效测试的核心技术:

了解函数的前提条件-有效状态(即您对函数作为方法的类的状态做出的假设)和有效输入参数范围-每个数据类型都有一个可能值范围-其中一个子集将由您的功能处理。

如果您只是在显式地测试函数输入时假设了这些,并确保在没有进一步处理的情况下记录或抛出了违规和/或函数错误,那么您可以快速知道您的软件是否在生产中失败,请使其功能强大和容错能力,并发展您的对抗测试写作技巧。


注意 关于前后条件,不变量等的完整文献,以及可以使用属性应用它们的库。我个人不喜欢这么正式,但值得研究。


1

这是有关软件开发的最重要的事实之一:编写完全没有错误的代码是绝对,绝对不可能的。

TDD不会避免您引入与您未想到的测试用例相对应的错误。这也不会免除您编写不正确的测试而没有意识到它,然后编写恰巧通过错误测试的错误代码的麻烦。而且曾经创建的所有其他单一软件开发技术也有类似的漏洞。作为开发人员,我们是不完美的人。最终,无法编写100%无错误的代码。它永远也不会发生。

这并不是说您应该放弃希望。尽管不可能编写出完全完美的代码,但是编写出很少有在极少数情况下出现的错误的代码是有可能的,因此该软件非常实用。在实践中不表现出错误行为的软件极有可能编写。

但是编写它需要我们接受一个事实,即我们将生产错误的软件。几乎每一个现代软件开发实践都在某种程度上围绕防止错误首先出现或保护自己免受我们不可避免地产生的错误的后果而建立:

  • 收集全面的要求可以使我们知道代码中的不正确行为。
  • 编写干净,经过精心设计的代码可以更轻松地避免一开始就引入错误,并且在我们识别出错误时更容易修复它们。
  • 编写测试使我们能够记录我们认为软件中许多最严重的错误的记录,并证明我们至少避免了这些错误。TDD在代码之前生成这些测试,BDD从需求中生成这些测试,老式的单元测试在代码编写之后生成测试,但是它们都可以防止将来出现最差的回归。
  • 同行评审意味着每次更改代码时,至少有两双眼睛看到了该代码,从而减少了错误进入主代码的频率。
  • 使用将错误视为用户故事的错误跟踪器或用户故事跟踪器意味着,当出现错误时,它们会被跟踪并最终得到处理,而不是被遗忘并始终如一地以用户的方式接受。
  • 使用登台服务器意味着在主要版本发布之前,所有显示阻止程序错误都有机会出现并得到解决。
  • 使用版本控制意味着在最坏的情况下,将带有重大错误的代码交付给客户,您可以在进行整理时执行紧急回滚,并将可靠的产品重新交付客户。

解决您发现的问题的最终方法不是与无法保证不能编写无错误代码的事实相反,而是要接受它。在开发过程的所有领域中都采用行业最佳实践,您将一如既往地向用户交付代码,尽管这些代码虽然不够完善,但足以胜任工作。


1

您以前根本没有想到过这种情况,因此没有针对它的测试用例。

这种情况一直发生,而且很正常。您在创建所有可能的测试用例上付出了多少努力始终是一个权衡。您可以花费无限时间考虑所有测试用例。

对于飞机自动驾驶仪,您将花费比简单工具更多的时间。

通常可以考虑一下输入变量的有效范围并测试这些边界。

此外,如果测试人员与开发人员的身份不同,那么通常会发现更重要的案例。


1

(并且尽管代码中统一使用了UTC,但仍认为它与时区有关)

这是代码中的另一个逻辑错误,您尚未对其进行单元测试:)-您的方法将为非UTC时区的用户返回不正确的结果。在计算之前,您需要将“现在”和事件的日期都转换为用户的本地时区。

示例:在澳大利亚,当地时间上午9点发生了一个事件。由于UTC日期已更改,因此它将在上午11点显示为“昨天”。


0
  • 让其他人编写测试。这样,对您的实现不熟悉的人可能会检查您从未想到的罕见情况。

  • 如果可能,将测试用例作为集合注入。这使得添加另一个测试就像添加另一个行一样容易yield return new TestCase(...)。这可以朝探索性测试的方向发展,从而自动创建测试用例:“让我们看看一周前所有秒的代码返回了什么”。


0

您似乎误以为如果所有测试都通过了,那么您就不会有错误。实际上,如果所有测试都通过,则所有已知行为都是正确的。您仍然不知道未知行为是否正确。

希望您在TDD中使用代码覆盖率。为意外的行为添加新的测试。然后,您可以仅对意外行为进行测试,以查看其实际通过代码的路径。知道当前行为后,您可以进行更改以更正它,并且当所有测试再次通过时,您就会知道自己已经正确完成了。

这仍然并不意味着您的代码没有错误,只是比以前更好,而且所有已知行为都正确!

正确使用TDD并不意味着您将编写无bug的代码,这意味着您将编写更少的bug。你说:

要求比较明确

这是否意味着在要求中指定了超过一天但不是昨天的行为?如果您错过书面要求,那是您的错。如果您在编写代码时发现需求不完整,那么对您有好处!如果按照需求工作的每个人都错过了这种情况,那么您并不比其他人差。每个人都会犯错,而犯错越细微,就越容易错过。这里最大的收获是TDD 不能防止所有错误!


0

即使在这样简单的源代码中,犯逻辑错误也是非常容易的。

是。测试驱动的开发不会改变这一点。您仍然可以在实际代码以及测试代码中创建错误。

测试驱动的开发无济于事。

哦,但是做到了!首先,当您注意到该错误时,您已经具有完整的测试框架,并且只需要在测试中修复该错误(以及实际的代码)。其次,您不知道如果一开始没有做TDD的话还会有多少个bug。

同样令人担忧的是,我看不到如何避免此类错误。

你不能 甚至NASA都没有找到避免错误的方法。我们这个小人类当然也不会。

除了在编写代码之前要多考虑之外,

那是谬论。TDD的最大好处之一是您可以用较少的思想进行编码,因为所有这些测试至少都能很好地捕获回归。而且,即使是TDD或特别是TDD技术,也不会一开始就交付无错误的代码(否则您的开发速度就会停滞不前)。

我能想到的唯一方法是为我认为永远不会发生的情况添加大量断言(例如我认为一天前一定是昨天),然后在过去十年中每秒循环浏览一次,以检查任何断言违规,这似乎太复杂了。

这显然与仅编码您现在实际需要的宗旨相冲突。您以为您需要这些案件,事实如此。这是一段非关键的代码。就像您说的那样,没有损坏,只是您想知道它已经30分钟了。

对于关键任务代码,您实际上可以按照您说的做,但对于日常的标准代码则不能。

我如何才能避免一开始就创建此错误?

你不知道 您相信自己的测试可以找到大多数回归分析;您要保持红色-绿色重构周期,在实际编码之前/期间编写测试,并且(重要!)实现使红色-绿色切换所需的最小数量(不多,不少)。最终将获得很好的测试覆盖率,至少是正面的。

如果不是(如果不是)发现错误,则编写测试以重现该错误,并以最少的工作量修复该错误,以使所述测试从红色变为绿色。


-2

您刚刚发现,无论您多么努力,都永远无法捕获代码中所有可能的错误。

因此,这意味着即使尝试捕获所有错误也是徒劳的,所以您应该只使用TDD之类的技术来编写更好的代码,而该代码中的bug较少,而不是0 bug。

相应地,这意味着您应该减少使用这些技术的时间,而将节省下来的时间用于替代方法,以发现贯穿开发网络的错误。

其他选择,例如集成测试或测试团队,系统测试以及记录和分析这些日志。

如果您无法捕获所有错误,则必须制定适当的策略来减轻从您身边溜走的错误的影响。如果仍然要执行此操作,则为此付出更多的努力比尝试(徒劳地)阻止它们首先更有意义。

毕竟,它花费大量时间在编写测试上是毫无意义的,而且在您将产品提供给客户的第一天,它就倒了,特别是如果您当时不知道如何查找和解决该错误的话。验尸和交付后的错误解决是如此重要,并且比大多数人在编写单元测试上花费的精力更多。保留单元测试中的复杂位,不要在先尝试完美。


这是最失败的。That in turn means you should spend less time using these techniques-但是您刚刚说过,它会减少错误,对您有帮助吗?
JᴀʏMᴇᴇ

@JᴀʏMᴇᴇ更为务实的态度是哪种技术最能使您物超所值。我知道有人为自己花了十倍的时间编写测试而不是编写代码感到自豪,并且他们仍然有错误,所以要明智,而不是条条框框,关于测试技术至关重要。而且无论如何都必须使用集成测试,因此要比在单元测试中付出更多的努力。
gbjbaanb
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.