我喜欢重构代码和思考软件设计。作为我日常工作的一部分,这是我谈论、写博客并喜欢做的事情。任何重构的核心部分是知道您没有破坏任何功能,而对此充满信心的最佳方法是通过一组您可以运行的测试来确保您没有破坏任何东西。
但是如果没有测试,你会怎么做?你永远不应该在没有测试的情况下进行重构,但是你如何确保你有好的测试呢?今天我们将看一些我们偶然发现并想要重构的代码,以及我们如何首先采取添加测试的步骤。
下面的示例代码取自 Katrina Owen 的精彩演讲,题为“治疗性重构”,我强烈推荐。这是一个很好的例子,我已经将它改编成 JavaScript 用于这篇博文。
代码:为书籍生成文件名
这是我们被要求使用的代码。我们在一家出版商工作,此代码为给定书籍(或目标)的封面生成文件名。我们需要在这段代码中添加一些特性,但现在我们只需要理解它。随意花点时间阅读一下。
class Publisher {
static generateFilename(target) {
let fileName = `${target.publishOn.getFullYear()}-${target.publishOn.getMonth() +
1}`
fileName += target.categoryPrefix
fileName += target.kind.replace('_', '')
fileName += String(target.id)
fileName += Array.from({ length: 5 }, _ =>
Math.floor(Math.random() * 10);
).join('')
let truncatedTitle = target.title.replace(/[^\[a-z\]]/gi, '').toLowerCase()
let truncateTo = truncatedTitle.length > 9 ? 9 : truncatedTitle.length
fileName += `-${truncatedTitle.slice(0, truncateTo)}`
fileName += '.jpg'
return fileName
}
}
这里发生了很多事情!看起来我们是根据出版日期、类别、书籍类型、一些随机数字以及我们在需要时截断的标题生成名称。很明显,这段代码可以引起一些注意;这不是最容易阅读或遵循的。第一步是尝试澄清我们拥有的所有行为,以便我们可以对其进行测试。但是现在我们没有一个测试!所以让我们尝试写一个。
编写我们的第一个测试
我之前谈过描述性测试,但在这种情况下,我们甚至不知道我们在测试什么!在这种情况下,我喜欢从非常基础的开始,然后向自己证明这段代码甚至可以工作:
describe('Publisher', () => {
it('does a thing', () => {})
})
我们知道这generateFilename
需要一个目标,所以我们可以尽可能地尝试制作一个假目标。如果我们把它搞砸了,我们会从测试中得到错误,告诉我们我们错过了什么。
describe('Publisher', () => {
it('does a thing', () => {
const fileName = Publisher.generateFilename({
publishOn: new Date(2021, 3, 1),
categoryPrefix: 'tech',
kind: 'software-design',
id: 123,
title: 'Software Design',
})
expect(fileName).toEqual('???')
})
})
但是断言呢?我们不知道输出会是什么。在这种情况下,我喜欢写一个明显错误的输出并观察测试失败。失败将向我们展示我们的实际期望!
Expected: "???"
Received: "2021-4techsoftware-design12358113-softwared.jpg"
好的,所以让我们把这个名字放到我们的断言中,希望测试应该通过。不幸的是:
Expected: "2021-4techsoftware-design12358113-softwared.jpg"
Received: "2021-4techsoftware-design12369199-softwared.jpg"
像这样的随机数可能会使测试脱轨,但幸运的是有一种解决方法。我们可以期望我们的输出匹配一个正则表达式,在这个正则表达式中,我们对所有内容进行硬编码,除了随机的 5 位数字:
expect(fileName).toMatch(/2021-4techsoftware-design123[0-9]{5}-softwared\.jpg/)
现在我们过去了!呸。虽然这感觉有点困难,但我们现在处于一个很好的位置。我们至少有一个测试,现在我们准备找出我们需要的另一组测试。
在代码中查找分支
当您尝试编写清除所有可能的边缘情况的测试用例时,您应该在代码中寻找条件。这些实际上是您要测试的所有分支。每个都if
变成两个测试用例:一个测试正面,一个测试负面。
ageRange
如果这本书是个人的,我们点击的第一个条件添加到文件名:
fileName += target.isPersonal ? target.ageRange : ''
我们的第一个测试用例没有包含这个,所以让我们确保我们测试这个并在断言中包含年龄范围:
it('includes the age range if the book is personal', () => {
const fileName = Publisher.generateFilename({
publishOn: new Date(2021, 3, 1),
ageRange: 10,
isPersonal: true,
categoryPrefix: 'kids',
kind: 'childrens-book',
id: 123,
title: 'Five go on an Adventure',
})
expect(fileName).toMatch(
/2021-4kidschildrens-book123[0-9]{5}10-fivegoona\.jpg/
)
})
下一个条件是截断:
let truncatedTitle = target.title.replace(/[^\[a-z\]]/gi, '').toLowerCase()
let truncateTo = truncatedTitle.length > 9 ? 9 : truncatedTitle.length
fileName += `-${truncatedTitle.slice(0, truncateTo)}`
我们的第一个测试用例使用了超过 9 个字符的标题“软件设计”,因此已经在测试这种行为。因此,让我们添加另一个使用非常短的标题并确认它不会被截断的测试用例。
it('does not truncate titles less than 9 characters long', () => {
const fileName = Publisher.generateFilename({
publishOn: new Date(2021, 3, 1),
categoryPrefix: 'bio',
kind: 'biography',
id: 123,
title: 'Jack',
})
expect(fileName).toMatch(/2021-4biobiography123[0-9]{5}-jack\.jpg/)
})
这里还有其他行为有待测试——正则表达式特别有趣——但现在我们只关注分支。
这些是我们遇到的所有条件,所以让我们看看我们的测试在哪里:
describe('Publisher', () => {
it('does a thing', () => {})
it('includes the age range if the book is personal', () => {})
it('does not truncate titles less than 9 characters long', () => {});
我们现在可以重命名'it does a thing'
测试;该测试实际上测试截断是否适用于长度超过 9 个字符的标题。请注意,我们当时不知道,但我们现在知道了。让我们相应地更新它的描述:
it('truncates titles greater than 9 characters long', () => {
现在我们已经通过了三个测试并处理了我们的条件,让我们看看其他边缘情况或我们想要测试的特别有趣的行为。
寻找其他极端情况和行为变化
现在我们正在扫描代码寻找我们想要测试的东西。我们在第 1 行找到了一个很好的候选;包括输出中的年份和月份。我们现在必须考虑的是这是否值得为其编写特定的测试,或者当前的测试套件是否足够?这是一些个人喜好的来源;我认为每个测试都会测试这个日期逻辑,因为它不以其他任何条件为条件,所以我们可以保留它。
fileName += target.kind.replace('_', '')
这是让我想写一个测试的第一行。如果kind
有下划线,它将被删除。我们在这里也遇到了一个奇怪的问题:如果有多个下划线怎么办?此代码只会替换第一个实例,而不是全部。这将是我稍后会记下的那种事情;检查是否需要这样做或实现中的错误。当你为你不理解的代码编写测试时,首先不要修复任何东西。获得良好的测试覆盖率并记下您在此过程中发现的任何潜在错误。
在这里,我确保我编写了一个kind
带有下划线的测试,并断言它已在输出中被删除。然后我还编写了一个测试来确认是否有多个下划线,只有第一个被删除,因为我想记录这种行为,即使我们最终确定它是一个错误(此时我们可以更新测试)。
it('removes the first underscore from the kind', () => {
const fileName = Publisher.generateFilename({
publishOn: new Date(2021, 3, 1),
categoryPrefix: 'bio',
kind: 'self_biography',
id: 123,
title: 'Title',
})
expect(fileName).toMatch(/2021-4bioselfbiography123[0-9]{5}-title\.jpg/)
})
it('does not remove any subsequent underscores from the kind', () => {
const fileName = Publisher.generateFilename({
publishOn: new Date(2021, 3, 1),
categoryPrefix: 'bio',
kind: 'self_bio_graphy',
id: 123,
title: 'Title',
})
expect(fileName).toMatch(/2021-4bioselfbio_graphy123[0-9]{5}-title\.jpg/)
})
接下来让我印象深刻的是这一行:
let truncatedTitle = target.title.replace(/[^\[a-z\]]/gi, '').toLowerCase()
或者具体来说,这个正则表达式:
[^\[a-z\]]/gi
这个正则表达式(我们认为)应该匹配任何不是字母的东西。在代码中,任何匹配的内容都会被任何内容替换,我们注意到这/gi
使得它成为全局的(每个匹配项都将被替换)并且不区分大小写。但这里奇怪的是内部大括号被转义了:
\[a-z\]
所以这个正则表达式看起来也会在标题中留下任何大括号。这似乎不太可能,因此我们将其视为一个潜在的错误,但鉴于它是编码行为,让我们编写一个测试来证明大括号确实存在。我们还将编写另一个测试,该测试具有一个充满特殊字符的时髦标题,以确保它们被删除:
it('does not remove braces or letters from the book title', () => {
const fileName = Publisher.generateFilename({
publishOn: new Date(2021, 3, 1),
categoryPrefix: 'bio',
kind: 'biography',
id: 123,
title: 'My [Title]',
})
expect(fileName).toMatch(/2021-4biobiography123[0-9]{5}-my\[title\]\.jpg/)
})
it('removes other special characters from the book title', () => {
const fileName = Publisher.generateFilename({
publishOn: new Date(2021, 3, 1),
categoryPrefix: 'bio',
kind: 'biography',
id: 123,
title: '(My) <title$>',
})
expect(fileName).toMatch(/2021-4biobiography123[0-9]{5}-mytitle\.jpg/)
})
这是我们认为值得测试的行为的最后一部分。
结论
有了它,我们现在有 7 个测试来描述和指定generateFilename
为我们提供的功能:
it('truncates titles greater than 9 characters long', () => {})
it('includes the age range if the book is personal', () => {})
it('does not truncate titles less than 9 characters long', () => {})
it('removes the first underscore from the kind', () => {})
it('does not remove any subsequent underscores from the kind', () => {})
it('does not remove braces or letters from the book title', () => {})
it('removes other special characters from the book title', () => {})
我们还认为我们可能在此过程中发现了一些错误:
- 是否故意只
_
从 中删除第kind
一个target
? - 同样,大括号是否应该作为标题输出的一部分包含在内?或者在定义正则表达式时这是一个错字?
尽管随时修复这些“错误”很诱人,但请记住,这项工作的全部目的是澄清代码的行为以进行改进。抵制不断改进的冲动;一旦你有一个完整的测试套件,就更容易决定去哪里,如果你开始做出改变,你就有一套很好的测试来确保你不会破坏任何功能。