单元测试如何工作?


23

我试图使我的代码更健壮,并且我一直在阅读有关单元测试的信息,但是我发现很难找到实际有用的用法。例如,维基百科示例

public class TestAdder {
    public void testSum() {
        Adder adder = new AdderImpl();
        assert(adder.add(1, 1) == 2);
        assert(adder.add(1, 2) == 3);
        assert(adder.add(2, 2) == 4);
        assert(adder.add(0, 0) == 0);
        assert(adder.add(-1, -2) == -3);
        assert(adder.add(-1, 1) == 0);
        assert(adder.add(1234, 988) == 2222);
    }
}

我觉得这个测试是完全没有用的,因为您需要手动计算所需的结果并对其进行测试,我觉得这里会有更好的单元测试

assert(adder.add(a, b) == (a+b));

但这只是在测试中对函数本身进行编码。有人可以为我提供一个实际使用单元测试的示例吗?仅供参考,我目前主要在编码“程序”函数,这些函数需要约10个布尔值和几个整数,并以此为基础给出整数结果,我觉得我唯一可以做的单元测试就是在测试。编辑:我还应该在移植(可能设计不当)红宝石代码(我没有做过)的同时精确化这一点


14
How does unit testing work?没人真正知道:)
yannis 2011年

30
“要求您手动计算所需的结果”。那“完全没用”是怎么回事?您还能如何确定答案正确呢?
S.Lott

9
@ S.Lott:这被称为进步,在古代人们使用计算机来计算数字并节省时间,在现代人们花时间来确保计算机可以对数字进行计算:D
Coder

2
@Coder:单元测试的目的不是“减少数字并节省时间”;)
Andres F.

7
@lezebulon:Wikipedia的示例不是很好,但这是特定测试用例的问题,而不是一般的单元测试。该示例中大约有一半的测试数据没有添加任何新内容,从而使其变得多余(我不敢认为该测试的作者将在更复杂的场景下做什么)。一个更有意义的测试将至少在以下情况下划分测试数据:“是否可以添加负数?”,“零中性是吗?”,“可以添加负数和正数吗?”。
Andres F.

Answers:


26

单元测试,如果您正在测试足够小的单元,总是断言是显而易见的。

其原因add(x, y)甚至得到了单元测试的提及,是因为一段时间后会有人进入add,把特别税的逻辑处理代码没有意识到补充的是到处被使用。

单元测试是非常多的关于关联的原理:如果A不B,和B则C,则A不C.“A确实C”是更高级别的测试。例如,考虑以下完全合法的业务代码:

public void LoginUser (string username, string password) {
    var user = db.FetchUser (username);

    if (user.Password != password)
        throw new Exception ("invalid password");

    var roles = db.FetchRoles (user);

    if (! roles.Contains ("member"))
        throw new Exception ("not a member");

    Session["user"] = user;
}

乍一看,这看起来像是一种很棒的单元测试方法,因为它的目的非常明确。但是,它执行约5种不同的操作。每件事物都有一个有效和无效的情况,并且将对单元测试进行巨大的排列。理想情况下,将其进一步细分:

public void LoginUser (string username, string password) {

    var user = _userRepo.FetchValidUser (username, password);

    _rolesRepo.CheckUserForRole (user, "member");

    _localStorage.StoreValue ("user", user);
}

现在我们来谈谈单位。一个单元测试并不关心什么_userRepo被认为是有效的行为FetchValidUser,只有它被调用。您可以使用另一项测试来确保有效用户的确切身份。同样,对于CheckUserForRole...,您已经使测试与知道角色结构的外观脱钩了。您还可以使整个程序脱离严格的约束Session。我想这里所有缺少的部分看起来像:

class UserRepository : IUserRepository
{
    public User FetchValidUser (string username, string password)
    {
        var user = db.FetchUser (username);

        if (user.Password != password)
            throw new Exception ("invalid password");

        return user;
    }
}

class RoleRepository : IRoleRepository
{
    public void CheckUserForRole (User user, string role)
    {
        var roles = db.FetchRoles (user);

        if (! roles.Contains (role))
            throw new Exception ("not a member");
    }
}

class SessionStorage : ILocalStorage
{
    public void StoreValue (string key, object value)
    {
        Session[key] = value;
    }
}

通过重构,您可以立即完成几件事。该程序更支持撕裂底层结构(您可以放弃NoSQL的数据库层),或者一旦意识到Session线程安全性或其他原因无缝添加锁定。现在,您还为自己编写了非常简单的测试来编写这三个依赖项。

希望这可以帮助 :)


13

我目前主要在编码“过程”函数,这些函数需要约10个布尔值和几个整数,并以此为基础给出整数结果,我觉得我唯一可以做的单元测试就是对测试中的算法进行重新编码

我非常确定您的每个过程函数都是确定性的,因此对于每个给定的输入值集,它都会返回特定的int结果。理想情况下,您将具有一个功能规范,从中可以确定对于某些输入值集应收到什么结果。缺少此功能,您可以针对某些输入值集运行ruby代码(假定正常工作),并记录结果。然后,您需要将结果硬编码到测试中。该测试应该证明您的代码确实产生了已知正确的结果


+1用于运行现有代码并记录结果。在这种情况下,这可能是实用的方法。
MarkJ 2011年

12

由于似乎没有其他人提供了实际示例:

    public void testRoman() {
        RomanNumeral numeral = new RomanNumeral();
        assert( numeral.toRoman(1) == "I" )
        assert( numeral.toRoman(4) == "IV" )
        assert( numeral.toRoman(5) == "V" )
        assert( numeral.toRoman(9) == "IX" )
        assert( numeral.toRoman(10) == "X" )
    }
    public void testSqrt() {
        assert( sqrt(4) == 2 )
        assert( sqrt(9) == 3 )
    }

你说:

我觉得此测试完全没有用,因为您需要手动计算所需结果并进行测试

但是,要点是,在进行手动计算和进行编码时,您犯错误的可能性(或者至少更有可能注意到错误)的可能性要小得多。

您在十进制到罗马转换代码中出错的可能性有多大?很有可能。手工将十进制转换为罗马数字时,您出错的可能性有多大?不太可能。这就是为什么我们要对手动计算进行测试。

实现平方根函数时,您出错的可能性有多大?很有可能。手工计算平方根时,您出错的可能性有多大?可能更有可能。但是使用sqrt,您可以使用计算器来获取答案。

仅供参考,我目前主要在编码“程序”函数,这些函数需要约10个布尔值和几个整数,并以此为基础给出整数结果,我觉得我唯一可以做的单元测试就是在测试

因此,我将推测这里发生的事情。您的函数有点复杂,因此很难从输入中找出输出应该是什么。为此,您必须手动(在脑海中)执行该函数以找出输出是什么。可以理解,这似乎毫无用处且容易出错。

关键是您要找到正确的输出。但是您必须对照已知正确的测试这些输出。编写自己的算法来计算该值是没有好处的,因为这很可能是不正确的。在这种情况下,手动计算值太困难了。

我将返回到ruby代码,并使用各种参数执行这些原始功能。我将使用ruby代码的结果,并将其放入单元测试中。这样,您就不必进行手动计算。但是您正在针对原始代码进行测试。那应该有助于保持结果不变,但是如果原始版本中有错误,那么它将无济于事。基本上,您可以将原始代码像sqrt示例中的计算器一样对待。

如果您显示了要移植的实际代码,我们可以提供有关如何解决问题的更详细的反馈。


而且,如果Ruby代码中确实存在一个您不知道的错误,而该错误不在您的新代码中,并且您的代码未能基于Ruby输出进行单元测试,那么对其失败原因的调查最终将为您辩护并导致发现潜在的Ruby错误。所以这很酷。
亚当·维尔

11

我觉得我唯一可以做的单元测试就是对测试中的算法重新编码

对于这样一个简单的类,您几乎是正确的。

尝试使用更复杂的计算器。就像保龄球得分计算器。

当您具有要测试的不同场景的更复杂的“业务”规则时,单元测试的价值更容易体现。

我并不是说您不应该测试铣床计算器的运行(您的计算器帐户是否发出无法表示的1/3之类的值?它被零除有什么作用?),但是您会看到如果您用更多的分支来测试某些东西以获得覆盖,则价值会更加清晰。


4
+1表示注意到它对于复杂功能变得更加有用。如果您决定将adder.add()扩展为浮点值怎么办?矩阵?Leger帐户值?
joshin4colours 2011年

6

尽管宗教狂热大约有100%的代码覆盖率,但我还是要说并不是每个方法都应该进行单元测试。仅包含有意义的业务逻辑的功能。简单地增加数字的功能毫无意义。

我目前正在编写大多数“过程”函数,这些函数需要约10个布尔值和几个整数,并根据此结果得出一个整数结果

那里确实是您的真正问题。如果单元测试看起来异常困难或毫无意义,则可能是由于设计缺陷。如果面向对象更多,那么您的方法签名就不会那么庞大,并且可以测试的输入也将更少。

我不需要进入OO优于过程编程...


在这种情况下,该方法的“签名”不是很大,我只是从作为类成员的std :: vector <bool>中读取。我还应该指出我正在移植(可能是设计错误的)红宝石代码(我没有制作过)
lezebulon 2011年

2
@lezebulon不管单个方法接受的输入是否很多,那么该方法就做得太多了
maple_shaft

3

在我看来,单元测试在您的小加法器类中甚至很有用:不要考虑“重新编码”算法,而应该将其视为黑盒,因为它唯一了解的是功能行为(如果您熟悉的话)使用快速乘法,您会比使用“ a * b”)和您的公共接口更快,更复杂地进行尝试。比您应该问自己:“到底怎么了?”

在大多数情况下,它发生在边界(我看到您已经测试过添加这些模式++,-,+-,00-时间以-+,0 +,0-,+ 0,-0来完成)。想一想当在MAX_INT和MIN_INT处进行加法或减法(加负数;)时会发生什么。或尝试确保您的测试看起来与在零附近发生的事情完全一样。

总而言之,对于简单的类,秘密是非常简单的(也许对于更复杂的秘密也是如此;)):考虑类的合同(请参阅按合同设计),然后对它们进行测试。您越了解自己的inv,pre和post的“测试者”。

您的测试类的提示:尝试在一个方法中只编写一个断言。给方法命名(例如“ testAddingToMaxInt”,“ testAddingTwoNegatives”),以便在代码更改后测试失败时获得最佳反馈。


2

而不是测试手动计算的返回值,或者在测试中复制逻辑以计算期望的返回值,而不是测试期望属性的返回值。

例如,如果要测试反转矩阵的方法,而又不想手动反转输入值,则应将返回值乘以输入并检查是否获得了单位矩阵。

要将这种方法应用于您的方法,您必须考虑其目的和语义,以识别返回值相对于输入将具有哪些属性。


2

单元测试是一种生产力工具。您会收到一个变更请求,将其实施,然后通过单元测试区运行您的代码。这种自动测试可以节省时间。

I feel that this test is totally useless, because you are required to manually compute the wanted result and test it, I feel like a better unit test here would be

有争议的点。示例中的测试仅显示了如何实例化一个类并通过一系列测试来运行它。专注于单个实现的细节是缺少树木的森林。

Can someone provide me with an example where unit testing is actually useful?

您有一个Employee实体。该实体包含名称和地址。客户决定添加一个ReportsTo字段。

void TestBusinessLayer()
{
   int employeeID = 1234
   Employee employee = Employee.GetEmployee(employeeID)
   BusinessLayer bl = new BusinessLayer()
   Assert.isTrue(bl.Add(employee))//assume Add returns true on pass
}

这是BL与员工一起工作的基本测试。该代码将通过/失败您刚刚进行的架构更改。请记住,断言并不是测试的唯一内容。通过代码运行还可以确保不会冒出异常。

随着时间的流逝,适当地进行测试可以更轻松地进行总体更改。该代码会自动测试是否有异常,并根据您提出的断言进行测试。这样可以避免质量检查小组进行手动测试所产生的大量开销。尽管UI仍然很难实现自动化,但假设您正确使用访问修饰符,其他各层通常非常容易。

I feel like the only unit testing I could do would be to simply re-code the algorithm in the test.

甚至过程逻辑也很容易封装在函数内部。封装,实例化并传入要测试的int /原语(或模拟对象)。不要将代码复制粘贴到单元测试中。击败了DRY。由于您不是在测试代码,而是代码的副本,因此它也完全无法通过测试。如果应该测试的代码发生更改,则测试仍然可以通过!


<pedantry>“色域”,而不是“ gambit”。</
pedantry

@chao lol每天都学点新东西。
P.Brian.Mackey 2012年

2

以您的示例(经过一些重构)为例,

assert(a + b, math.add(a, b));

无助于:

  • 了解math.add内部行为如何,
  • 知道边缘情况会发生什么。

几乎等于说:

  • 如果您想知道该方法的作用,请亲自查看数百行源代码(因为是的,math.add 可以包含数百个LOC;请参见下文)。
  • 我不介意该方法是否正常工作。如果期望值和实际值都与我真正期望的值不同,则可以

这也意味着您不必添加以下测试:

assert(3, math.add(1, 2));
assert(4, math.add(2, 2));

它们无济于事,或者至少在您提出第一个断言之后,第二个就没什么用。

相反,该怎么办:

const numeric Pi = 3.1415926535897932384626433832795;
const numeric Expected = 4.1415926535897932384626433832795;
assert(Expected, math.add(Pi, 1),
    "Adding an integer to a long numeric doesn't give a long numeric result.");
assert(Expected, math.add(1, Pi),
    "Adding a long numeric to an integer doesn't give a long numeric result.");

这是不言而喻的,对您和以后将维护源代码的人都非常有用。想象一下,这个人对做了一点修改,math.add以简化代码并优化性能,然后看到如下测试结果:

Test TestNumeric() failed on assertion 2, line 5: Adding a long numeric to an
integer doesn't give a long numeric result.

Expected value: 4.1415926535897932384626433832795
Actual value: 4

该人员将立即了解到,新修改的方法取决于参数的顺序:如果第一个参数是整数,第二个参数是长整数,则结果将是整数,而预期是长整数。

以同样的方式,4.141592在第一个断言中获取的实际值是不言自明的:您知道该方法具有很高的精度,但是实际上,它失败了。

出于相同的原因,在某些语言中,以下两个断言可能有意义:

// We don't expect a concatenation. `math` library is not intended for this.
assert(0, math.add("Hello", "World"));

// We expect the method to convert every string as if it was a decimal.
assert(5, math.add("0x2F", 5));

另外,关于:

assert(numeric.Infinity, math.add(numeric.Infinity, 1));

也是不言自明的:您希望您的方法能够正确处理无穷大。去超越无限或抛出异常不是预期的行为。

或者,也许取决于您的语言,这会更有意义吗?

/**
 * Ensures that when adding numbers which exceed the maximum value, the method
 * fails with OverflowException, instead of restarting at numeric.Minimum + 1.
 */
TestOverflow()
{
    UnitTest.ExpectException(ofType(OverflowException));

    numeric result = math.add(numeric.Maximum, 1));

    UnitTest.Fail("The tested code succeeded, while an OverflowException was
        expected.");
}

1

对于像add这样的非常简单的功能,可以认为测试是不必要的,但是随着您的功能变得越来越复杂,为什么必须进行测试就变得越来越明显。

考虑一下您在编程时的工作(不进行单元测试)。通常,您编写一些代码,运行它,看看它是否有效,然后继续进行下一步,对吧?当您编写更多的代码时,尤其是在非常大的系统/ GUI /网站中,您会发现您必须做越来越多的“运行并查看其是否有效”。您必须尝试并尝试。然后,您进行一些更改,然后必须再次尝试相同的操作。很明显,您可以通过编写单元测试来节省时间,该单元测试将使整个“运行并查看其是否有效”部分自动化。

随着项目变得越来越大,您必须“运行并查看其是否有效”的事情变得不切实际。因此,您最终仅运行并尝试了GUI /项目的一些主要组件,然后希望其他一切都很好。这是灾难的秘诀。当然,作为一个人,如果实际上有数百人使用GUI,则您无法反复测试客户端可能使用的每种情况。如果您有单元测试,则可以在交付稳定版本之前甚至在提交到中央存储库之前(如果您的工作场所使用一个)就可以简单地运行测试。而且,如果以后发现任何错误,您只需添加一个单元测试即可在将来进行检查。


1

编写单元测试的好处之一是,它可以通过迫使您考虑边缘情况来帮助您编写更健壮的代码。如何测试某些边缘情况,例如整数溢出,十进制截断或参数的空值处理?


0

也许您假设add()是使用ADD指令实现的。如果某些初级程序员或硬件工程师使用ANDS / ORS / XORS重新实现了add()函数(位反转和移位),则可能需要针对ADD指令进行单元测试。

通常,如果您用随机数或输出生成器替换add()或被测单元的内胆,您如何知道某些东西坏了?将这些知识编码在单元测试中。如果没有人知道它是否损坏,那么只需为rand()检入一些代码然后回家,就可以完成工作。


0

我可能会在所有答复中都错过了它,但是对我来说,单元测试背后的主要驱动力不是证明今天一种方法的正确性,而是它证明了无论何时您更改,该方法的持续正确性

采取简单的功能,例如返回某个集合中的项目数。今天,当您的列表基于一个众所周知的内部数据结构时,您可能会认为这种方法非常痛苦,您不需要对其进行测试。然后,在几个月或几年的时间里,您(或其他人)决定替换内部列表结构。您仍然需要知道getCount()返回正确的值。

这就是您的单元测试真正发挥作用的地方。

您可以更改内部实现您的代码,但该代码的任何消费者,结果仍然相同。

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.