zoukankan      html  css  js  c++  java
  • TDD中的单元测试写多少才够?

    测试驱动开发(TDD)已经是耳熟能详的名词,既然是测试驱动,那么测试用例代码就要写在开发代码的前面。但是如何写测试用例?写多少测试用例才够?我想大家在实际的操作过程都会产生这样的疑问。

    3月15日,我参加了thoughtworks组织的“结对编程和TDD Openworkshop”活动,聆听了tw的资深咨询专家仝(tong2)键的精彩讲解,并在讲师的带领下实际参与了一次TDD和结对编程的过程。活动中,仝键老师对到底写多少测试用例才够的问题,给出了下面一个解释:

    我们写单元测试,有一个重要的原因是用来防止自己犯低级错误的。我们不能把写实现代码的人当作我们的敌人,一定要把全部情况都测到,以防止他们在里面故意留下各种隐蔽的陷阱。测试写的再多可能也没有办法覆盖全部情况,所以只要能让自己感到安全即可。怎样才能让自己感到安全呢?这是没有标准答案的,只能是写多了测试以后慢慢体会。

    另外,写测试也要花时间的,比如compare这个方法的实现部分,我们只花了一两分钟就写完了,而这些测试代码,我们花了足足半个多小时,这样做值得吗?对于简单的业务逻辑来说,当然是不值得的,毕竟我们还很多工作等着做,老板花钱是为了我们的产品代码,而不是测试代码。

    再考虑一种情况,我要创业,想了一个点子,做了一个网站,我当然是想以最快的速度把它做成型让别人用。如果我在完全不知道人们会不会喜欢的时候,先花大量时间写测试,最后发现没人用只能丢掉,这些测试岂不是白写了。

    所以还是上面那句话:单元测试是让你提升自己对代码的信心的,只要你感觉安全可以继续开发时就够了,不是越多越好。

    我相信上面一段解释对于本文中提出的问题大家都没有什么异议。但是这里我们不考虑特殊情况,在实际操作中,是否有办法对单元测试这一工作进行衡量?来判断是否足够?

     

    使用代码覆盖率来衡量单元测试是否足够

    常见的代码覆盖率有下面几种:

    • 语句覆盖(Statement Coverage):这是最常用也是最常见的一种覆盖方式,就是度量被测代码中每个可执行语句是否被执行到了。
    • 判定覆盖(Desicion Coverage):它度量程序中每一个判定的分支是否都被测试到了。
    • 条件覆盖(Condition Coverage):它度量判定中的每个子表达式结果true和false是否被测试到了。
    • 路径覆盖(Path Coverage):它度量了是否函数的每一个分支都被执行了。

    前三种覆盖率大家可以查看下面的引用的第3篇文章,这里就不再多说。我们通过一个例子,来看看路径覆盖。比如下面的测试代码中有两个判定分支

    int foo(int a, int b)
    {
    int nReturn = 0;
    if (a < 10)
    {// 分支一
    nReturn+= 1;
    }
    if (b < 10)
    {// 分支二
    nReturn+= 10;
    }
    return nReturn;
    }

    我们仔细看看逻辑,nReturn的结果一共有4种可能,我们通过路径覆盖的方法设计出来的测试用例:
    用例 参数 返回值
    Test Case 1 a=5, b=5 0
    Test Case 2 a=15, b=5 1
    Test Case 3 a=5, b=15 10
    Test Case 1 a=15, b=15 11

    Perfect。但是实际中的代码往往比上面的例子复杂,如果代码中有5个if-else,那么按照路径覆盖的方法,至少需要25=32个测试用例。这样简直要疯掉了。

     

    没必要追求代码覆盖率,真正要覆盖的是逻辑

    简单追求代码结构上的覆盖率,容易导致产生大量无意义的测试用例或者无法覆盖关键业务逻辑。我们再看看上面解释的第一段话。

    我们写单元测试,有一个重要的原因是用来防止自己犯低级错误的。我们不能把写实现代码的人当作我们的敌人,一定要把全部情况都测到,以防止他们在里面故意留下各种隐蔽的陷阱。测试写的再多可能也没有办法覆盖全部情况,所以只要能让自己感到安全即可。怎样才能让自己感到安全呢?这是没有标准答案的,只能是写多了测试以后慢慢体会。

    怎么才算让自己感到安全?覆盖逻辑,而不是代码。站在使用者的角度考虑,需要关心的是软件实现逻辑,而不是覆盖率。如下面的例子:

    public class UserBusiness
    {
    public string CreateUser(User user)
    {
    string result = "success";

    if (string.IsNullOrEmpty(user.Username))
    {
    result = "usename is null or empty";
    }
    else if (string.IsNullOrEmpty(user.Password))
    {
    result = "password is null or empty";
    }
    else if (user.Password != user.ConfirmPassword)
    {
    result = "password is not equal to confirmPassword";
    }
    else if (string.IsNullOrEmpty(user.Creator))
    {
    result = "creator is null or empty";
    }
    else if (user.CreateDate == new DateTime())
    {
    result = "createdate must be assigned value";
    }
    else if (string.IsNullOrEmpty(user.CreatorIP))
    {
    result = "creatorIP is null or empty";
    }

    if (result != "success")
    {
    return result;
    }

    user.Username = user.Username.Trim();
    user.Password = BitConverter.ToString(MD5.Create().ComputeHash(Encoding.UTF8.GetBytes(user.Password)));

    UserDataAccess dataAccess = new UserDataAccess();
    dataAccess.CreateUser(user);

    return result;
    }
    }

    在写UserBusiness.CreateUser的测试用例的时候,我们定义了下面几个单元测试用例:
    [TestClass()]
    public class UserBusinessTest
    {
    private TestContext testContextInstance;

    /// <summary>
    ///Gets or sets the test context which provides
    ///information about and functionality for the current test run.
    ///</summary>
    public TestContext TestContext
    {
    get
    {
    return testContextInstance;
    }
    set
    {
    testContextInstance = value;
    }
    }

    [TestMethod()]
    public void Should_Username_Not_Null_Or_Empty()
    {
    UserBusiness target = new UserBusiness();
    User user = new User();
    string expected = "usename is null or empty";
    string actual = target.CreateUser(user);
    Assert.AreEqual(expected, actual);
    }

    [TestMethod()]
    public void Should_Password_Not_Null_Or_Empty()
    {
    UserBusiness target = new UserBusiness();
    User user = new User()
    {
    Username = "ethan.cai"
    };
    string expected = "password is null or empty";
    string actual = target.CreateUser(user);
    Assert.AreEqual(expected, actual);
    }

    [TestMethod()]
    public void Should_Password_Equal_To_ConfirmPassword()
    {
    UserBusiness target = new UserBusiness();
    User user = new User()
    {
    Username = "ethan.cai",
    Password = "a121ww123",
    ConfirmPassword = "a121ww1231"
    };
    string expected = "password is not equal to confirmPassword";
    string actual = target.CreateUser(user);
    Assert.AreEqual(expected, actual);
    }

    [TestMethod()]
    public void Should_Creator_Not_Null_Or_Empty()
    {
    UserBusiness target = new UserBusiness();
    User user = new User()
    {
    Username = "ethan.cai",
    Password = "a121ww123",
    ConfirmPassword = "a121ww1231"
    };
    string expected = "password is not equal to confirmPassword";
    string actual = target.CreateUser(user);
    Assert.AreEqual(expected, actual);
    }

    [TestMethod()]
    public void Should_CreateDate_Assigned_Value()
    {
    UserBusiness target = new UserBusiness();
    User user = new User()
    {
    Username = "ethan.cai",
    Password = "a121ww123",
    ConfirmPassword = "a121ww123",
    Creator = "ethan.cai"
    };
    string expected = "createdate must be assigned value";
    string actual = target.CreateUser(user);
    Assert.AreEqual(expected, actual);
    }

    [TestMethod()]
    public void Should_CreatorIP_Not_Null_Or_Empty()
    {
    UserBusiness target = new UserBusiness();
    User user = new User()
    {
    Username = "ethan.cai",
    Password = "a121ww123",
    ConfirmPassword = "a121ww123",
    Creator = "ethan.cai",
    CreateDate = DateTime.Now
    };
    string expected = "creatorIP is null or empty";
    string actual = target.CreateUser(user);
    Assert.AreEqual(expected, actual);
    }

    [TestMethod()]
    public void Should_Trim_Username()
    {
    UserBusiness target = new UserBusiness();
    User user = new User()
    {
    Username = "ethan.cai ",
    Password = "a121ww123",
    ConfirmPassword = "a121ww123",
    Creator = "ethan.cai",
    CreateDate = DateTime.Now,
    CreatorIP = "127.0.0.1"
    };
    string expected = "ethan.cai";
    target.CreateUser(user);
    Assert.AreEqual(expected, user.Username);
    }

    [TestMethod()]
    public void Should_Save_MD5_Hash_Password()
    {
    UserBusiness target = new UserBusiness();
    User user = new User()
    {
    Username = "ethan.cai ",
    Password = "a121ww123",
    ConfirmPassword = "a121ww123",
    Creator = "ethan.cai",
    CreateDate = DateTime.Now,
    CreatorIP = "127.0.0.1"
    };

    string actual = target.CreateUser(user);
    Assert.IsTrue("success" == actual
    && user.Password == BitConverter.ToString(MD5.Create().ComputeHash(Encoding.UTF8.GetBytes("a121ww123"))));
    }

    [TestMethod()]
    public void Should_Create_User_Successfully_When_User_Is_OK()
    {
    UserBusiness target = new UserBusiness();
    User user = new User()
    {
    Username = "ethan.cai ",
    Password = "a121ww123",
    ConfirmPassword = "a121ww123",
    Creator = "ethan.cai",
    CreateDate = DateTime.Now,
    CreatorIP = "127.0.0.1"
    };
    string expected = "success";
    string actual = target.CreateUser(user);
    Assert.IsTrue(expected == actual);
    }
    }
     
    image

    如果仅从代码覆盖率的角度来看,单元测试Should_Trim_Username、Should_Save_MD5_Hash_Password不会增加覆盖率,似乎没有必要,但是从逻辑上看,创建的账户的Username头尾不能包含空白字符,密码也不能明文存储,显然这两个用例是非常有必要的。
     
    单元测试写多少才够?这个问题没有确定的答案,但原则是让你自己觉得安全。代码覆盖率高不能保证安全,真正的安全需要用测试用例覆盖逻辑。
     

    参考文章:

  • 相关阅读:
    Python网络爬虫与信息提取(二)—— BeautifulSoup
    Linux文件系统概述
    XML简介
    JSON数据格式
    SQL*Loader之CASE8
    SQL*Loader之CASE9
    SQL*Loader之CASE10
    SQL*Loader之CASE11
    Oracle工具之DBNEWID
    Oracle常用函数汇总
  • 原文地址:https://www.cnblogs.com/EthanCai/p/3622292.html
Copyright © 2011-2022 走看看