65.9K
CodeProject 正在变化。 阅读更多。
Home

可测试的应用程序

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.30/5 (9投票s)

2007年4月21日

12分钟阅读

viewsIcon

46238

downloadIcon

213

让你的应用程序可测试。

Testable Application Image

引言

我们中的许多人今天都听说过单元测试和模拟对象,其他人可能第一次接触单元测试

是使用 NUnit 工具

http://www.nunit.org/

或通过 Visual Studio 2005 Team Suite,有很多文章介绍如何创建单元

测试,或者如何测试您的应用程序以及如何使用不同的测试模式。


这不是本文的目标,本文重点介绍如何创建可测试的应用程序

或者如何重构您的普通应用程序代码,使其成为可测试的代码。

致谢

我首先要感谢通过一次快速培训向我介绍单元测试和模拟对象的人

培训课程,他是学习与创新中心的资深分析程序员 Mark Focas

和创新中心

我的经验

直到我参与了一个大型应用程序,我才意识到单元测试的重要性。它包含

超过 15 个 .Net 项目,此应用程序完全不依赖数据集,因此所有对象都是

自定义对象和集合,因此类的总数约为数千个,要

在没有单元测试的情况下控制这些代码几乎是不可能的。


每次我完成一个模块,我都想知道它对应用程序中其他模块的影响。

应用程序。同时,它节省了在 Web 应用程序开发中编写代码的时间,

每次完成代码后都运行 Web 应用程序,这将是一个非常耗时的过程。


因此,在创建了数千个单元测试并完成每个模块后运行了所有这些测试,

我建议使用单元测试,即使您的应用程序看起来很简单。

快速定义

单元测试

单元测试是自动化测试,测试代码的一个单元或片段,可能是对象、对象中的方法、

或属性,或通过名为 Nunit 的框架的任何其他单元

这个框架为您提供了比较、断言、预期

异常等所需的所有工具,以测试您的应用程序。

模拟对象

是一种模仿(充当)其他真实对象依赖项的对象,这样我们就可以尽可能地隔离

对象与它们的依赖项,以便专注于每个对象本身。

集成测试

使用一个或多个对象和依赖项的测试,以查看每个对象

与它的依赖项一起工作的影响

Bug 修复

错误修复是一个更改代码片段并运行单元测试来测试此代码的过程,

然后运行集成测试来测试此更改对其他代码的影响。

使用代码

  1. 首先,您应该下载 Nunit,打开此页面

    http://www.nunit.org/index.php?p=download


    然后选择 win .net 2.0 NUnit-2.4.0-r2-net-2.0.msi

  2. 然后解压缩文章代码,并在 Visual Studio 2005 中打开文章解决方案,然后

    将 UnitTest 项目设置为启动项目。

  3. 选择 UnitTest 项目属性,并确保“启动外部程序”字段

    和“工作目录”指向正确的位置。

    图 1,单元测试应用程序配置
  4. 运行应用程序,程序将启动 Nunit,然后点击“运行”,您应该会看到

    此屏幕

    图 2,单元测试结果



项目构想

该项目只是一个简单计费系统的示例。客户将填写发票字段

数据,然后开具发票。在此过程中,发票数据库将受到影响,并且

客户数据库也将受到影响,以使客户承担发票总额的债务。

的债务。

支付过程将简单地结清发票,并更新客户账户中的

交易

图 3,代码的可测试和不可测试的两个版本


为了演示,我们创建了两个版本的项目对象

  • 不可测试的对象

    所有位于 Nontestable 命名空间下的对象

  • 可测试对象。

    所有位于 Testable 命名空间下的对象。所有位于 Testable 命名空间下的对象

    命名空间

不可测试应用程序的特点

每个应用程序都可以进行测试,无论是手动测试还是少量自动测试。

QA 部门仍在通过运行应用程序并在不同平台中创建

不同场景来测试应用程序,但我们关注的是由程序员完成的自动化单元测试

作为他工作的一部分。

为不可测试的应用程序创建单元测试一点也不容易,因为您总是

将测试集成测试的外壳,在大多数情况下您

可能不知道错误在哪里,除非您逐行调试代码以了解代码在何处中断,我们可以总结不可测试的应用程序的特点

1) 没有分离成设计良好的层次

我们认为这与设计良好的面向对象编程(OOP)是相同的条件。如果您的应用程序没有很好地

分离成不同的层次,这意味着您无法设计出具有特定

职责并封装内部实现的对象。

2) 代码不可重用

这是重构代码的第一步,我们将通用代码收集到方法中,

然后我们可能将具有身份的代码封装到对象中,然后将对象抽象

为更抽象的对象,直到我们发现代码中没有任何冗余,

结果将是一个非常清晰、可重用且易于维护的应用程序。

3) 代码隐藏了我们无法隔离的内部依赖项

这一点是区分可测试应用程序和优秀的面向对象应用程序的第一点,

我们知道面向对象编程的概念是建立在封装之上的。

所以我们需要代码既能封装并隐藏内部实现,又能

在运行时开放更改依赖项“这就是挑战”

4) 对象没有任何契约

如果对象没有任何契约(接口或抽象基类),则意味着

客户端对象将使用具体类而不是抽象类或接口,最终

我们将得到非常静态、不可更改的代码,因此很难测试。

5) 对象方法和属性不是虚拟的

此要求并非强制性,但如果您考虑

出于测试目的在运行时将任何对象替换为另一个对象,它将会有所帮助。这意味着在运行时

,客户端类仍然看到旧的实现,而不是新版本(新的子类或新的继承类)

提供的新实现,

我们最终将得到一个不可测试的应用程序,因为我们无法在运行时更改对象的行为。

如果您已经使用过自动模拟对象,尤其是在 .Net 1 中,这个概念会更清楚。

中,这个概念会更清楚。

将这些简单概念应用于示例代码

图 4,不可测试代码的类图


由于以下原因,此设计是不可测试应用程序的一个很好的示例:

  1. 业务对象和数据对象的实现存在于同一个地方,

    很难将对象分层,因此我们至少有 2 个层混合在

    一个层中。

  2. 代码不可重用,我们发现 ExecuteStoredProcedure 方法在

    Invoice 对象和 Customer 对象中是通用的,但它实现了两次,因此我们可以清楚地看到

    需要一个新的对象,例如 DalcBase,来封装所有这些通用方法到抽象类中,

    然后我们将为每个对象继承自己的 Dalc。

  3. 代码隐藏了无法访问的实现,ExecuteStoredProcedure 是私有的,它

    存在于对象内部,您无法访问此方法以更改其行为以进行

    测试。

  4. 所有对象都没有契约,无论是接口还是抽象类,这意味着所有

    客户端类直接使用具体类,这在发票类使用 Customer 类而非 CustomerBsase 或 ICustomer 中显而易见。

    Invoice 类使用 Customer 类而不是 CustomerBsase 或 ICustomer 是显而易见的
图 5,不可测试代码示例。
using System;
using System.Collections.Generic;
using System.Text;
using System.Data.SqlClient;
using System.Data;

namespace TestableApplication.NonTestable
{
    public class Invoice
    {
        private int _invoiceNumber;
….

Customer _customer;
        public Customer Customer
        {
            get { return _customer; }
        
        }
}
}

在此代码中,我们可以看到 Invoice 对象直接使用 Customer 对象,

而不是接口或抽象类,因此我们无法在运行时更改 Customer 依赖项,

也无法创建继承 Customer 的模拟对象,因为所有私有方法

和字段都无法访问,所以现在我们以固定的、不可更改的依赖项告终。

可变的。

不可测试应用程序对单元测试的影响

图 6,不可测试结果示例。
testable_applications/NonTestableResult.GIF

在图 6 中,我们看到 Customer 测试中 Debt 方法中发生的相同错误

所有 IssueInvoice 单元测试中都存在相同的错误。错误是“InvalidOperationException

Customer Debt 方法,此错误是有意为之,作为 Customer 依赖项错误的示例”。让我们看看代码

错误”。让我们看看代码

public class Customer{

public void Debt(decimal value, string description, int transactionID,DateTime transactionDate)
        {
            throw new InvalidOperationException("Customer Debt method, This Error did in
                purpose as Example of Customer Dependency Error");             
            Console.WriteLine("--- Debt Method was invoked from object {0} ", this);
            Console.WriteLine("press enter to continue ....");
            Console.ReadLine();

            List<sqlparameter __designer:dtid="844424930132191"> parameters = new List<sqlparameter>();
            SqlParameter param = new SqlParameter("@VALUE", SqlDbType.Money);
…………..
}
</sqlparameter></sqlparameter>

此异常在 Customer Debit 方法内部抛出,但它发生在 Issue invoice 方法测试的完整周期中。

现在让我们看看 Issue invoice 代码。
public Invoice IssueInvoice(string description, Customer customer, List items)
        {
            Console.WriteLine("--- IssueInvoice Method was invoked from object {0} ", this);
            Console.WriteLine("press enter to continue ....");
            Console.ReadLine();

            List<sqlparameter> parameters = new List<sqlparameter>();
            SqlParameter param = new SqlParameter("@DESCRIPTION", SqlDbType.NVarChar);
            param.Value = description;
            parameters.Add(param);

            param = new SqlParameter("@CUS_ID", SqlDbType.Int);
            param.Value = customer.ID; ;
            parameters.Add(param);

            DateTime issueDate = DateTime.Now;
            param = new SqlParameter("@ISSUE_DATE", SqlDbType.DateTime);
            param.Value = issueDate;
            parameters.Add(param);

            param = new SqlParameter("@DUE_DATE", SqlDbType.DateTime);
            param.Value = issueDate.AddMonths(1); ;
            parameters.Add(param);

            object idObj = ExecuteStoredScalerProcedure("stp_add_invoice_header", parameters);
            // for test only
            idObj = DateTime.Now.Millisecond; 
            int invoiceNumber = int.Parse(idObj.ToString());

            parameters.Clear();
            int seq = 0;
            foreach (InvoiceItem item in items)
            {

                param = new SqlParameter("@INV_ID", SqlDbType.Int);
                param.Value = invoiceNumber;
                parameters.Add(param);

                param = new SqlParameter("@QTY", SqlDbType.Int);
                param.Value = item.Quantity;
                parameters.Add(param);

                param = new SqlParameter("@SEQ_NUM", SqlDbType.Int);
                param.Value = ++seq;
                parameters.Add(param);
                ExecuteStoredScalerProcedure("stp_add_invoice_line", parameters);

            }
            Invoice newInvoice=new Invoice(invoiceNumber, description, customer, issueDate,Status.Issued, items);
// always cause error            
customer.Debt(newInvoice.TotalAmount, newInvoice.Description, newInvoice.InvoiceNumber, newInvoice.IssueDate);
            return newInvoice;
        }
</sqlparameter></sqlparameter> 
Issueinvoice 有代码来创建新的 Invoice 实例,并更新发票

数据库,并通知客户他应该支付的发票金额。

因此,如果发票数据库或客户对象出现任何问题,

IssueInvoice 方法将失败,这不是一个好现象;


好的设计是将数据库更新单独在单元测试中测试,将

客户债务方法在不同的单元测试中测试,最后将 issueInvoice 方法单独测试

在所有代码通过单元测试后,我们将对它们全部运行集成测试。



现在,让我们来谈谈好的面向对象、可测试的设计。

可测试应用程序的特点

  1. 分离成设计良好的,如上所述,这是面向对象设计中良好封装和可维护应用程序的一个条件。

    良好封装和可维护应用程序的设计条件。

  2. 代码应该可重用,所有方法都应该在适当的对象中,并且

    通用成员应该在抽象层中。

  3. 代码应该以接口的形式暴露内部依赖项

    所有依赖项都应该聚集在特定的对象中,然后我们应该提取

    接口,并让这些依赖接口作为对象的属性(或者

    也可能作为参数传递给特殊构造函数,我们稍后会解释),

    通过这个简单的想法,我们可以通过属性(或构造函数)注入对象

    这个想法很完美,但不幸的是它违反了面向对象编程(OOP)的概念,

    它会将隐藏的实现暴露给客户端,这不好,而且

    对象根本不安全。
  4. 对象应始终实现接口。这是最佳实践,

    特别是如果您想使用外部模拟对象,

    如果您喜欢始终将抽象类作为契约(编写自己的默认实现),

    您可能需要将所有抽象类与接口包装起来,然后

    让所有客户端对象都使用接口,而不是具体类甚至抽象类。


  5. 对象的属性和方法可以是虚拟的,这不是强制性的,只是

    在特殊情况下建议使用,如果从类直接创建模拟对象

    以创建快速自定义模拟对象,它将很有用

    但是,如果所有类都已实现接口,则无需将所有公共

    成员设为虚拟,这已经是一个副作用,因为虚拟成员的性能

    比非虚拟成员有延迟。

重构旧代码以使其可测试的步骤

  1. 将所有对象分离到不同的层中

    在我们的示例中,Testable 命名空间下的所有代码都分为 2 层:业务

    组件层和 Dalc 层

    图 7,可测试代码示例,分层分离。

  2. 让所有对象都实现接口,并让客户端类使用这些接口。

    图 8,可测试代码示例,每个对象都有一个接口。



    在此图中,从类中提取了接口,并且我们实现了所有这些接口。

    public partial class Invoice : IInvoice
        {
            private ICustomer _customer;
        
        //expose dependency as interface
            public ICustomer Customer
            {
                get { return _customer; }
    
            }
    
            private IInvoiceDalc _invoiceDalc;
    
    //expose dependancy as interface        
    public IInvoiceDalc InvoiceDalc
            {
                get {
                    if (_invoiceDalc == null) _invoiceDalc = new InvoiceDalc(this);
    
                    return _invoiceDalc; }
                    
                set {
                    this._invoiceDalc = value;    
                    }   
            }
            
            private int _invoiceNumber;
            public int InvoiceNumber
            {
                get { return _invoiceNumber; }
                set { _invoiceNumber = value; }
            }
    
    
    我们可以听到我们如何将 Customer 暴露为接口,并且我们对

    发票 Dalc 也是如此

  3. 所有依赖项都应集中在特定对象中

    图 9,可测试代码示例,Dalc 对象作为对象中所有依赖项封装的示例。





    在此图中,我们封装了所有数据库方法到 Dalc 对象中,然后我们使

    所有这些类都实现了接口,并且我们使用这些接口来暴露

    所有通过 Dalc 使用数据库的对象中的 Dalc 对象。
  4. 使用自定义模拟对象

    要创建一个自定义模拟对象,您应该实现被模拟对象的相同接口,

    并在您的单元测试中,用模拟对象替换真实对象。

    您可以通过不同的方式实现模拟对象,也许您会将所有

    方法留空,或者将所有调用记录到日志文件中,或者为特定方法和参数生成并返回数据,

    或者将数据保存在哈希表中。

    模拟对象的用途很多,但在我们的示例中,我们只是在控制台输出简单的日志,

    只是为了表明方法已被调用。

    我们这样做只是为了隔离依赖项(如发票 Dalc 和客户

    发票对象的依赖项),以专注于主要对象(我们示例中的发票对象)。

    样本)

    图 10,可测试代码示例,每个对象都实现接口。




    图 11,可测试代码示例,模拟对象应实现相同的对象接口。


测试可测试代码

单元测试的主要技术是在运行时替换依赖项。

在运行时更改依赖项

最著名的技术是
  1. 依赖注入。



    用新的设计做到这一点一点也不难,但问题是,

    如果我们给代码添加 Set 属性,我们的类将不安全,并且会违反

    面向对象设计。

    在这种情况下,您可以使用 #if DEBUG 指令进行依赖注入

    所以您应该做的,是将依赖项作为接口暴露,并创建一个 Set

    访问器(仅在调试模式下)以在运行时更改它。
    public partial class Invoice : IInvoice
        {
            private ICustomer _customer;
            public ICustomer Customer
            {
                get { return _customer; }
    
            }
    
            private IInvoiceDalc _invoiceDalc;
            public IInvoiceDalc InvoiceDalc
            {
                get
                {
                    if (_invoiceDalc == null) _invoiceDalc = new InvoiceDalc(this);
    
                    return _invoiceDalc;
                }
    #if DEBUG
                set
                {
                    this._invoiceDalc = value;
                }
    #endif
            }
    


    上面的代码是调试模式下依赖注入的一个示例。

    此代码是单元测试中使用依赖注入的示例。
    Invoice invoice = new Invoice().GetInvoice(12);
                IInvoiceDalc invoiceDalc = new InvoiceDalcMock();
    
                invoice.InvoiceDalc = invoiceDalc;
                int count1 = invoice.Items.Count;
                invoice.Items.Add(new InvoiceItem(45, "ss", 55, 23.4m));
    //will not generate error
        
    
        invoice.Update();
                int count2 = invoice.Items.Count;
                Assert.IsTrue(count2 == count1 + 1, "Item Count")
    
  2. 构造函数注入


    通过使用分部类,在 .Net 2 中创建构造函数(或任何其他方法)注入非常容易

    使用部分类
    namespace TestableApplication.Testable
    {
        public partial class Invoice : IInvoice // only has a constructor injection
        {
    
    // this code will work only in debug mode
    #if DEBUG 
            public Invoice(int invoiceNumber, string description, ICustomer customer, InvoiceDalc invoiceDalc/*Dependency*/)
                : this(invoiceNumber, description, customer)
            {
                this._invoiceDalc = invoiceDalc;
            }
    
            public Invoice(int invoiceNumber, string description, ICustomer customer, DateTime issueDate, Status status, List items, InvoiceDalc invoiceDalc)
                : this(invoiceNumber, description, customer, issueDate, status, items)
            {
                this._invoiceDalc = invoiceDalc;
    
            }
    
            public Invoice(InvoiceDalc invoiceDalc)
            {
    
            }
    #endif
        }
    }
    


    我们进行了构造函数注入(或任何其他方法注入)

  3. 在 #if DEBUG 中


    确保此代码仅在调试模式下工作,因此您的类在发布模式下

    将是安全的,并封装所有细节和依赖项

  4. 分部类


    将所有额外代码放入单独的文件中,并将所有这些文件收集到一个特殊的文件夹中,您

    可以在发布前将其排除。现在我们可以使用此单元测试代码测试此对象。

    [Test]
            public void InvoiceTestUpdate_UsingDalcMock2()
            {
                IInvoiceDalc invoiceDalcMock = new InvoiceDalcMock();
                Invoice invoice = new Invoice(123, "description test", customerMock, invoiceDalcMock);
                
                int count1 = invoice.Items.Count;
                invoice.Items.Add(new InvoiceItem(45, "ss", 55, 23.4m));
                invoice.Update();
                int count2 = invoice.Items.Count;
                Assert.IsTrue(count2 == count1 + 1, "Item Count");
    
            }
    


    让我们看看新设计和少量设计指南对

    测试结果

    图 12,可测试应用程序,只会因有缺陷的代码而失败,而不是因容器代码而失败。

    testable_applications/testResultForGoodTest.GIF

    这是一个很棒的结果

    乍一看,您会发现发票的 Update 方法和

    客户的 Debt 方法失败了,而其他所有代码都运行良好。

    过了一会儿你会发现,使用 DalcMock 的 Update 方法运行良好,

    这意味着问题出在 Dalc 方法的 Update 方法中,而不是 Invoice Update 方法中。

    方法,如果你编写 InvoiceDalc 单元测试,你的代码将在 Update 方法中失败。

    结论

    在创建单元测试之前,您的代码应该可测试。为了使您的代码

    可测试,您必须遵循可测试应用程序的指导原则。
© . All rights reserved.