测试驱动开发

测试驱动开发概览。


测试驱动开发(TDD)是一种编程风格:先编写测试,再用测试来指导程序设计的代码实现。

在开始写代码之前,先编写一个或多个测试(尤其是单元测试)。 这些测试旨在覆盖程序行为的某一个方面,可能只针对单个函数或方法。 编写测试,是把程序需求和整体架构转化为具体实现层面设计的一种方式。 运行测试,它们应该失败,因为代码还没有实现。 然后实现代码,再次运行测试。 如果测试通过,那么要么该行为的实现已经完成,要么可能还需要编写更多测试。 如果测试没有通过,就调试代码,然后再次运行测试。 测试与编码的这个循环会不断重复,直到所有必要的测试都通过;此时,程序该方面行为的实现就完成了……暂时如此。

重构

重构是指为了改进设计而重写代码。 它不只是为了修 bug 而重写代码。 有时,为了让代码通过测试而修改代码,也会被称作“重构”。 虽然为了让测试通过而修改代码_可能_也包含改进设计,但单纯调试并不一定是在改进代码的_设计_,因此也不一定是在_重构_。

下面是一个只调试、不重构的例子:

# A function intended to return x added to y.
# x and y are bad parameter names, but we ignore that for now.
def add(x, y):
    # used multiply operator by mistake. It fails the tests.
    return x * y

# Function corrected. It passes the tests. It has been debugged, but not refactored.
def add(x, y):
    return x + y

下面是一个先重构、再调试的例子:


# Function name and parameter names are modified to something more meaningful. This is refactoring.
def lot_inventory(old_cars, new_cars):
    # Introduced multiply operator by mistake. It fails the tests. This is why we test.
    return old_cars * new_cars

# Function corrected. It passes the tests. This is debugging.
def lot_inventory(old_cars, new_cars):
    return old_cars + new_cars

TDD 与 Exercism 上的 Python

Exercism 的 Python 轨道在练习中采用 TDD 方法。 单元测试已经写好。 学生可以查看这些测试,从而更详细地了解解答需要满足哪些条件才能通过。 可能会向学生提供解答的桩代码。

排查 Exercism 在线编辑器中测试失败的问题

当 Python 解答有一个或多个测试失败时,对应的任务不会显示绿色背景。 第一个失败的任务区域会自动展开,其标题大致如下

Task 1 Extract coordinates -

点击减号可以折叠该任务,这样我们就能查看其他任务;不过现在我们先专注于这个任务。

其下方是一个已展开的测试区域,大致如下

       Test 1                               ⌄
FAILED TisburyTreasure > get coordinate

其中Tisbury Treasure表示练习,get_coordinate表示失败的函数或方法。

Test 1通常是一种模板,其中包含用于设置测试的代码段。 它不包含关于具体哪个测试失败的信息。 它会在接近底部的地方显示

One or more variations of this test failed. Details can be found under each [variant#].

点击⌄可以折叠该测试。

下方是一个已折叠的测试,大致如下:

       Test 2                                                    >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
       ("Scrimshaw Whale's Tooth", '2A'), result='2A')

它的显示效果会随右侧窗格宽度的不同而变化。 点击>可以展开该测试。 输入数据和预期结果数据很可能会显示在代码段中。 这些数据可能涵盖该任务的所有测试。 在底部的Test Failure部分,会给出该测试失败的具体原因。 它可能类似这样:

AssertionError: ['2A'] != '2A'

在这个例子中,它表示返回的值['2A']与期望值'2A'不相等。

查看get_coordinate的代码,可以看到它是这样实现的

def get_coordinate(record):
    return [record[1]]

如果去掉列表的方括号(例如return record[1]),再运行测试,任务 1 的测试就会通过。

如果还有一个或多个任务持续失败,就对每一个重复上述过程,直到所有测试都通过。

有时,预期数据和返回数据太大,无法全部放进Test Failure部分。 它可能类似这样:

AssertionError: '("Sc[67 chars]\')\n\n(\'Brass Spyglass\', \'Abandoned Lighth[952 chars]')\n' != '("Sc[67 chars]\')\n(\'Brass Spyglass\', \'Abandoned Lighthou[928 chars]')\n'
Diff is 970 characters long. Set self.maxDiff to None to see it.

数据可能仍然足够看出问题所在。 在上面的例子中,返回了两个换行符(例如\n\n(\'Brass Spyglass),而预期只有一个(例如\n(\'Brass Spyglass)。

所有测试都通过之后

恭喜! 所有测试都通过了。 接下来做什么? 可以立即发布解答。 或者,既然代码已经能正常工作,如果你想以任何理由重构它,都可以修改代码并提交另一个版本。 如果你觉得代码可以更好,却不知道从何下手,可以为该解答申请导师指导。 如果有导师可以指导,他可能会联系你,提出解答的其他思路。 发布解答时,你可以允许评论,其他学生也可以借此机会发表评论或提问。

性能优化

虽然“过早优化是万恶之源”(这句话常被归到 Tony Hoare 和 Donald Knuth 名下),但总会有这样的时候:解答明明可以正常工作,却仍希望提升它的性能。 其中一种情况是:解答通过了部分测试,却在其他测试上超时。 知道一段代码究竟花了多少时间,会很有帮助。 timeit 模块可以测量代码的执行时间,精度可达极短的时长。 timeit函数最多可以接受五个实参:timeit.timeit(stmt='pass', setup='pass', timer=<default timer>, number=1000000, globals=None)。 stmt形参定义要运行并计时的实际代码。 number形参决定stmt代码要运行多少次。 setup形参定义只运行一次的代码,用于为运行stmt代码做准备。 setup代码运行的时间也计入总时间。 stmt代码运行的迭代次数越多,每次迭代中setup时间所占的比例就越小。 timer形参允许传入一个与默认Timer不同的计时器。 timer形参的默认实参是perf_counter,对大多数情况来说已经足够。 number形参的默认实参是1_000_000。 globals形参指定执行代码所用的命名空间。 globals形参的默认实参是None。

下面是一个使用timeit的例子,用来查看判断一个句子是否包含所有英语元音需要多长时间:


import timeit

# run one million times
loops = 1_000_000

# first positional argument is for stmt
# second positional argument is for setup
# third (named) argument is for number
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    return all(letter in sentence.casefold() for letter in VOWELS)
""", number=loops) / loops)

运行一百万次,平均每次调用耗时4.965089999896008e-07秒(约合每次调用497纳秒)。

下面这个例子用来看看把casefold调用移出列表推导式是否能节省时间:


import timeit

loops = 1_000_000

print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    sentence = sentence.casefold()
    return all(letter in sentence for letter in VOWELS)
""", number=loops) / loops)

运行一百万次,平均每次调用耗时4.923898000270128e-07秒(约合每次调用492纳秒)。 所以,把casefold移出列表推导式,每次调用大约节省了5纳秒,一百万次调用总共大约节省5毫秒。

cProfile 也可以用来对代码做性能分析;不过它的粒度没那么细,最小只能到毫秒级。