如何用测试驱动开发自信地改进代码

104 阅读7分钟

像许多开发人员一样,当我第一次被介绍到测试驱动开发(TDD)时,我完全不理解。我没有线索(也没有耐心),不知道如何先去写测试。所以,我没有在这方面投入太多精力,而是按照正常的流程写代码,然后再添加测试来覆盖它。这种情况持续了好几年。

我在2017年共同创立了typless,目前负责工程方面的工作。我们一开始就必须迅速行动,所以我们积累了不少技术债务。当时,平台本身是由一个大型的、单体的Django应用程序支持的。调试是非常困难的。代码很难阅读,更难改变。我们解决了一个bug,又有三个bug取代了它。因此,是时候让我再试一下TDD了。我所读到的关于它的一切都表明,它将会有帮助 -- 而且它确实如此。我终于看到了TDD的一个核心副作用:它使代码的修改变得更加容易。

软件是一个有生命的东西

对于任何软件来说,最重要的质量因素之一是它是否容易改变。

"好的设计意味着当我做一个改变时,就好像整个程序是在预料之中的一样。我可以用几个选择的函数调用来解决一个任务,这些函数完美地插入,在代码平静的表面上没有留下丝毫的涟漪。"

软件随着业务需求的变化而变化。不管,是什么推动了这种变化,昨天有效的解决方案今天可能就不灵了。

改变干净的、模块化的、由测试覆盖的代码要容易得多,而这正是TDD倾向于产生的代码类型。

让我们看一个例子。

要求

假设你有一个客户希望你开发一个基本的电话簿,用于添加和显示(按字母顺序)电话号码。

你是否应该创建一个数字列表,以及一些用于追加、排序和打印的辅助函数?或者你应该创建一个类?当然,在这一点上可能并不那么重要。你可以开始写代码来满足当前的要求。这感觉是最自然的事情,对吗?然而,如果这些要求改变了,你必须包括搜索或删除呢?如果你不在一开始就决定一个明智的策略,那么代码可能很快就会变得混乱不堪。

因此,退一步讲,先写一些测试。

先写测试

首先,创建(并激活)一个虚拟环境并安装pytest。

(venv)$ pip install pytest

创建一个新的文件来存放你的测试,名为test_phone_book.py

从一个有两个方法的类开始,addall ,这似乎很合理,对吗?

  • 给出一个PhoneBook 类,有一个records 属性
  • all 方法被调用时
  • 那么所有的数字都应该按升序返回。

这个测试应该是这样的。

class TestPhoneBook:

    def test_all(self):

        phone_book = PhoneBook(
            records=[
                ('John Doe', '03 234 567 890'),
                ('Marry Doe', '01 234 567 890'),
                ('Donald Doe', '02 234 567 890'),
            ]
        )

        previous = ''

        for record in phone_book.all():
            assert record[0] > previous
            previous = record[0]

在这里,我们检查前一个元素是否总是按字母顺序小于当前元素。

运行它。

(venv)$ pytest

当然,测试是失败的。

为了实现,添加一个新的文件,叫做phone_book.py

class PhoneBook:

    def __init__(self, records=None):
        self.records = records or []

    def all(self):
        return sorted(self.records)

把它导入到测试文件中。

from phone_book import PhoneBook


class TestPhoneBook:

    def test_all(self):

        phone_book = PhoneBook(
            records=[
                ('John Doe', '03 234 567 890'),
                ('Marry Doe', '01 234 567 890'),
                ('Donald Doe', '02 234 567 890'),
            ]
        )

        previous = ''

        for record in phone_book.all():
            assert record[0] > previous
            previous = record[0]

再次运行它。

(venv)$ pytest

现在测试通过了。你已经满足了第一个要求之一。

现在为一个add 方法写一个测试,以检查一个新的号码是否在records

  • 给出一个PhoneBook ,有一个add 方法
  • 当一个数字被添加并且all 方法被调用时
  • 那么,新的数字是返回的数字的一部分
from phone_book import PhoneBook


class TestPhoneBook:

    def test_all(self):

        phone_book = PhoneBook(
            records=[
                ('John Doe', '03 234 567 890'),
                ('Marry Doe', '01 234 567 890'),
                ('Donald Doe', '02 234 567 890'),
            ]
        )

        previous = ''

        for record in phone_book.all():
            assert record[0] > previous
            previous = record[0]

    def test_add(self):

        record = ('John Doe', '01 234 567 890')
        phone_book = PhoneBook(
            records=[
                ('Marry Doe', '01 234 567 890'),
                ('Donald Doe', '02 234 567 890'),
            ]
        )
        phone_book.add(record)

        assert record in phone_book.all()

这个测试应该失败,因为add 方法还没有实现。

class PhoneBook:

    def __init__(self, records=None):
        self.records = records or []

    def all(self):
        return sorted(self.records)

    def add(self, record):
        self.records.append(record)

现在,PhoneBook 类满足了上述所有的要求。数字可以被添加,并且所有的数字都可以按字母排序返回。客户很高兴。将代码打包并交付。

新的要求

让我们反思一下第一个实现。

虽然我们用测试来更好地定义应该做什么,但我们可以很容易地写出没有测试的代码。事实上,测试似乎拖慢了进程。

几个星期过去了,你没有听到客户的消息。他们一定很喜欢添加和查看电话号码。做得好。给自己拍拍胸脯,然后给客户发送一个关于未付发票的温和提醒。在你点击发送后不到30秒,你收到一封沮丧的电子邮件,说检索号码的速度相当慢。

发生了什么事?好吧,你在每次调用all 方法时都在对记录进行分类,这将随着时间的推移而变慢。所以,让我们改变代码,在列表初始化和添加新号码时进行排序。

由于我们测试的是接口而不是底层实现,我们可以在不破坏测试的情况下改变代码。

class PhoneBook:

    def __init__(self, records=None):
        self.records = sorted(records or [])

    def add(self, record):
        self.records.append(record)
        self.records = sorted(self.records)

    def all(self):
        return self.records

测试仍然应该通过。

这很好,但是我们实际上可以把事情做得更快,因为这些数字一开始就已经被排序了。

class PhoneBook:

    def __init__(self, records=None):
        self.records = sorted(records or [], key=lambda rec: rec[0])

    def add(self, record):

        index = len(self.records)
        for i in range(len(self.records)):
            if record[0] < self.records[i][0]:
                index = i
                break

        self.records.insert(index, record)

    def all(self):
        return self.records

在这里,我们按顺序插入新的数字,并删除排序。

尽管我们改变了实现方式以满足新的要求,但我们仍然满足了我们最初的要求。我们怎么知道呢?运行测试。

我们能做得更好吗?

我们已经满足了所有的要求。这很好。我们的客户支付了发票。一切都很好。时间过去了。你忘记了这个项目。然后,你突然看到他们在你的收件箱里发来一封邮件,抱怨说现在添加新号码时,应用程序很慢。

你打开你的文本编辑器,开始调查。由于忘记了这个项目,你从测试开始,然后深入到代码中。看一下add 方法,你发现你必须在插入数字之前找到准确的位置,以保持顺序。这两件事--插入和搜索插入索引--的时间复杂度为O(n)。

那么,你如何提高那里的性能呢?

转到谷歌和Stack Overflow。使用你的信息检索技能。经过一个小时左右,你发现插入二叉树的时间复杂度是O(log n)。这就好了。除此以外,元素可以通过顺序内的遍历以排序的方式返回。因此,继续下去,改变你的实现,使用二叉树而不是列表。

二叉树

首先,定义一个节点。

class Node:

    def __init__(self, data):

        self.left = None
        self.right = None
        self.data = data

第二,添加一个插入方法。

class Node:

    def __init__(self, data):

        self.left = None
        self.right = None
        self.data = data

    def insert(self, data):
        # Compare the new value with the parent node
        if self.data:
            if data[0] < self.data[0]:
                if self.left is None:
                    self.left = Node(data)
                else:
                    self.left.insert(data)
            elif data[0] > self.data[0]:
                if self.right is None:
                    self.right = Node(data)
                else:
                    self.right.insert(data)
        else:
            self.data = data

在这里,我们检查在当前节点是否有数据集。

如果没有,则设置数据。

如果数据被设置了,它检查第一个元素是否大于或小于我们需要插入的数据。在此基础上,它添加左边或右边的节点。

最后,添加顺序内的遍历方法。

class Node:

    def __init__(self, data):

        self.left = None
        self.right = None
        self.data = data

    def insert(self, data):
        # Compare the new value with the parent node
        if self.data:
            if data[0] < self.data[0]:
                if self.left is None:
                    self.left = Node(data)
                else:
                    self.left.insert(data)
            elif data[0] > self.data[0]:
                if self.right is None:
                    self.right = Node(data)
                else:
                    self.right.insert(data)
        else:
            self.data = data

    def inorder_traversal(self, root):
        res = []
        if root:
            res = self.inorder_traversal(root.left)
            res.append(root.data)
            res = res + self.inorder_traversal(root.right)
        return res

有了这个,我们就可以把它实现到我们的PhoneBook

class Node:

    def __init__(self, data):

        self.left = None
        self.right = None
        self.data = data

    def insert(self, data):
        # Compare the new value with the parent node
        if self.data:
            if data[0] < self.data[0]:
                if self.left is None:
                    self.left = Node(data)
                else:
                    self.left.insert(data)
            elif data[0] > self.data[0]:
                if self.right is None:
                    self.right = Node(data)
                else:
                    self.right.insert(data)
        else:
            self.data = data

    def inorder_traversal(self, root):
        res = []
        if root:
            res = self.inorder_traversal(root.left)
            res.append(root.data)
            res = res + self.inorder_traversal(root.right)
        return res


class PhoneBook:

    def __init__(self, records=None):
        records = records or []

        if len(records) == 1:
            self.records = Node(records[0])
        elif len(records) > 1:
            self.records = Node(records[0])
            for elm in records[1:]:
                self.records.insert(elm)
        else:
            self.records = Node(None)

    def add(self, record):
        self.records.insert(record)

    def all(self):
        return self.records.inorder_traversal(self.records)

运行测试。他们应该通过。

总结

首先编写测试有助于很好地定义问题,这有助于编写一个更好的解决方案。

你可以用测试来帮助澄清问题,以及混乱的功能范围。

然后,测试检查你的解决方案是否解决了问题。

大多数客户不会关心你是如何解决问题的,只要它能工作;因此,我们把测试的重点放在接口上,而不是实现上。当我们对代码进行修改时,我们不需要改变我们的测试,因为代码解决的问题并没有改变。

随着实现的复杂性增加,可能也需要在这个层面上增加单元测试。我建议把你的时间和注意力集中在实现层面的集成测试上,只有当你发现你的代码在某一特定领域反复损坏时,才增加单元测试。

测试给了我们一定的自由,我们可以改变该实现而不必担心破坏接口。毕竟,它如何工作并不重要,重要的是它能工作。

TDD可以提供所需的信心,让我们自信地重构代码,使其变得更好。更快,更干净,更好的结构 -- 这并不重要。

编码愉快