像许多开发人员一样,当我第一次被介绍到测试驱动开发(TDD)时,我完全不理解。我没有线索(也没有耐心),不知道如何先去写测试。所以,我没有在这方面投入太多精力,而是按照正常的流程写代码,然后再添加测试来覆盖它。这种情况持续了好几年。
我在2017年共同创立了typless,目前负责工程方面的工作。我们一开始就必须迅速行动,所以我们积累了不少技术债务。当时,平台本身是由一个大型的、单体的Django应用程序支持的。调试是非常困难的。代码很难阅读,更难改变。我们解决了一个bug,又有三个bug取代了它。因此,是时候让我再试一下TDD了。我所读到的关于它的一切都表明,它将会有帮助 -- 而且它确实如此。我终于看到了TDD的一个核心副作用:它使代码的修改变得更加容易。
软件是一个有生命的东西
对于任何软件来说,最重要的质量因素之一是它是否容易改变。
"好的设计意味着当我做一个改变时,就好像整个程序是在预料之中的一样。我可以用几个选择的函数调用来解决一个任务,这些函数完美地插入,在代码平静的表面上没有留下丝毫的涟漪。"
软件随着业务需求的变化而变化。不管,是什么推动了这种变化,昨天有效的解决方案今天可能就不灵了。
改变干净的、模块化的、由测试覆盖的代码要容易得多,而这正是TDD倾向于产生的代码类型。
让我们看一个例子。
要求
假设你有一个客户希望你开发一个基本的电话簿,用于添加和显示(按字母顺序)电话号码。
你是否应该创建一个数字列表,以及一些用于追加、排序和打印的辅助函数?或者你应该创建一个类?当然,在这一点上可能并不那么重要。你可以开始写代码来满足当前的要求。这感觉是最自然的事情,对吗?然而,如果这些要求改变了,你必须包括搜索或删除呢?如果你不在一开始就决定一个明智的策略,那么代码可能很快就会变得混乱不堪。
因此,退一步讲,先写一些测试。
先写测试
首先,创建(并激活)一个虚拟环境并安装pytest。
(venv)$ pip install pytest
创建一个新的文件来存放你的测试,名为test_phone_book.py。
从一个有两个方法的类开始,add 和all ,这似乎很合理,对吗?
- 给出一个
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可以提供所需的信心,让我们自信地重构代码,使其变得更好。更快,更干净,更好的结构 -- 这并不重要。
编码愉快