可测试的代码更好吗?


103

我试图养成定期用我的代码编写单元测试的习惯,但是我已经读过第一件事,编写可测试的代码很重要。 这个问题涉及编写可测试代码的SOLID原则,但是我想知道这些设计原则是否有益(或至少无害),而根本不计划编写测试。需要澄清的是-我了解编写测试的重要性;这不是它们有用性的问题。

为了说明我的困惑,在启发这个问题的那篇文章中,作者给出了一个检查当前时间并根据时间返回一些值的函数示例。作者指出这是错误的代码,因为它会产生内部使用的数据(时间),因此很难进行测试。但是,对我而言,将时间作为争论似乎有点过头了。在某个时候需要初始化值,为什么不最接近消耗量呢?另外,在我看来,该方法的目的是基于当前时间返回一些值,通过将其设为参数,您可以暗示可以/应该更改此目的。这个问题以及其他问题使我想知道可测试的代码是否与“更好的”代码同义。

即使在没有测试的情况下,编写可测试的代码是否仍然是一种好习惯


可测试的代码实际上更稳定吗?建议重复。但是,这个问题与代码的“稳定性”有关,但是我在更广泛地询问代码是否由于其他原因(例如可读性,性能,耦合性等)是否优越。


24
该函数的一个特殊属性要求您传递称为幂等的时间每次使用给定的参数值调用 此函数时,它都会产生相同的结果,这不仅使其更具可测试性,而且使其可组合性更高且更易于推理。
罗伯特·哈维

4
您可以定义“更好的代码”吗?您的意思是“可维护的”吗?“无需使用IOC容器即可轻松使用魔术”?
k3b 2015年

7
我猜您从未有过测试失败的情况,因为它使用了实际的系统时间,然后更改了时区偏移量。
安迪

5
它比无法测试的代码更好。
图兰斯·科尔多瓦

14
@RobertHarvey我不认为这是幂等,我会说这是引用透明:如果func(X)回报率"Morning",然后替换出现的所有func(X)"Morning"不会改变程序(即调用。func不会做以外的任何其他返回值)。幂等性暗示func(func(X)) == X(不是类型正确的),或者func(X); func(X);具有与func(X)(但这里没有副作用)相同的副作用
Warbo

Answers:


116

关于单元测试的通用定义,我要说不。我已经看到简单的代码令人费解,因为需要将其扭曲以适合测试框架(例如,接口和IoC到处都使通过接口调用和数据层难以理解,而魔术应该很明显地传递这些层)。考虑到在易于理解的代码与易于单元测试的代码之间进行选择,我每次都会选择可维护的代码。

这并不意味着不进行测试,而是要使工具适合您,而不是相反。还有其他测试方法(但是难以理解的代码始终是错误的代码)。例如,您可以创建粒度较小的单元测试(例如,Martin Fowler认为单元通常是一个类,而不是方法),或者可以使用自动集成测试来编写程序。这样的效果可能不如您的测试框架亮起绿色的勾号,但是我们需要经过测试的代码,而不是过程的游戏化,对吧?

通过在代码之间定义良好的接口,然后编写行使组件公共接口的测试,可以使代码易于维护并且仍然适合单元测试。或者您可以获得更好的测试框架(可以在运行时替换函数以对其进行模拟,而不是要求使用适当的模拟来编译代码)。更好的单元测试框架使您可以在运行时用自己的系统替换系统GetCurrentTime()功能,因此您无需为此而引入人工包装就可以适合测试工具。


3
评论不作进一步讨论;此对话已转移至聊天
世界工程师

2
我认为值得一提的是,我至少了解一种语言,它可以让您执行上一段所描述的内容:带Mock的Python。由于模块导入的工作方式,除了关键字之外,几乎所有其他内容都可以用模拟甚至标准API方法/类/等替换。因此这是可能的,但是可能需要以支持这种灵活性的方式来设计语言。
jpmc26 2015年

5
我认为“可测试的代码”和“适用于测试框架的代码(扭曲)”之间存在区别。我不知道该评论的内容,除非说我同意“扭曲”代码是不好的,而具有良好接口的“可测试”代码是好的。
Bryan Oakley

2
我在文章的评论中表达了我的一些想法(由于这里不允许扩展评论),请检查!需要明确的是:我是上述文章的作者:)
Sergey Kolodiy

我必须同意@BryanOakley。“可测试的代码”表明您的关注点是分开的:可以测试方面(模块)而不会受到其他方面的干扰。我会说这不同于“调整项目支持特定的测试约定”。这类似于设计模式:不应强行使用它们。该规范正确使用设计模式将被视为强有力的代码。同样适用于测试原理。如果使代码“可测试”导致过度扭曲项目的代码,则说明您在做错事。
文斯·埃米格

68

即使在没有测试的情况下,编写可测试的代码是否仍然是一种好习惯?

首先,缺少测试是比代码是否可测试更大的问题。没有单元测试意味着您还没有完成代码/功能。

顺便说一句,我不会说编写可测试的代码很重要-编写灵活的代码很重要。僵化的代码很难测试,因此方法和人们所说的有很多重叠。

所以对我来说,编写代码总是有一系列优先事项:

  1. 使其有效 -如果代码没有执行所需的操作,那么它就一文不值。
  2. 使其可维护 -如果代码不可维护,它将迅速停止工作。
  3. 使其具有灵活性 -如果代码不灵活,则不可避免地会出现业务问题并询问代码是否可以执行XYZ,它将停止工作。
  4. 使其快速 -超出基本可接受的水平,性能只是肉汁。

单元测试有助于代码的可维护性,但仅限于一点。如果使代码的可读性降低或使单元测试更易碎,则适得其反。“可测试代码”通常是灵活的代码,因此很好,但不如功能或可维护性那么重要。对于当前这样的情况,使其具有灵活性是不错的选择,但通过使代码更难于正确使用和变得更加复杂,却损害了可维护性。由于可维护性更重要,因此即使测试性较差,我通常也会偏向于更简单的方法。


4
我喜欢您指出的可测试性和灵活性之间的关系,这使我更容易理解整个问题。灵活性使您的代码可以适应,但是一定会使它变得更抽象,更不直观,但这是为获得好处而付出的宝贵牺牲。
WannabeCoder

3
就是说,我经常看到应该是私有的方法被强制公开或打包,以便单元测试框架能够直接访问它们。远非理想的方法。
jwenting

4
@WannabeCoder当然,只有在最终节省您时间的情况下,才需要增加灵活性。这就是为什么我们不针对接口编写每个方法的原因-在大多数情况下,重写代码比起初引入过多的灵活性要容易得多。YAGNI仍然是一个非常强大的原则-只要确保无论“不需要”是什么,追溯地添加它都不会比提前实施平均给您更多的工作。在我的经验中,灵活性最高的问题是遵循YAGNI 的代码。
罗安2015年

3
“没有单元测试意味着您还没有完成代码/功能”-不正确。“完成的定义”是团队决定的事情。它可能包括也可能不包括某种程度的测试范围。但是,没有任何地方有一个严格的要求,即如果没有测试,就不能“完成”功能。团队可以选择要求测试,也可以不要求。
aroth

3
@Telastyn在10多年的开发中,我从未有一个团队强制执行单元测试框架,只有两个团队甚至只有一个(覆盖面很差)。一个地方需要一份有关如何测试所编写功能的Word文档。而已。也许我很不幸?我不是反单元测试(严重的是,我修改了SQA.SE站点,我是非常专业的单元测试!),但我没有发现它们像您的声明所声称的那样广泛。
corsiKa 2015年

50

是的,这是个好习惯。原因是可测试性不是为了测试。随之而来的是为了清楚和易于理解。

没人在乎测试本身。生活中一个可悲的事实是,我们需要大型的回归测试套件,因为我们没有足够的才能编写出完美的代码,而又不会不断检查自己的立足点。如果可以的话,测试的概念将是未知的,所有这些都不是问题。我当然希望可以。但是经验表明,几乎我们所有人都做不到,因此涵盖我们的代码的测试是一件好事,即使它们浪费了编写业务代码的时间。

有测试如何独立于测试本身改善我们的业务代码?通过强迫我们将功能划分为易于证明正确的单位。这些单元比我们原本会写的单元更容易正确设置。

您的时间示例是一个很好的观点。只要您只有一个返回当前时间的函数,您可能会认为对其进行编程毫无意义。做到这一点有多难?但不可避免地,您的程序将在其他代码中使用此功能,并且您肯定要在不同的条件下(包括在不同的时间)测试代码。因此,能够操纵函数返回的时间是一个好主意-不是因为您不信任单行currentMillis()调用,而是因为您需要在受控情况下验证该调用的调用方。因此,您可以看到,即使代码本身具有可测试性也很有用,但这似乎并没有引起太多关注。


另一个示例是,如果您想将一个项目的代码的一部分拉到其他位置(无论出于何种原因)。功能的不同部分越相互独立,就越容易准确地提取您所需的功能,仅此而已。
valenterry

10
Nobody cares about the tests themselves - 我做。我发现测试比其他任何注释或自述文件更能证明代码的作用。
jcollum

我已经慢慢阅读了一段时间的测试实践(以某种方式谁根本没有进行单元测试),我不得不说,最后一部分是在受控环境下验证调用,以及随附的更灵活的代码它使各种各样的东西都点击到位。谢谢。
plast1k

12

在某个时候需要初始化值,为什么不最接近消耗量呢?

因为您可能需要重用该代码,所以其值与内部生成的值不同。插入将要用作参数的值的能力确保了您可以根据需要随时生成这些值,而不仅仅是“ now”(在调用代码时意为“ now”)。

使代码可测试实际上意味着使代码可以(从一开始)就可以在两种不同的场景(生产和测试)中使用。

基本上,虽然您可以辩称在没有测试的情况下没有动机使代码可测试,但是编写可重用代码具有很大的优势,并且两者都是同义词。

另外,在我看来,该方法的目的是根据当前时间返回一些值,通过将其设为参数,您可以暗示可以/应该更改此目的。

您也可能会争辩说,此方法的目的是基于时间值返回某个值,并且您需要它根据“现在”生成该值。其中一种更为灵活,如果您习惯于选择该变体,那么随着时间的流逝,您的代码重用率将会提高。


10

这样说似乎很愚蠢,但是如果您希望能够测试您的代码,那么可以,编写可测试的代码会更好。你问:

在某个时候需要初始化值,为什么不最接近消耗量呢?

正是因为在您所引用的示例中,它使该代码不可测试。除非您仅在一天的不同时间运行部分测试。或者您重置系统时钟。或其他解决方法。所有这些都比简单地使代码灵活更糟糕。

除了不灵活之外,该小方法还具有两个职责:(1)获取系统时间,然后(2)基于该时间返回一些值。

public static string GetTimeOfDay()
{
    DateTime time = DateTime.Now;
    if (time.Hour >= 0 && time.Hour < 6)
    {
        return "Night";
    }
    if (time.Hour >= 6 && time.Hour < 12)
    {
        return "Morning";
    }
    if (time.Hour >= 12 && time.Hour < 18)
    {
        return "Afternoon";
    }
    return "Evening";
}

进一步分解职责是有意义的,这样,不受控制的部分(DateTime.Now)对其余代码的影响最小。这样做将使上面的代码更简单,并且具有可系统测试的副作用。


1
因此,您必须在清晨进行测试,以确保在需要时得到“夜晚”的结果。那很难。现在假设您想检查日期处理在2016年2月29日是否正确...而且,一些iOS程序员(可能还有其他)都受到初学者的错误的困扰,该错误会在年初之前或之后将事情弄乱,您该怎么办?为此测试。根据经验,我将检查2020
。– gnasher729 2015年

1
@ gnasher729正是我的意思。“使此代码可测试”是一个简单的更改,可以解决许多(测试)问题。如果您不想自动化测试,那么我想代码可以按原样通过。但是,一旦“可测试”,它将更好。
埃里克·金

9

当然,它是有成本的,但是有些开发人员习惯于付钱,以至于他们忘记了那里的成本。以您的示例为例,您现在有两个单元,而不是一个,您需要调用代码来初始化和管理其他依赖项,并且虽然GetTimeOfDay更具可测试性,但您又回到了测试new的同一条船上IDateTimeProvider。只是,如果您具有良好的测试,则收益通常会超过成本。

同样,在某种程度上,编写可测试的代码鼓励您以更可维护的方式构建代码。新的依赖关系管理代码很烦人,因此,如果可能的话,您希望将所有与时间相关的功能分组在一起。这可以帮助缓解和修复错误,例如,当您在时间边界上正确加载页面时,使用前时间渲染某些元素,而使用后时间渲染某些元素。它还可以避免重复的系统调用来获取当前时间,从而加快程序运行速度。

当然,这些体系结构改进高度依赖于注意到并实施这些机会的人。如此密切地关注单位的最大危险之一就是忽视了全局。

许多单元测试框架使您可以在运行时猴子模拟对象的修补程序,这使您获得了可测试性的好处,而不会一团糟。我什至看到它是用C ++完成的。在看起来可测试性成本不值得的情况下研究该功能。


+1-您确实需要改进设计和体系结构以简化编写单元测试的过程。
BЈовић

3
+-重要的是您的代码架构。更轻松的测试只是一个令人愉快的副作用。
gbjbaanb 2015年

8

在可测试性之外,并非所有有助于可测试性的特性都是可取的-例如,我很难为您引用的时间参数提出与测试无关的理由-但从广义上讲,有助于可测试性的特性不论可测试性如何,它也有助于编写良好的代码。

从广义上讲,可测试代码是可延展代码。它是小的,离散的,有凝聚力的块,因此可以调用各个位以进行重用。它的组织和名称都很好(为了能够测试某些功能,您应该更加注意命名;如果您不编写测试,那么一次性函数的名称就没那么重要了)。它往往更具参数性(例如您的时间示例),因此可以从其他上下文中使用,而不是最初的预期目的。它很干燥,因此更简洁,更易于理解。

是。 最好编写可测试的代码,即使不考虑测试也是如此。


不认同它是干的-将GetCurrentTime封装在方法中MyGetCurrentTime非常重复OS调用,除了协助测试工具外没有任何好处。那只是最简单的例子,实际上它们变得更糟。
gbjbaanb

1
“无益地拒绝OS调用”-直到您最终在一个时钟的服务器上运行,在不同时区与aws服务器通话,这破坏了代码,然后您必须遍历所有代码并更新它以使用MyGetCurrentTime,它将返回UTC。; 时钟偏斜,夏令时,以及其他原因导致盲目信任OS调用可能不是一个好主意,或者至少有一个地方可以加入另一个替代品。
安德鲁·希尔

8

如果您希望能够证明您的代码确实有效,那么编写可测试的代码就很重要

我倾向于同意将代码扭曲为令人讨厌的扭曲,以使其适合特定的测试框架的负面想法。

另一方面,这里的每个人在某种程度上都不得不处理那1000行长的魔术函数,这是必须要处理的令人发指的功能,实际上,如果不破坏一个或多个不起眼的,非明显的依赖关系在其他地方(或在其内部的某个地方,这种依赖关系几乎无法可视化),并且根据定义几乎是不可测试的。我认为,测试框架已经过时的观点(并非没有优点)不应被视为免费的许可,可以编写质量差,不可测试的代码。

例如,测试驱动的开发理想确实会促使您编写单一职责的程序,绝对是一件好事。就我个人而言,我说要买入单一责任,单一事实来源,可控制范围(没有freakin的全局变量),并将脆弱的依存关系降至最低,您的代码是可测试的。是否可以通过某些特定的测试框架进行测试?谁知道。但是,也许是测试框架需要将自身调整为良好的代码,而不是相反。

但是,要清楚一点,太聪明,太长和/或相互依赖以致于另一个人不容易理解的代码不是好代码。巧合的是,它也不是易于测试的代码。

因此,接近我的总结,可测试代码是否是更好的代码?

我不知道,也许不是。这里的人有一些有效的观点。

但是我确实相信,更好的代码也倾向于是可测试的代码。

而且,如果您要谈论的是用于认真工作的严肃软件,那么交付未经测试的代码并不是您用雇主或客户的钱所能做的最负责任的事情。

确实,某些代码比其他代码需要更严格的测试,并且假装其他方法有点愚蠢。如果未测试将您与航天飞机上的重要系统连接的菜单系统,您希望成为航天飞机上的宇航员吗?还是没有对用于监测反应堆温度的软件系统进行测试的核电厂员工?另一方面,生成一个简单的只读报告的一些代码是否需要一个装满文档和一千个单元测试的容器卡车?我当然希望不会。只是说...


1
“更好的代码也往往是可测试的代码”,这是关键。使它可测试并不能使其更好。使其变得更好通常可以使其可测试,并且测试通常会为您提供可以用来使其更好的信息,但是仅存在测试并不意味着质量,并且(很少)例外。
anaximander

1
究竟。考虑相反的。如果它是不可测试的代码,则未经测试。如果未经测试,除了现场情况外,您如何知道它是否有效?
pjc50

1
所有测试证明是代码通过了测试。否则,经过单元测试的代码将没有错误,我们知道事实并非如此。
wobbily_col

@anaximander确实如此。如果所有的重点只是检查复选框,那么至少存在测试的禁忌至少有可能导致代码质量较差。“每个功能至少要进行七个单元测试?” “校验。” 但是我确实相信,如果代码是高质量的代码,它将更易于测试。
Craig

1
...但是使官僚机构退出测试可能是完全浪费,并且不会产生有用的信息或可信赖的结果。而不管; 我当然希望有人测试过SSL Heartbleed错误,是吗?还是Apple Goto失败错误?
Craig

5

但是,对我而言,将时间作为争论似乎有点过头了。

没错,通过模拟,您可以使代码可测试,避免浪费时间(双关意图未定义)。示例代码:

def time_of_day():
    return datetime.datetime.utcnow().strftime('%H:%M:%S')

现在,假设您要测试a秒发生的情况。如您所说,要测试这种过大的方法,您必须更改(生产)代码:

def time_of_day(now=None):
    now = now if now is not None else datetime.datetime.utcnow()
    return now.strftime('%H:%M:%S')

如果Python支持leap秒,则测试代码如下所示:

def test_handle_leap_second(self):
    actual = time_of_day(
        now=datetime.datetime(year=2015, month=6, day=30, hour=23, minute=59, second=60)
    expected = '23:59:60'
    self.assertEquals(actual, expected)

您可以对此进行测试,但是代码比必要的更为复杂,并且测试仍然无法可靠地执行大多数生产代码将要使用的代码分支(即,不传递的值now)。您可以通过模拟解决此问题。从原始生产代码开始:

def time_of_day():
    return datetime.datetime.utcnow().strftime('%H:%M:%S')

测试代码:

@unittest.patch('datetime.datetime.utcnow')
def test_handle_leap_second(self, utcnow_mock):
    utcnow_mock.return_value = datetime.datetime(
        year=2015, month=6, day=30, hour=23, minute=59, second=60)
    actual = time_of_day()
    expected = '23:59:60'
    self.assertEquals(actual, expected)

这带来了几个好处:

  • 您正在time_of_day 独立于其依赖项进行测试。
  • 您正在测试生产代码相同的代码路径
  • 生产代码尽可能简单。

顺便提一句,希望将来的模拟框架将使这种事情变得更容易。例如,由于必须将嘲笑的函数引用为字符串,因此当time_of_day开始使用其他时间源时,很难轻易使IDE自动更改它。


仅供参考:您的默认参数错误。它只会被定义一次,因此您的函数将始终返回第一次被评估的时间。
ahruss

4

编写良好的代码的质量在于更改的鲁棒性。也就是说,当需求发生变化时,代码中的变化应成比例。这是一个理想的方法(并非总是可以实现),但是编写可测试的代码有助于使我们更接近这个目标。

为什么它可以帮助我们更紧密联系?在生产中,我们的代码在生产环境中运行,包括与我们所有其他代码的集成和交互。在单元测试中,我们清除了大部分这种环境。我们的代码现在具有很强的更改能力,因为测试是更改。与在生产中使用这些单元相比,我们以不同的方式使用这些单元,它们具有不同的输入(模拟,可能从未真正传入的错误输入等)。

这将为系统中发生更改的那一天准备我们的代码。假设我们的时间计算需要根据时区花费不同的时间。现在,我们可以传递时间,而不必对代码进行任何更改。当我们不想传递时间并想使用当前时间时,我们可以使用默认参数。我们的代码具有很强的更改能力,因为它是可测试的。


4

根据我的经验,在构建程序时做出的最重要,最深远的决定之一就是如何将代码分解为多个单元(其中“ units”是最广义的用法)。如果使用的是基于类的OO语言,则需要将用于实现程序的所有内部机制分解为一些类。然后,您需要将每个类的代码分解为一些方法。在某些语言中,选择是如何将代码分解为功能。或者,如果您执行SOA事务,则需要确定要构建的服务数量以及每个服务要包含的内容。

您选择的故障将对整个过程产生巨大影响。好的选择可以使代码更易于编写,并减少错误(甚至在开始测试和调试之前)。它们使更改和维护变得更加容易。有趣的是,事实证明,一旦找到了良好的故障,通常也比较差的故障更容易测试

为什么会这样呢?我认为我无法理解和解释所有原因。但是一个原因是,良好的故障分析总是意味着要为实施单位选择适当的“粒度”。您不想将太多的功能和太多的逻辑塞入单个类/方法/功能/模块/等中。这使您的代码更易于阅读和编写,但也使测试更容易。

不仅如此。良好的内部设计意味着可以清楚,准确地定义每个实现单元的预期行为(输入/输出/等)。这对于测试很重要。一个好的设计通常意味着每个实现单元对其他单元都有一定程度的依赖性。这使您的代码更易于他人阅读和理解,也使测试更加容易。原因还在继续;也许其他人可以阐明我无法理解的更多原因。

关于您问题中的示例,我认为“良好的代码设计”并不等同于说所有外部依赖项(例如对系统时钟的依赖项)都应始终“注入”。这可能是个好主意,但这是与我在此处描述的问题分开的问题,因此我不会深入研究其优缺点。

顺便说一句,即使您直接调用返回当前时间的系统函数,对文件系统执行操作等,这并不意味着您不能单独对代码进行单元测试。诀窍是使用标准库的特殊版本,该版本允许您伪造系统函数的返回值。我从未见过其他人提到这种技术,但是使用许多语言和开发平台都非常简单。(希望您的语言运行时是开源的,并且易于构建。如果执行代码涉及链接步骤,希望它也很容易控制链接到的库。)

总之,可测试代码不一定是“好”代码,但“好”代码通常是可测试的。


1

如果您遵循SOLID原则,那么您将处于好的一面,尤其是如果将其扩展为KISSDRYYAGNI

对我而言,一个缺失点是方法的复杂性。这是一个简单的getter / setter方法吗?然后仅仅编写测试来满足您的测试框架将是浪费时间。

如果这是一种处理数据的更复杂的方法,并且即使必须更改内部逻辑也要确保它能正常工作,那么对测试方法将是一个很好的选择。很多次,我不得不在几天/几周/几月后更改一段代码,我真的很高兴拥有测试用例。最初开发该方法时,我用测试方法对其进行了测试,并且确定它会起作用。更改后,我的主要测试代码仍然有效。因此,我确定自己所做的更改不会破坏生产中的某些旧代码。

编写测试的另一个方面是向其他开发人员展示如何使用您的方法。开发人员常常会搜索有关如何使用方法以及返回值是什么的示例。

只要我两美分

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.