测试驱动开发的探讨

为什么进行测试驱动开发... 1

利用NUnit进行测试驱动开发... 2

单元测试模型... 3

单元测试原则... 4

单元测试的原理... 5

Mock对象的出现... 7

动态Mock对象... 9

常见的验证模式... 11

测试驱动开发所引发的争论... 12

小结... 13

 

为什么进行测试驱动开发

测试驱动开发TDD来自于Kent Beck提倡的极限编程理论,是其12个指导原则中重要的一项。抛开极限编程,单就测试驱动开发本身来看,这确实是一个很有意思而且有意义的话题。它可以在很大程度上提高代码和设计的质量。

测试驱动开发中的测试主要是单元测试,即把类作为测试的基本单位,验证它对外提供的功能是否符合接口规范。而测试驱动开发就是指在设计时就要考虑如何进行测试,在编写功能代码前首先编写测试代码,在功能代码写完后,通过测试代码进行验证。即:先确定目标的验收标准,再去实现目标,最后通过验收标准检查是否达到目标。

测试驱动开发有几个好处:

1.  提高代码质量:可以从系统内部进行测试,覆盖率更广,尤其在用户界面层占整个系统比例较小的情况下,更可以提供全面的测试。

2.  在设计时减少类的耦合度:我们都知道,高内聚低耦合是优良设计的基本原则,一个好的设计应该尽量符合这个原则。测试驱动开发的一个好处就是在设计时强迫解除类与类之间的耦合,因为不解耦就无法进行测试。同时测试驱动给你提供另外一个角度——跳出实现者的思维,从使用者角度——审视你编写的类,有助于强化面向接口开发的编程模式。

3.  自动化测试过程:将重复性的劳动转向自动化,减少对人工的依赖。这带来的好处不仅仅是自动化,想象一下,项目即将发布,而你在代码中发现了一处隐患,尽管你清楚如何去修改,但你会去做吗?测试部已经对加班抱怨多多,而且为了这点小事,做一次全面测试有点小题大做。不做测试?天晓得改完以后会出什么事。算了吧,还是忘了这件事吧,下个版本再说。现在,如果你有了自动测试脚本,这些就不成问题了,放心大胆的改吧,跑一遍测试脚本,5分钟后就可以知道有没有问题了。

4.  为设计和开发提供明确的目标:在设计和开发初期,人的思维往往是发散的,经常是在刚有了一个模糊的设计思路时,就会丧失耐心再进一步细化,而是希望尽早进入编码阶段。而测试驱动可以强迫你进一步明确目标,直到它是可实现的。

5.  减少集成问题,降低纠错时间。一个有经验的开发人员知道,当出现错误时最困难的是如何找到问题的所在,而解决问题常常很容易。我们经常用两天的时间找到bug,然后用10分钟去修改好。为什么这样?因为它被埋藏在众多的对象及其复杂交互之中。搜索的范围越小,越容易找到问题。而单元测试就是缩小搜索范围,有效发现问题的捷径。

 

下面我们就如何进行测试驱动开发进行一些理论上的探讨,并结合实际例子作为参考。

利用NUnit进行测试驱动开发

xUnit家族是单元测试的经典工具包,我们以NUnit为例,解释一下如何来做单元测试。


单元测试是通过一系列测试脚本实现的。开发人员必须手工编写测试脚本,模拟真实的运行环境,并调用项目或产品类库中的被测试对象。这个过程中只编写测试脚本,不需要对项目或产品类库做任何修改。相近的测试脚本可以组成一个类,每个测试脚本就是这个类的一个方法,也叫做一个测试用例。众多的测试类就组成了单元测试类库

       .Net提供了一种定制特性(custom attribute)的机制,开发人员可以这种机制定义一些信息,并应用到各种元数据表项上(类、方法或属性等)。在NUnit中常见的特性标签有:

特性标签

被标注对象

作用

TestFixture

标识当前是一个测试类

Test

方法

标识当前单元是一个可执行的测试用例(方法)

SetUp

方法

用于搭建测试环境。在每个测试用例执行前都要先被调用。一个测试类中只能有一个SetUp方法

TearDown

方法

用于清理测试环境。在每个测试用例执行后被调用,一个测试类中只能有一个TearDown 方法

ExceptedException

方法

执行当前测试用例时希望得到的异常

 

开发人员必须为单元测试类库中每一个测试类和测试用例打上这种特性标签。当NUnit执行测试时,首先加载单元测试类库编译后的DLL,通过这些特性标签找到测试类和测试用例,并利用反射机制执行这些测试用例。开发人员在编写这些测试用例时都会调用Assert类进行结果验证,如果验证失败,就会由Assert类弹出异常。当NUnit执行测试用例时这些异常就会捕捉到,并亮出红灯以示警告。NUnit执行测试用例的过程可以用下面流程图简单表示:

关于NUnit的使用已经有很多相关文章和书籍介绍,在这里不再做重复工作了。我们更希望抛开具体的使用,从理论角度做一点深入的研究,探讨测试驱动开发对设计和开发模式产生的影响。

单元测试模型

设计时我们经常会参考一下现有的设计模式,可以帮助我们理清思路,把握方向。而在设计单元测试时一个固定的测试模型可以让我们先抛开一些细枝末节,抓住主要脉络。

在一个单元测试模型里主要有3种元素,测试用例、被测试对象和第三方对象。

1.         测试用例:它会搭建一个测试运行环境,模拟实际环境调用被测试对象,并检查被测试对象是否实现了约定的功能。

2.         被测试对象:被测试对象就是需要被测试的类/对象,它应该提供一组方法和属性,按约定实现功能。

3.         第三方对象:被测试对象在实现功能时总会或多或少的调用其它类和方法,帮助实现自己的功能,这些被调用的对象叫做第三方对象。这些对象可能是开发人员自己编写或者是第三方厂商提供的接口。它们往往是自动测试的关键和障碍。

 

在一个简单的模型里也可能只包含测试用例和被测试对象。


单元测试原则

开发单元测试代码时应该遵循一定原则,而这些原则也是相互依赖的。

1.  内外测试原则:从被测试对象内外两个角度设计测试用例。

2.  测试独立原则:该原则包括两个含义:一个含义是每个测试用例应该是独立的,用例之间不要有依赖和关联。另一个是隔离被测试对象和第三方对象,只验证被测试对象的功能实现,而不去验证第三方对象的功能。

3.  测试优先原则:在设计和编写被测试对象时,就要考虑它是不是可以被测试。如果难以测试,就有可能被测试对象的功能不单一,比较复杂;或者对第三方对象依赖得太强,这时需要对被测试对象进行重构,使得它可以测试。

 

为什么单元测试要从内外两个角度考虑?什么是内,什么又是外呢?

我们先来解释一下内外的概念。所谓外就是不考虑内部实现机制,只是从外部看被测试对象实现的效果或造成的影响。而内就是检查内部代码上是否按照设计的逻辑运行。这就像黑盒测试和白盒测试,从不同的角度出发。

一个外部的例子是对数学运算类Math的测试,Math有一个加法函数Add(int a, int b),它会返回一个值表示加法的结果。这样我们可以说,当Add接收到12时,返回结果3Add就会被假定实现了正确的功能。这是因为Add有一个返回值,而这个返回值是整个函数功能的体现,换句话说,如果返回值正确,整个函数就是正确的。外部测试也不仅仅是检查返回值,还可以通过检查第三方对象的状态和数据的变化判断,比如:OrderSave()方法,可以通过读取数据库,检查是否正确执行了保存功能。

不幸的是,在实际的开发环境中,返回值并非总能代表了一切。很多实现了约定功能的方法没有返回值。从第三方对象也常常难以取得状态和数据,因为它们常常在被测试对象的肚子里使用后就释放了。那如何校验这类被测试对象或方法呢?这就需要孙悟空钻到铁扇公主的肚子里,无论铁扇公主说什么或听到什么,都能一清二楚。这时就需要从内部角度考虑了。假设我们有一个Order类,它有一个Save()的方法。在Save()方法中,调用了数据存取层的一个类DbServiceExcuteSql(string sql)方法将数据保存到数据库中(如图)。

 

在这里有两种方式可以判断Save()是否正确执行:

1. 调用Save()方法后检查数据库是否作了正确修改;

2. 检查ExecuteSql() 接到的参数sql是否是update语句

前一种方式是从外部测试功能,但这样会提高测试复杂度,造成搭建测试环境比较困难(比如实际运行环境是从web.config中取得数据库连接字符串,而在测试环境中甚至都没有web.config。),而且判断执行结果和清理运行环境也比较麻烦。这样做违反了测试独立原则,因为检查数据库结果实际上是对Order.Save()DbService.ExecuteSql()进行校验,在这里对DbService的测试是冗余的。而第二种方法就比较简单,它假设DbService总是能正确执行自身功能。那剩下的就只是检查sql语句,不涉及到具体的运行环境问题。

 

测试独立原则要求减少测试代码之间的依赖性,每个测试用例应该是独立的、封闭的,同时它也不应该对测试环境造成污染。比如在测试Order.Save()方法时,应该在用例结束后删除测试中创建的记录,避免对其它测试用例或下一次测试产生影响。当然,你还是可以在几个测试用例中提取共用的代码,比如搭建测试环境,准备测试数据,甚至对最终结果的验证,从而减少“拷贝代码”的坏味道。

       测试独立原则还要求尽量只验证被测试对象的功能,它的一个重要假设是在测试用例中只有被测试对象是有问题的、需要验证的,而其它第三方对象(包括调用它的和它调用的对象)都是可靠的、没有问题的。这种假设可以大大减少测试的复杂性,降低测试成本。

测试独立原则会受到被测试对象设计方案的影响,高耦合的设计实现方案会直接破坏该原则,甚至导致被测试对象整个功能无法测试。我们经常会听到有人抱怨编写的代码无法自动测试,怀疑测试驱动的可行性,其实高耦合的设计或编码之后再实现测试的思路(我称之为测试被动)往往是导致无法自动测试的重要原因。要想实现自动测试,必须在设计和开发时就遵守测试优先原则,即开发代码前就要编写测试用例,确保被测试对象使可以测试的。

单元测试的原理

单元测试只有一个目的,就是验证判断被测试对象能否正确工作。说起来简单,怎么做呢?这里有一个很简单的测试模式。如果说单元测试模型是一个静态模型,就像UML中的类图,那么测试模式就是一个动态模型,像UML中的顺序图。无论在设计测试用例时,还是在最终编写的测试代码中,都要始终有明确的顺序观念。

一个测试用例至少包括3个步骤:

a)         定义期望值

b)        调用被测试对象或方法

c)        验证测试结果,比较期望值与实际值。

在一些复杂的或依赖较多的测试用例中,还会包括搭建测试环境清理测试垃圾的工作。

在了解这些步骤之前,我们先明确一下测试结果这个词。顾名思义,测试结果就是运行测试后得到的结果,而单元测试的工作就是判断实际的测试结果是否与期望的相符。如何对两个结果进行比较就变成一个很关键的问题,。因此我们提出一个测试原则:测试结果必须是可以验证的。什么样的结果才算是可以验证的呢?又用什么来验证呢?

我们可以借助下面两种方式对测试结果进行验证:

1.  利用NUnitAssert类,对测试对象或方法的返回结果、或其它对象的状态或数据进行验证。

2.  使用Mock对象,检查调用第三方对象和方法的过程是否正确,包括检查入口参数,调用顺序和次数。

 

定义期望值

运行测试前首先要确定的是调用被测试对象或方法后希望得到的测试结果。定义明确的、可验证的期望值是单元测试的第一步,而且在开始设计和编写测试用例前就要做。通过明确期望值可以帮助你设计测试方案,制定一个可行的测试用例。

调用不同的入口参数或次序会产生不同的结果,所以期望值可能不只一个。

你不仅要定义正常工作时的期望值,也要考虑出现异常情况下的期望值。

期望值可以不仅仅是方法的返回值,也可能是弹出的异常、第三方对象的状态或值的变化(如新增单据时数据库记录的增加)。如果你使用Mock对象,期望值可能就是Mock对象方法的入口参数,调用顺序和次数。

 

执行被测试对象和方法

       在复杂系统或设计糟糕的情况下,执行被测试对象和方法并不是件容易的事情。你需要在设计被测试对象时尽量将它独立于实际运行环境,这样才能保证在测试环境中可以工作。这也是为什么要测试驱动开发(TDD)而不是开发后测试的一个重要原因。

尽量使被测试对象不依赖于难以模拟的环境。如:HTTP运行时,数据库等。

对第三方对象的依赖尽量基于接口而不是具体类。

 

验证测试结果

       验证测试结果就是将期望值和实际值进行比较。最核心也是最基本的比较方法就是Assert类。它通过了一系列的方法比较期望值和实际值。NUnit提供了下面方法进行验证:

q        AreEqualobject expected, object actual: 比较expectedactual对象是否相等

q        AreSame (object expected, object actual): 判断两个对象是否指向相同的引用

q        IsTrue( bool condition) : 条件测试,断言条件值为真

q        IsFalse(bool condition) : 条件测试,断言条件值为假

q        IsNull(object anObject) : 断言对象为Null

q        IsNotNull(object anObject) : 断言对象不为Null

 

测试方式和结果的多样性必然带来验证方式的复杂性。但是再复杂的验证也不过是Assert方法的组合,它将复杂的验证过程分解成一个个较小的验证步骤和单元,直到可以通过Assert方法进行比较和验证。因此你可以针对某些固定的验证过程,封装自己的验证方法,便于日后使用。比如动态Mock对象的Verify方法,就是封装了对调用函数的入口参数、调用顺序和次数的验证。

 

Mock对象的出现

在单元测试原则中我们提出应该从内外两个角度对被测试类进行功能校验。从外部测试功能可以使用NUnitAssert类,判断函数的返回值是否符合期望。那么从内部角度如何实现呢?如何才能判断测试调用对象是否被正确的调用呢?

       重新回到Order.Save()方法,我们前面说可以通过检查DbService.ExecuteSql()是否接受到正确的参数,从而判断Order.Save()的方法是否实现正确。可是如何检查呢?好像只能在ExecuteSql()方法中才能做这件事,这样做可是污染函数代码啊。最好有办法可以让Order在实际运行环境中调用真正的ExecuteSql(),而在测试环境中调用另外一个ExecuteSql()

GoF的《设计模式可复用面向对象软件的基础》(P12)中为我们提供了解决方案,即:

针对接口编程,而不是针对实现编程。

如何理解这句话呢?它的含义是当使用一个第三方的类/对象时应该依赖接口,调用第三方类对外发布的接口,而不是具体的实现类。比如Order所依赖的数据库存取对象DbService应该是个接口或抽象类,在实际运行环境中调用真正的数据库存取类,而在测试运行环境中编写DbService的另外一个实现类,这样才可以“偷梁换柱”,在不同的运行环境使用不同的实现对象。据此我们对原来的设计方案作一个重构,下图是重构前和重构后的两个设计类图的对比:

在新的设计方案中我们定义了一个IDbService的接口,SqlService为其提供具体的实现。Order所依赖的不再是一个具体的类,而是IDbService接口。而MockServiceIDbService的在测试环境中的另一个实现。与SqlService不同的是,它不做任何事,只是检查接收的sql参数是否正确。在NUnit的测试代码如下:

 

public class MockService : IDbService

{

         public void ExecuteSql(string sql)

         {

           Assert.AreEqual(“update orders set orderdate=’ 2003/05/01 ’ where orderid=1”, sql);

         }

}

[TestFixture]

public class TOrder

{

         [Test]

         public void TestSave()

         {

           Order ord = new Order(1);

           ord.OrderDate = new DateTime(2005,5,1);

           ord.Service = new MockService();

           ord.Save();

         }

}

 

Mock对象有助于保持测试环境的简单和干净,减少对实际环境的依赖。同时还无形中在设计时减少了类与类之间的依赖,实现松耦合。

 

手工编写Mock对象往往是一项枯燥的工作,因为你必须:

q        实现接口的每一个方法,而且要保证接口变化时保持同步;

q        为每一个测试用例实现一个Mock类,进行其特殊的验证逻辑;

q        测试用例和结果验证要分别写在两个类代码中,还要保持逻辑上的一致性;

感觉怎么样,为了减少被测试对象的依赖性,好像又在测试代码中增加了更多的依赖和复杂性。如果对Mock对象缺乏有效的管理方式,随着时间推移,测试代码会结构会越来越复杂,内容会越来越混乱。这时候另一个有趣的概念——动态Mock对象——就出现了它可以帮助你解决这些问题。动态Mock对象可以做到什么呢?它能够:

q        根据接口定义动态创建Mock对象;

q        可以模拟函数调用,设置期望的函数入口参数、执行顺序、次数和返回值;

q        提供Verify方法验证函数的入口参数、执行顺序和次数;

 

动态Mock对象

下面我们通过NMock举例,介绍动态Mock对象的使用方法。读者可以在http://www.nmock.org/了解更多信息。

NMock通过代理实现动态创建类,所谓的“动态”就是只要你给它一个接口定义,它就可以创建出一个实现该接口的对象实例,而不需要你写一句代码。它在执行被测试方法前设置期望值,在执行后统一验证。NMock还提供了一个Contraint库,可以更灵活的设置期望值。

NMock推荐一个标准的测试步骤,可以作为模板:

1)      Setup: 搭建测试环境,初始化Mock对象

2)      Expectations: 设置你希望发生的方法调用

3)      Execute:执行测试,调用被测试的方法

4)      Verify:验证方法是否按期望的方式被调用

这里有两个“方法”不要弄混,一个是被测试对象的方法;一个是被测试方法又去调用第三方对象(接口)的方法。而NMock设置和验证的都是第二个方法。

我们通过上面提到的OrderDbService举例,说明如何使用NMock编写测试用例。事先准备一个接口定义是必要的,幸好之前我们已经有IDbService

public interface IDbService
{
       void ExecuteSql(string sql);
}


1.  新建一个测试用例,搭建测试环境,并通过DynamicMock创建一个接口类的实例对象。      

2.  设置期望被调用的方式和返回值,NMock为此提供了一组Expect方法:

a)        Expect ( string methodName , params object[] args )设置期待调用的方法和参数列表,该函数没有返回值。

b)        ExpectAndReturn (string methodName , object returnVal , params object[] args ):设置期待调用的方法、参数和返回值

c)        ExpectAndThrow (string methodName , Exception exceptionVal , params object[] args ):设置期待调用的方法参数以及期待产生的异常。

d)        SetupResult (string methodName , object returnVal , params System.Type[] argTypes ):对方法调用设置默认值,因为有时并不知道该方法会被调用多少次。通过设置SetupResult可以对期待方法始终返回同一值。而argTypes是为了应付方法重载时,不知道该调用哪一个而准备的。

因为入口参数可以多个,而返回值只有一个,所以参数列表始终放在最后一个,方便多个参数时使用。

在我们的例子中,使用Expect设置期望值:

如果把Math也做成接口,它的方法调用可能是:

在这里, Contraint.IsAnything是个挺有用的类,当你确实不知道或者不关心某个参数的实际调用值时,可以用它告诉NMock不用验证该参数的期望值和实际值。

3.  设置期望值后,就要开始运行被调用方法了,很简单哦。

4.  验证结果。这一步就更简单了。因为NMock已经提供了Verify()方法,它可以帮助验证实际的方法调用和期望是否相符。

如果你的设计和开发方式是基于接口的,就会发现NMock是个非常有用的工具,你可以用它设计很多轻量级的测试用例。EasyMockNMock一样,也可以实现动态Mock对象,详细介绍可以参考David AstelsRonJeffries编著的《测试驱动开发——实用指南》。这二者相比大同小异,只是在细节处理上略有不同。不过总的来说,我比较喜欢NMock,它设置期望值的代码比较简洁,更容易读懂;NMockContraint库也很有用,可以设置一些特殊的期望值,像IsAnyting这样的期望值在EasyMock中就做不到。不过EasyMock可以设置函数的调用次数,这个功能在NMock中还没有发现。

常见的验证模式

下面通过例子列举了一些常见的验证模式,仅供参考。在实际应用时往往是这些模式的混合使用,从而实现复杂的自动测试。

 

1.  直接比较函数的实际返回值和期望值。

2.  比较第三方对象状态和值的变化

3.  通过ExpectedException比较期望抛出的异常和实际抛出的异常

4.  通过手工编写Mock对象验证调用的入口参数

5.  利用动态Mock对象进行校验

 

测试驱动开发所引发的争论

1. 我们已经有了专门的测试人员,不需要程序员再去浪费时间。

传统的测试方法主要通过测试人员手工对程序进行黑盒测试。但由于测试人员不了解开发人员的实现方法,只能通过用户界面输入数据,根据反馈判断是否正确。这样得到的测试往往不全面,会产生遗漏,而且对一些用户界面少的系统或者后台任务很难测试。另外由于采用手工测试,一次全面地走查需要较长的时间,尤其在项目后期,将耗费大量的人力。这往往导致两种结果:要么缩减测试时间,提交给用户一个没有经过全面测试的系统;要么减少修改,减缓项目后期的开发速度。

测试驱动开发则致力于测试过程的自动化,减少人工的依赖,提高测试速度。自动化的好处不需要多说,只要看看现代工业的发展,很明显就是生产从手工向自动化的过程。如果项目从开始就建立起一个自动化的测试脚本,在开发过程中不断地进行积累和完善。开发人员每次在修改代码后,通过运行自动测试脚本,马上就可以发现功能是否正常运转、修改对哪些模块产生了影响。而且开发人员了解内部的实现过程,测试会更加全面。

2.  项目太紧,正常代码还写不完呢,哪有时间去写测试啊。

从个人经验来看,如果功能编码时间是1的话,测试编码的时间大概也需要1,测试覆盖率可以达到60%-80%左右,这个数字仅仅作为一个参考,只是希望能出一个感性认识。如果覆盖率越高,相应的时间成本也会越高。这个简单的估计意味着开发时间将会因测试驱动而加倍。

在项目进度有压力的情况下,这是一个很有争议的话题。其实这个问题的讨论的焦点并非测试驱动,而是如何在代码质量和项目进度之间进行权衡。测试驱动仅仅是提高代码质量的一种方法,提高质量还是追赶进度是项目管理者所面临的一个选择。但要提出的是,代码质量问题是存在的,你现在可以捂上眼睛,假装它不存在。但在项目后期和验收时它还是会跳出来,而那个时候解决的成本是大于现在解决的。如果您进行的是产品开发,建议采用测试驱动保证代码的质量。

3. 很多地方没办法写自动测试,像Web页面。

在测试环境中有时无法完全加载或模拟真实的运行环境,将会导致对环境依赖较强的代码无法测试或很难测试。所谓很难测试,就是测试编码的时间会远远超出功能编码,或者功能代码修改时,测试代码要做大量的工作进行调整。

这种情况下是否要放弃测试驱动呢?如果你还不想接受测试驱动,这是一个很好的借口。如果你是一个测试驱动的爱好者,就会找出很多的解决办法。

比如Web页面很难进行测试,这时应该避免在页面代码中编写大量的逻辑,将这些逻辑从页面中抽取出来,放到单独的业务类中封装,这本身也是一个很好的设计方法。如果你的开发依赖了第三方类库(如数据库),在测试代码中无法或很难判断执行的结果。那就对第三方类库进行抽象,提取出公开接口,并修改你的设计,依赖于这个接口。然后建立Mock类,帮助你验证测试结果。

小结

测试驱动开发是一种新的开发方式,它促使开发人员从不同的角度对类/对象进行思考,帮助产生高质量的代码。在测试驱动中并没有引入新的设计模式,但它却可以降低代码间的耦合性,实现高内聚、低耦合的设计目标。

测试驱动是一个很好的开发方式,但是再好的东西运用不当也会产生负面效果。测试驱动中有很多话题值得深入讨论:如何把握测试用例的度,用最少的代码,实现最有效的测试?如何组织测试用例中的测试数据?如何针对某类项目或产品,设计相应的自动测试架构等等。本文对测试驱动开发作了一点理论上的研究,希望能够展开更高水平的探讨,从而推动测试驱动在国内的普及和应用。

 

参考书籍:

《单元测试之道C#——使用Nunit》,作者:Andrew Hunt,David Thomas

《测试驱动开发——实用指南》,作者:Dave Astels

 

 
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值