| 我们希望采用并行的方式在本地运行单元测试,从而减少测试时间,提高开发人员的工作效率。我们使用了线程池来提供多线程的并行任务。通过配置启动多个线程,并以程序集为单位,启动TestRunner: var executorWrapper=newExcetorWrapper(assemblyName,null,false); var testRunner=newTestRunner(executorWrapper,newRunnerLoggerWrapper()); testRunner.RunAssembly(); 其中的RunnerLoggerWrapper是一个自定义的类,实现了Xunit的IRunnerLogger。XUnit的使用并非本文描述的内容,在此略过。 因为是以程序集为单位,所以我们在启动多线程之前,会事先将需要运行的程序集放到一个队列中,然后在启动多线程之后,执行出队列操作。多线程的运行代码如下所示: privatestaticManualResetEvent[] resetEvents; privatestaticQueue<String>assemblyQue; privatestaticreadonlyObject LockAssembly2Queue=newObject(); publicvoidRun() { for(var index=0; index<numThreads; 
                          index++) { resetEvents[index]=newManualResetEvent(false); ThreadPool.QueueUserWorkItem(DoWork, 
                          index); } WaitForAllManualEvent(); } privatevoidWaitForAllManualEvent() { if(Thread.CurrentThread.ApartmentState=ApartmentState.STA) { foreach(var manualResetEventinresetEvents) {     WaitHandle.WaitAny(newWaitHandle[]{manualResetEvent}); } } else { WaitHandle.WaitAll(resetEvents); } } privatestaticvoidDoWork(Object index) { Thread.CurrentThread.ApartmentState=ApartmentState.STA; while(true) { stringcurrentAssemblyName=null; lock(LockAssembly2Queue) {     if(assemblyQue.Count!=0)     {         currentAssemblyName=assemblyQue.Dequeue();     }     else     {         resetEvents[(int)index].Set();         Console.WriteLine("Exited current 
                          thread:{0}", Thread.CurrentThread.Name);         break;         } } if(currentAssemblyName!=null) {     newTestRunnerWrapperWithAssembly(currentAssemblyName).Runner(); } } } 由于要测试的程序集比较多,采用这种并行方式可以极大地提高运行效率。由于单元测试彼此是独立的,在并行运行时,互相没有干扰。这是我们实现判断的结果。一切看起来很美好,但在真正运行时,却出现了大量的死锁。异常信息为: Transaction (Process ID) was deadlocked 
                          on resources with another process and has been chosen 
                          as the deadlock victim. Rerun the transaction. 在我们的单元测试中,大多数测试需要访问的资源都是在内存中进行,但有一部分单元测试必须与数据库通信,对数据表进行读写。除了极少数特殊的测试用例外,对数据表的操作都放在事务中进行,并在执行完毕后,通过回滚事务,避免对真实数据的提交,保证单元测试不会影响数据库。  注:单元测试应该访问数据库吗?这其实还有待确认。在《修改代码的艺术》一书中,Feathers这样写道:  单元测试运行得快。运行得不快的不是单元测试。  有些测试容易跟单元测试混淆起来。譬如下面这些测试就不是单元测试:  (1)跟数据库有交互;  (2)进行了网络间通信;  (3)调用了文件系统;  (4)需要你对环境作特定的准备(如编辑配置文件)才能运行的。  以上可以看到Feathers的态度是单元测试不应与外部资源进行交互。显然,如果出现了这些交互,就应该采用Mock的方式来模拟对外部资源的访问。然而,某些实现功能却是与外部资源息息相关,又或者我们测试的目的本身就是验证对外部资源的访问是否正确。从测试的范围来看,它们仍然算是单元测试,但因其特殊性,而应该将这些测试放到系统测试的范畴。在持续集成中,我们常常用金字塔来表示单元测试、系统测试和集成测试的数量。如下图所示: 
  单元测试的数量最多,如果还需要访问外部资源,就会严重影响运行单元测试的速度。关于单元测试、Mock等内容,我希望在以后的文章里详细论述。  在我们的项目中,是通过注入Fixture的形式生成测试数据。例如,我们可能希望注入Client、Associate等对象,从而完成对某些行为的测试。例如:  [Fixture(typeof(client_hastings))]public Client client;
 [Fixture(typeof(Samuel))] public Associate Samuel;  通过Fixture准备数据时,如果采用了持久化方式,则意味着需要对数据表进行操作。如上代码就可能操作多张表,例如对Client表和Associate表进行写操作。由于单元测试采用并行方式进行。假设存在两个单元测试均需要对Client和Associate注入Fixture,生成测试数据;并且不幸的是,这两个测试用例准备数据的顺序刚好相反,即A测试用例的顺序为Client->Associate,B测试用例的顺序为Associate->Client,就可能发生死锁。  为什么?让我们分析数据库发生死锁的情况。它必然是多个进程(或线程)对两个或两个以上的资源形成了交叉访问。例如进程A在占有了资源1的同时,还需要访问资源2;与此同时,进程B在占有了资源2的同时,需要访问资源1。由于资源1已经被进程A占用,无法释放,进程B就会等待;而进程A希望访问的资源2又被等待中的进程B持有;二者互不相让,最终产生死锁。这正是并行运行单元测试导致死锁的根本原因。我们可以运行SQL 
                          Server Profiler来监视数据库的执行。注意,倘若需要跟踪死锁的情况,需要在Trace Properties中勾选“Deadlock 
                          Graph”和“Lock: Deallock”选项,如下图所示: 
  创建Trace后,利用并行方式运行单元测试,可能得到这样的Deadlock 
                          graph:  
 图中,椭圆代表进程(线程),矩形代表资源。左边的椭圆打了一把叉,说明是竞争失败的进程(线程)。从椭圆出发,箭头所指的资源,代表进程请求的资源;而发出箭头的资源,则代表箭头指向的进程持有该资源。可以发现,两个进程与两个资源之间的箭头,事实上形成了一个封闭的环。这正是死锁的典型表现。  当我们将单元测试的Fixture注入顺序保持一致时,这样的死锁就能够避免了。这是一种限制,它很难被编写单元测试的开发人员所接受,即使勉强接受,仍然很容易疏漏。因此,我们的结论仍然是“不到迫不得已,单元测试不要访问外部资源”。或者说,我们可以将访问外部资源的单元测试,看成是特殊的单元测试,如果确实需要并行运行测试,以提高测试效率,可以通过引入多个Agent,以物理方式隔离资源,避免出现资源的争用导致死锁。  那么,这是否意味着我们的产品代码不够严谨,没有充分考虑并发的情况呢?不完全对。因为这里产生死锁的时机发生在准备测试数据的阶段,实际操作时,一般不会出现这种频繁对多张表进行操作的情况。然而,即使几率很低,始终存在死锁的隐患,这就为我们的开发敲响了警钟。因此在开发过程中,有必要通过对业务的分析,制订一些指导原则,通过规范写数据表操作的顺序,避免出现死锁。这是这次并行运行单元测试给我们带来的启示。
 |