开发人员的编程习惯,单元测试意识与软件质量

软件质量对软件公司来说是生存之根本,而怎样的质量管理能把软件的bug消除到最低程度?这是一个很大的问题,微软企业方法是"以测试为核心"!

测试能大大提高软件质量,测试有单元测试、集成测试、性能测试、压力测试、用户验收测试(UAT)、回归测试、安全性测试(代码攻击)、稳定性测试(内存泄漏),兼容性测试等等。本文主要要讲的是单元测试。

单元测试是和开发人员最密切相关的测试类型。它通常由开发人员编写和执行。由于单元测试通常发生在错误产生之后不久,因此通过单元测试发现错误然后进行修正的代价通常比较小。单元测试是如此重要,以至于一些极限编程爱好者主张任何未经测试的代码都应该被自动删除。

 

且不谈极限编程或TDD(测试驱动的开发),我们都知道,bug越早发现越好;发现产品中存在的问题越早,开发费用就越低,产品质量就越高,软件发布后维护费用就越低。一个bug被隐藏的时间的越长,修复这个bug的代价就越大。最后才修改一个bug的代价可能是在bug产生时修改它的代价的10倍! 那么开发人员如何把bug消灭在最初的时候? 这就要依靠单元测试,依靠开发人员的编程习惯、质量意识(单元测试意识)和测试方法。面分别对这几点说明:

 

1. 编程习惯

     实际上,程序员每写一行代码,每次修改已经存在的代码,无论修改有多微小,都有可能引入bug。而一个具有良好编程习惯的程序员写的代码bug数量明显少于编程习惯不好的程序员。优秀的程序员只是因为习惯。例如平常的编程规范的遵循,代码逻辑的清晰程度,边界条件的判断和异常处理,析构函数,Dispose方法的实现,函数的独立性,实现方法的效率等,都和程序员的习惯和专业素质有关。良好的编程习惯能尽量保证在一开始写程序时就避免bug的发生。Careful programmers test early and test often. 

2. 单元测试意识

     微软企业方法是以测试为核心的,微软面试时经常会问一个开发人员,你写这段代码的时候,如何去判断你的代码是没有问题的呢?你会如何来测试它呢?也就是开发人员写代码的时候一定要有质量意识和测试意识,要懂得如何进行单元测试来确保自己的代码的可靠性。而不是没有测试意思和质量意识,一路酣畅淋漓的 写下去,根本不做单元测试,非常自信的认为自己的每行代码都是经典,其实自己都不知道写的代码是否正确,底层代码可靠性不得而知,上层的代码还在不断开发,最后在集成测试或者deadline时候才开始紧张,那就太晚了。平时就应该有良好的质量意识和测试意识,每个底层函数都要进行单元测试,保证可靠性然后入库。平时保持适当的紧张状态,到deadline的时候就不会紧张,因为每个底层函数都测试过,可靠性有保证。常做测试,早做测试,在软件生命周期中,越晚发现的错误越难修改,修改成本越昂贵。尽可能早的做测试是很值得的。

3. 测试方法

     除了以上两点以为,测试方法也是直接影响单元测试质量和软件质量的。我们知道,单元测试的覆盖种类有语句覆盖、判定分支覆盖、条件覆盖(条件一一覆盖,条件组合覆盖)、路径覆盖等,好的单元测试,应该覆盖所有代码路径和错误处理路径,要达到一定的覆盖率(Thorough)。尽量利用自动化单元测试工具以提高效率,例如JUnitNUnit等等,自动化工具也有利于每日构建等行为;单元测试代码必须和代码一起入库和维护;以后当出现代码更改时候可以方便的自动进行回归测试;所以好的单元测试,必须是自动的(Automatic),可重复的(Repeatable)。最后一点是单元测试的独立性(Independent), 单元测试的运行/通过/失败不依赖于别的测试。

 

    良好的单元测试策略给我们增强了对程序的信心,减少了bug的产生及bug的潜伏期。降低修改bug的代价。单元测试不会是项目开发周期的某一个生命周期,它贯穿于项目的整个生命周期,是一个非常重要的日常开发活动。

在极限编程中,测试驱动开发已经被证明是一种有效提高软件质量的方法。在测试驱动的开发方式中,软件工程师在编写功能代码之前首先编写测试代码,这样能从最开始保证程序代码的正确性,并且能够在程序的每次演进时进行自动的回归测试。

 

国内程序员为什么不写单元测试


尽管单元测试是这么重要,国内还是很少有程序员能够比较认真的去编写单元测试。那么到底又是什么原因呢?为了完成编码任务,为了赶上进度,没有足够的时间编写单元测试。编写单元测试会导致不能按时完成编码任务,推迟项目进度。

  1. 单元测试的价值不高,完全是浪费时间。
  2. 业务逻辑比较简单,不值得编写单元测试
  3. 不知道怎么编写单元测试
  4. 项目没有要求,所以不编写.
  5. 在项目的前期还是尽量去编写单元测试,但是越到项目的后期,就越失控。

由此可见,开发人员没有质量意识和单元测试意识、没有测试习惯、公司没有要求,以及为了赶项目进度,都是导致国内程序员不重视单元测试的原因。从公司和项目管理者角度来说,还没有意识到单元测试带来的好处和产出;从开发人员角度,也认为单元测试是对自己的代码质量没有提高的浪费时间的行为。

 

单元测试到底值不值得做? 


个人认为如果不做单元测试,则bug的多少存在一个日积月累的累积效果,而一个bug被隐藏的时间的越长,修复这个bug的代价就越大。最后才修改一个bug的代价可能是在bug产生时修改它的代价的10

 

国内软件公司经常看到这种情况:为了追赶进度,绝大多数程序员都把没有经过任何测试的代码提交到版本服务器, 项目经理也不追问,照单全收。结果就是在后期,集成花费的时间越来越多,几个技术骨干人员只得日夜加班进行系统集成。好不容易集成完了之后,下发给测试人员测试时,bug的报告成数量级的增长。程序员就日以继夜的修改bug。数不清的bug, 修正了一个bug, 更多的bug报告上来,改了一个带来另外一个。甚至还有非常多的bug被 隐藏更深,一直潜伏到生产环境去。项目中,越来越多的人对项目失去信心, 不知道自己写的代码里面还有多少潜伏的“炸弹”没有引爆呢?!于是胆战心惊,于是开始有人想逃离了,有人请假,也有人离职。当项目总算结束时,每一个的内心都清楚,项目太烂了,还有很多的错误还没被测试出来,赶快逃离这个项目组吧

其实前期不写单元测试,貌似赶上了进度,却累积了bug,且不利于未来的维护,到项目后期必然出来bug成堆疲于应对的局面。甚至修改一个bug引入另一个bug,而修改一个bug的代价往往是早期的几倍甚至几十倍!与其痛苦的挣扎,还不如去好好想想更好的实践。

很多人都想着把做的东西整合起来,整体跑起来以后测试比较容易看到结果,例如相互之间存在接口和相互依赖关系的系统。这固然正确,我们常常说测试要占到整个开发周期的1/2或以上, 但事实上后期往往发现这种系统测试耗费的时间比预想的多得多,有的甚至超出项目预期耗费的测试时间的一倍以上,究其原因,很多“小零件”前期并没有好好严密的进行测试就来到了总装厂进行总装,这样消耗的时间能不多吗?各个“小零件”中边界条件,异常情况,逻辑分支都存在这样那样的缺陷,导致系统千疮百孔,然后各自开始扯皮推脱责任,这样的系统测试的确难度太大

其实前期可能耗费的一点时间来写测试,但能最大程度的减少最初的bug,能增强底层函数和代码的可靠性,不会使上层出现的bug呈现累积效应。所以单元测试付出的收效随着时间的推移才会逐渐显现出来,也就是说单元测试的成本是先高后低,而回报是逐渐增多,尤其是后期维护和升级,只需重新自动化运行单元测试用例进行回归测试,就能确保更改的接口正确性

 

如何高效的做好单元测试? 


不管是强迫还是自动养成了自己写单元测试的习惯,有了单元测试的意识,接下来就是如何高效的做好单元测试的问题了。

 

写单元测试并不是一个微不足道的任务,如果步骤正确,单元测试可以为开发者的生产力和代码的质量带来令人惊讶的提高,他可以帮助你去创建的应用程序含有更少的错误,同时也可以便于其他的开发者去洞察你的代码,但是他也需要在之前承担一个义务,确认遵循一些简单的规则。当方法并不是很好时,单元测试则可能达 到一个相反的结果,从而浪费您的时间,并且使测试过程更加复杂。

 

因为单元测试具体实施过程中有很多难点,例如,界面GUI如何做单元测试,网站的界面如何做,写文件的异常如何测,依赖复杂输入的情况如何编造数据,单元测试的粒度如何把握,更新代码时候需要同步更新测试代码和案例等等。


单元测试的范围/粒度如何把握


单元测试基本是围绕函数来进行的。当然你可以用MOCK技术来模拟一些相对复杂的对象和环境。但总体来说,粒度应把握在函数级别,单纯的环境,要和系统测试/集成测试严格区分开来。


单元测试的时间如何把握: 

如果按照项目时间这样划分:1/3 计划和设计, 1/6 实现, 1/4 组件测试, 1/4 系统测试。则在‘代码实现’中,不仅要包含Coding的时间,而且要包含单元测试的时间,他们的比例大概是1/3的时间做东西, 2/3的时间做检查。这样才能保证“零件质量”。单元测试不是为了增大自己的工作量,而是减少你未来的工作量。否则,从工程学上来说,或者有过真正工程经验的人,都知道其质量保证是掩耳盗铃,最终将自食其果。
 
 


哪些函数无需做单元测试:

这个函数是不是简单到一眼就能检查出输出和起征点的错误?如果的确是,那么这个函数不用做单元测试。有些公司和员工为了敷衍了是做一些“伪单元测试”,有点形而上学,根本不是高效的单元测试。



哪些函数需要做单元测试:

1.         逻辑或算法复杂,函数内部逻辑或算法很复杂,判断条件、循环、停顿跳转很多,一眼看不出输入什么结果,那么需要做单元测试来验证期望的输出

2.         涉及很多对象,单元测试一定要做,因为能够有效的发现和隔离依赖项,有助于更好的解耦;(一个紧耦合的系统的确很难做单元测试,需重构

3.         抛出异常或捕捉异常,任何函数如果存在内部断点或跳出点,一定要做单元测试模拟程序内部跳转和停止的情况

4.         越底层函数越需要单元测试,指的是系统越底层的函数、越核心的函数,越需要单元测试,反之,越高层的,比如UI层,越不要单元测试。

 

记住,不要去测试类中的每个方法。挑选以上几种函数进行测试,而且要根据需求来测试这个类对外所能提供的功能, 这些功能可能是其中的几个重要方法, 可能需要类中的几个方法协作,可以对这几个方法进行测试,无需测试类中的所有方法。将来做自动化回归测试的时候,这些重要函数的单元测试都有覆盖跑到,能大大增强软件的质量保证。(额外好处:你可以通过你的测试代码告诉别人如何使用这个类)

 

到底是先写测试函数,后写函数,还是反之,这个问题TDD主张先写测试函数确定接口,然后写具体函数实现。个人觉得看项目需要。



有关回归测试:


写单元测试的一大好处是可以自动进行回归测试,也就是说,将来代码修改了,可以自动跑一些所有测试案例,就知道这次修改有没有伤及其他函数。回归测试的功能非常重要,对软件后期的维护和升级节省相当的成本。甚至有人说,如果没有回归测试,就没必要写单元测试。

当然,单元测试另一个问题是覆盖率,不完全的测试覆盖率意味着缺陷的存在,但不能因为覆盖率不能100%覆盖就不做单元测试,所谓“高效的单元测试”就是取得项目测试时间、项目的输出和质量、Schedule和成本的三者的平衡。



分层架构下的单元测试:

  1. Web层,界面层,或称为表现层(Presentation:主要测试Controller的数据结构化逻辑。
  2. 业务逻辑层(Business Logic):主要测试业务规则,独立的业务规则或者叠加的业务规则;以来外部对象的可以用MOCK来模拟环境测试。当然繁杂的业务流程可以不测试,要视具体情况而定。
  3. 数据访问层(Data Access Logic: 需要测试。
  4. 面向方面(AOP)和其他基类库:需要重点测试。



单元测试的自动化工具

 

  1. CppUnit,这是C++单元测试工具的鼻祖,免费的开源的单元测试框架.
  2. C++Test,这是Parasoft公司的产品。[C++Test是一个功能强大的自动化C/C++单元级测试工具,可以自动测试任何C/C++函数、类,自动生成测试用例、测试驱动函数或桩函数,在自动化的环境下极其容易快速的将单元级的测试覆盖率达到100%]。Visual Unit,简称VU,这是国产的单元测试工具,据说申请了多项专利,拥有一批创新的技术,不过老纳只关心是不是有用和好用。
  3. NUnit,这是一个单元测试框架,专门针对于.NET来写的
  4. JUnit,针对Java的单元测试框架。目前java下的team 开发采用cvs(版本控制) + ant(项目管理) + junit(集成测试) 的模式时,通过对ant的配置,可以很简单地实现测试自动化。
  5. WinUnit,简化的C++应用程序单元测试工具。(参考MSDN上的详细介绍
         
      (更多测试工具请
    点击此处


单元测试的例子:

1. NUnit单元测试,测试单个函数的逻辑分支和抛出的异常:

           实现类:(ConnectionFactory.cs)主要实现了CreateConnection这个方法。
              

using System;
using Microsoft.Practices.SmartClient.ConnectionMonitor.Implementations;

namespace Microsoft.Practices.SmartClient.ConnectionMonitor
{
   
/// <summary>
   
/// A simple factory that can create <see cref="Connection"/> objects.
   
/// </summary>

    public class ConnectionFactory
   
{
       
/// <summary>
       
/// DesktopConnection
       
/// </summary>

        public const string DesktopConnection = "DesktopConnection";

       
/// <summary>
       
/// NicConnection
       
/// </summary>

        public const string NicConnection = "NicConnection";

       
/// <summary>
       
/// WirelessConnection
       
/// </summary>

        public const string WirelessConnection = "WirelessConnection";

       
/// <summary>
       
/// WiredConnection
       
/// </summary>

        public const string WiredConnection = "WiredConnection";

       
/// <summary>
       
/// Creates a <see cref="Connection"/> object.
       
/// </summary>
       
/// <param name="connectionType">The type of the connection to create.</param>
       
/// <param name="price">The price of the <see cref="Connection"/>.</param>
       
/// <returns>A <see cref="Connection"/> object.</returns>
       
/// <exception cref="ConnectionMonitorException">Thrown when an invalid type is requested.</exception>
       
/// <remarks>
       
/// For the built-in <see cref="Connection"/> types
       
/// (i.e. DesktopConnection, NicConnection, WirelessConnection, WiredConnection),
       
/// only the class name is required.  For user created types, the fully qualified
       
/// class name is required.
       
/// </remarks>

        public static Connection CreateConnection(string connectionType, int price)
       
{
            Guard.StringNotNullOrEmpty(connectionType,
"connectionType");
           
           
if (price < 0)
           
{
               
throw new ArgumentOutOfRangeException("price");
            }


            Connection connection;
           
switch (connectionType)
           
{
               
case DesktopConnection:
                    connection
= new DesktopConnection(DesktopConnection, price);
                   
break;
               
case NicConnection:
                    connection
= new NicConnection(NicConnection, price);
                   
break;
               
case WirelessConnection:
                    connection
= new WirelessConnection(WirelessConnection, price);
                   
break;
               
case WiredConnection:
                    connection
= new WiredConnection(WiredConnection, price);
                   
break;
               
default:
                   
try
                   
{
                        Type connectionTypeToCreate
= Type.GetType(connectionType, true);
                        Object createdObject
= Activator.CreateInstance(connectionTypeToCreate, connectionTypeToCreate.Name, price);
                        connection
= createdObject as Connection;
                    }

                   
catch(TypeLoadException ex)
                   
{
                       
throw new ConnectionMonitorException("Unsupported connection type.", ex);
                    }

                   
                   
if (connection == null)
                   
{
                       
throw new ConnectionMonitorException("Unsupported connection type.");
                    }

                   
break;
            }

           
return connection;
        }


    }

}


  
           测试类:(ConnectionFactoryFixture.cs) 主要测试了CreateConnection这个方法的逻辑分支,以及抛出的各种异常。
        

using System;
using System.Text;
using System.Collections.Generic;
using NUnit.Framework;
using Microsoft.Practices.SmartClient.ConnectionMonitor.Implementations;

namespace Microsoft.Practices.SmartClient.ConnectionMonitor.Tests
{
   
/// <summary>
   
/// Summary description for ConnectionFactoryFixture
   
/// </summary>

    [TestFixture]
   
public class ConnectionFactoryFixture
   
{
       
public ConnectionFactoryFixture()
       
{
        }


        [Test]
       
public void CanCreateDesktopConnection()
       
{
            Connection connection
= ConnectionFactory.CreateConnection("DesktopConnection", 1);
            Assert.IsNotNull(connection);
            Assert.IsTrue(connection
is DesktopConnection);
        }


        [Test]
       
public void CanCreateNicConnection()
       
{
            Connection connection
= ConnectionFactory.CreateConnection("NicConnection", 1);
            Assert.IsNotNull(connection);
            Assert.IsTrue(connection
is NicConnection);
        }



        [Test]
       
public void CanCreateWirelessConnection()
       
{
            Connection connection
= ConnectionFactory.CreateConnection("WirelessConnection", 1);
            Assert.IsNotNull(connection);
            Assert.IsTrue(connection
is WirelessConnection);
        }



        [Test]
       
public void CanCreateWiredConnection()
       
{
            Connection connection
= ConnectionFactory.CreateConnection("WiredConnection", 1);
            Assert.IsNotNull(connection);
            Assert.IsTrue(connection
is WiredConnection);
        }


        [Test]
       
public void CanCreateMyCustomConnection()
       
{
            Connection connection
= ConnectionFactory.CreateConnection("Microsoft.Practices.SmartClient.ConnectionMonitor.Tests.MyCustomConnection,SmartClient.ConnectionMonitor.Tests", 1);
            Assert.IsNotNull(connection);
            Assert.IsTrue(connection
is MyCustomConnection);
        }

       
        [Test]
        [ExpectedException(
typeof(ConnectionMonitorException))]
       
public void CreateConnectionThrowsWhenPassedBadType()
       
{
            Connection connection
= ConnectionFactory.CreateConnection("BadTypeName", 1);
        }

       
        [Test]
        [ExpectedException(
typeof(ArgumentNullException))]
       
public void CreateConnectionThrowsWhenPassedNullType()
       
{
            Connection connection
= ConnectionFactory.CreateConnection(null, 1);
        }


        [Test]
        [ExpectedException(
typeof(ArgumentException))]
       
public void CreateConnectionThrowsWhenPassedEmptyType()
       
{
            Connection connection
= ConnectionFactory.CreateConnection(String.Empty, 1);
        }


        [Test]
        [ExpectedException(
typeof(ArgumentOutOfRangeException))]
       
public void CreateConnectionThrowsWhenPassedNegativePrice()
       
{
            Connection connection
= ConnectionFactory.CreateConnection("WiredConnection", -1);
        }


    }

}

     (有关NUnit的详细使用方法可以点此参考


         2. NUnit单元测试,使用MOCK对象模拟外部依赖条件:
                      

假如某个方法依赖于其他一些难以操控的东西,比如说网络连接,数据库连接,数据库表记录,或者外部XML文件,那么我们该怎么办呢?

这无疑会加大我们测试工作的难度与力度。 而这些数据库连不上,数据读不到,文件打不开等情况又是经常会遇到的,难道就因为难测试就放弃测试吗?很多人采取的态度就是不测试,或者只准备在大规模集成测试的时候再手动测试一遍。

这样做有个坏处,那就是如果你的程序对异常情况没有处理好的话你只可能在大规模集成测试的时候才会发觉,而且这会消耗掉你非常多的时间去定位并解决它,因为已经过了很长时间了,没准依赖这段bug代码的代码已经有很多了,如果你的程序没有自动化回归测试的保护(可重构的)并且设计的依赖性较强的话那么你很可能会花上几个小时到几天的时间去解决一个当初可以在几分种内解决的bug

 

我们可以利用MOCK对象来模拟外部环境。

 

使用mock对象进行测试的时候,我们总共需要3个步骤,分别是:

1.         使用一个接口来描述这个对象

2.         为产品代码实现这个接口

3.         以测试为目的,在mock对象中实现这个接口



在此我们又一次看到了针对接口编程的重要性了。因为被测试的代码只会通过接口来引用对象,所以它完全可以不知道它引用的究竟是真实的对象还是mock对象。(如果系统没有很好的设计和解耦,测试起来的确有些难度。此时解决的办法就是重构系统。

 

 

另一个Mock来模拟数据库连接层对象实现单元测试的例子:

 

以下是一个简单 Order 类。最需要注意的是构造函数使用了 IShopDataAccess 的实例,它被定义为:

public class Order
{
    private int orderId_;
    private IShopDataAccess dataAccess_;
    private OrderLineCollection orderLines_;
 
    public Order(int orderId, IShopDataAccess dataAccess)
    {
        if (dataAccess == null)
        {
        throw new ArgumentNullException("dataAccess");
        }
        this.orderId_ = orderId;
        this.dataAccess_ = dataAccess;
        this.orderLines_ = new OrderLineCollection(this);
    }
 
    public OrderLineCollection Lines
    {
        get { return this.orderLines_; }
    }
 
    public void Save()
    {
        this.dataAccess_.Save(this.orderId_, this);
    }
 
    internal IShopDataAccess DataAccess
    {
        get { return this.dataAccess_; }
    }
}
 
public interface IShopDataAccess
{
    decimal GetProductPrice(int productId);
    void Save(int orderId, Order o);
}

为了对这个 Order 类的Save方法进行单元测试,需要提供 IShopDataAccess 的模拟,因为如果我传入空值,构造函数就会引发异常。

public void Save(int orderId, Order o)
{
    throw new Exception("The method or operation is not implemented.");
}

Mock模拟 IShopDataAccess 对象实现单元测试:

[TestMethod]
public void SaveOrderAndVerifyExpectations()
{
    MockRepository mocks = new MockRepository();
    IShopDataAccess dataAccess = mocks.CreateMock<IShopDataAccess>();
 
    Order o = new Order(6, dataAccess);
    o.Lines.Add(1234, 1);
    o.Lines.Add(4321, 3);
 
    // Record expectations
    dataAccess.Save(6, o);
 
    // Start replay of recorded expectations
    mocks.ReplayAll();
 
    o.Save();
 
    mocks.VerifyAll();
}

 

 

 

数据库相关操作的环境准备(MOCK实例)待续...
文件相关操作的环境准备(MOCK实例)待续...
IOC Service等对象的模拟(MOCK实例)待续...
多线程的测试 待续...

posted on 2008-03-11 21:40  Mainz  阅读(1813)  评论(4编辑  收藏  举报

导航