.NET单元测试的艺术测试代码(下)

 

假设我们得LogAnalyzer不仅需要调用WebService,而且如果WebService抛出一个错误...

(点击上方蓝字,可快速关注我们)

来源:Edison Chou

链接:http://www.cnblogs.com/edisonchou/p/5447812.html

2.3 同时使用模拟对象和存根

假设我们得LogAnalyzer不仅需要调用Web Service,而且如果Web Service抛出一个错误,LogAnalyzer还需要把这个错误记录在另一个外部依赖项里,即把错误用电子邮件发送给Web Service管理员,如下代码所示:

   if (fileName.Length 8)

{

try

{

// 在产品代码中写错误日志

service.LogError(string.Format("Filename too short : {0}", fileName));

}

catch (Exception ex)

{

email.SendEmail("a", "subject", ex.Message);

}

}



可以看出,这里LogAnalyzer有两个外部依赖项:Web Service和电子邮件服务。我们看到这段代码只包含调用外部对象的逻辑,没有返回值,也没有系统状态的改变,那么我们如何测试当Web Service抛出异常时LogAnalyzer正确地调用了电子邮件服务呢?

我们可以在测试代码中使用存根替换Web Service来模拟异常,然后模拟邮件服务来检查调用。测试的内容是LogAnalyzer与其他对象的交互。



Step1.抽取Email接口,封装Email类

public interface IEmailService

{

void SendEmail(EmailInfo emailInfo);

}

public class EmailInfo

{

public string Body;

public string To;

public string Subject;

public EmailInfo(string to, string subject, string body)

{

this.To = to;

this.Subject = subject;

this.Body = body;

}

public override bool Equals(object obj)

{

EmailInfo compared = obj as EmailInfo;

return To == compared.To & Subject == compared.Subject

&& Body == compared.Body;

}

}

Step2.封装EmailInfo类,重写Equals方法

 public class EmailInfo

{

public string Body;

public string To;

public string Subject;

public EmailInfo(string to, string subject, string body)

{

this.To = to;

this.Subject = subject;

this.Body = body;

}

public override bool Equals(object obj)

{

EmailInfo compared = obj as EmailInfo;

return To == compared.To & Subject == compared.Subject

&& Body == compared.Body;

}

}



Step3.创建FakeEmailService模拟对象,改造FakeWebService为存根

 public class FakeEmailService : IEmailService

{

public EmailInfo email = null;

public void SendEmail(EmailInfo emailInfo)

{

this.email = emailInfo;

}

}

public class FakeWebService : IWebService

{

public Exception ToThrow;

public void LogError(string message)

{

if (ToThrow != null)

{

throw ToThrow;

}

}

}



Step4.改造LogAnalyzer类适配两个Service

  public class LogAnalyzer

{

private IWebService webService;

private IEmailService emailService;

public LogAnalyzer(IWebService webService, IEmailService emailService)

{

this.webService = webService;

this.emailService = emailService;

}

public void Analyze(string fileName)

{

if (fileName.Length 8)

{

try

{

webService.LogError(string.Format("Filename too short : {0}", fileName));

}

catch (Exception ex)

{

emailService.SendEmail(new EmailInfo("someone@qq.com", "can't log", ex.Message));

}

}

}

}

Step5.编写测试代码,创建预期对象,并使用预期对象断言所有的属性

 [Test]

public void Analyze_WebServiceThrows_SendsEmail()

{

FakeWebService stubService = new FakeWebService();

stubService.ToThrow = new Exception("fake exception");

FakeEmailService mockEmail = new FakeEmailService();

LogAnalyzer log = new LogAnalyzer(stubService, mockEmail);

string tooShortFileName = "abc.ext";

log.Analyze(tooShortFileName);

// 创建预期对象

EmailInfo expectedEmail = new EmailInfo("someone@qq.com", "can't log", "fake exception");

// 用预期对象同时断言所有属性

Assert.AreEqual(expectedEmail, mockEmail.email);

}





总结:每个测试应该只测试一件事情,测试中应该也最多只有一个模拟对象。一个测试只能指定工作单元三种最终结果中的一个,不然的话天下大乱。

三、隔离(模拟)框架

3.1 为何使用隔离框架

对于复杂的交互场景,可能手工编写模拟对象和存根就会变得很不方便,因此,我们可以借助隔离框架来帮我们在运行时自动生成存根和模拟对象。

一个隔离框架是一套可编程的API,使用这套API创建伪对象比手工编写容易得多,快得多,而且简洁得多。

隔离框架的主要功能就在于帮我们生成动态伪对象,动态伪对象是运行时创建的任何存根或者模拟对象,它的创建不需要手工编写代码(硬编码)。

3.2 关于NSubstitute隔离框架

Nsubstitute是一个开源的框架,源码是C#实现的。你可以在这里获得它的源码:https://github.com/nsubstitute/NSubstitute

NSubstitute 更注重替代(Substitute)概念。它的设计目标是提供一个优秀的测试替代的.NET模拟框架。它是一个模拟测试框架,用最简洁的语法,使得我们能够把更多的注意力放在测试工作,减轻我们的测试配置工作,以满足我们的测试需求,帮助完成测试工作。它提供最经常需要使用的测试功能,且易于使用,语句更符合自然语言,可读性更高。对于单元测试的新手或只专注于测试的开发人员,它具有简单、友好的语法,使用更少的lambda表达式来编写完美的测试程序。

NSubstitute 采用的是Arrange-Act-Assert测试模式,你只需要告诉它应该如何工作,然后断言你所期望接收到的请求,就大功告成了。因为你有更重要的代码要编写,而不是去考虑是需要一个Mock还是一个Stub。

在.NET项目中,我们仍然可以通过NuGet来安装NSubsititute:



3.3 使用NSubstitute模拟对象

NSub是一个受限框架,它最适合为接口创建伪对象。我们继续以前的例子,来看下面一段代码,它是一个手写的伪对象FakeLogger,它会检查日志调用是否正确执行。此处我们没有使用隔离框架。

 public interface ILogger

{

void LogError(string message);

}

public class FakeLogger : ILogger

{

public string LastError;

public void LogError(string message)

{

LastError = message;

}

}

[Test]

public void Analyze_TooShortFileName_CallLogger()

{

// 创建伪对象

FakeLogger logger = new FakeLogger();

MyLogAnalyzer analyzer = new Chapter5.MyLogAnalyzer(logger);

analyzer.MinNameLength = 6;

analyzer.Analyze("a.txt");

StringAssert.Contains("too short", logger.LastError);

}

现在我们看看如何使用NSub伪造一个对象,换句话说,之前我们手动写的FakeLogger在这里就不用再手动写了:

 [Test]

public void Analyze_TooShortFileName_CallLogger()

{

// 创建模拟对象,用于测试结尾的断言

ILogger logger = Substitute.For();

MyLogAnalyzer analyzer = new MyLogAnalyzer(logger);

analyzer.MinNameLength = 6;

analyzer.Analyze("a.txt");

// 使用NSub API设置预期字符串

logger.Received().LogError("Filename too short : a.txt");

}



需要注意的是:

(1)ILogger接口自身并没有这个Received方法;

(2)NSub命名空间提供了一个扩展方法Received,这个方法可以断言在测试中调用了伪对象的某个方法;

(3)通过在LogError()前调用Received(),其实是NSub在询问伪对象的这个方法是否调用过。

3.4 使用NSubstitute模拟值

如果接口的方法返回不为空,如何从实现接口的动态伪对象返回一个值呢?我们可以借助NSub强制方法返回一个值:

 [Test]

public void Returns_ByDefault_WorksForHardCodeArgument()

{

IFileNameRules fakeRules = Substitute.For();

// 强制方法返回假值

fakeRules.IsValidLogFileName("strict.txt").Returns(true);

Assert.IsTrue(fakeRules.IsValidLogFileName("strict.txt"));

}

如果我们不想关心方法的参数,即无论参数是什么,方法应该总是返回一个价值,这样的话测试会更容易维护,因此我们可以借助NSub的参数匹配器:

 [Test]

public void Returns_ByDefault_WorksForAnyArgument()

{

IFileNameRules fakeRules = Substitute.For();

// 强制方法返回假值

fakeRules.IsValidLogFileName(Arg.Anystring>()).Returns(true);

Assert.IsTrue(fakeRules.IsValidLogFileName("anything.txt"));

}



Arg.Any称为参数匹配器,在隔离框架中被广泛使用,控制参数处理。

如果我们需要模拟一个异常,也可以借助NSub来解决:

 [Test]

public void Returns_ArgAny_Throws()

{

IFileNameRules fakeRules = Substitute.For();

fakeRules.When(x => x.IsValidLogFileName(Arg.Anystring>())).

Do(context => { throw new Exception("fake exception"); });

Assert.Throws(() => fakeRules.IsValidLogFileName("anything"));

}



这里,使用了Assert.Throws验证被测试方法确实抛出了一个异常。When和Do两个方法顾名思义代表了什么时候发生了什么事,发生了事之后要触发其他什么事。需要注意的是,这里When方法必须使用Lambda表达式。

3.5 同时使用模拟对象和存根

这里我们在一个场景中结合使用两种类型的伪对象:一个用作存根,另一个用作模拟对象。

继续前面的一个例子,LogAnalyzer要使用一个MailServer类和一个WebService类,这次需求有变化:如果日志对象抛出异常,LogAnalyzer需要通知Web服务,如下图所示:



我们需要确保的是:如果日志对象抛出异常,LogAnalyzer会把这个问题通知WebService。下面是被测试类的代码:

 public interface IWebService

{

void Write(string message);

}

public class LogAnalyzerNew

{

private ILogger _logger;

private IWebService _webService;

public LogAnalyzerNew(ILogger logger, IWebService webService)

{

_logger = logger;

_webService = webService;

}

public int MinNameLength

{

getset;

}

public void Analyze(string fileName)

{

if (fileName.Length  MinNameLength)

{

try

{

_logger.LogError(string.Format("Filename too short : {0}", fileName));

}

catch (Exception ex)

{

_webService.Write("Error From Logger : " + ex.Message);

}

}

}

}



现在我们借助NSubstitute进行测试:

 [Test]

public void Analyze_LoggerThrows_CallsWebService()

{

var mockWebService = Substitute.For();

var stubLogger = Substitute.For();

// 无论输入什么都抛出异常

stubLogger.When(logger => logger.LogError(Arg.Anystring>()))

.Do(info => { throw new Exception("fake exception"); });

var analyzer = new LogAnalyzerNew(stubLogger, mockWebService);

analyzer.MinNameLength = 10;

analyzer.Analyze("short.txt");

//验证在测试中调用了Web Service的模拟对象,调用参数字符串包含 "fake exception"

mockWebService.Received().Write(Arg.Isstring>(s => s.Contains("fake exception")));

}



这里我们不需要手工实现伪对象,但是代码的可读性已经变差了,因为有一堆Lambda表达式,不过它也帮我们避免了在测试中使用方法名字符串。



四、小结

本篇我们学习了单元测试的核心技术:存根、模拟对象以及隔离框架。使用存根可以帮助我们破除依赖,模拟对象与存根的区别主要在于存根不会导致测试失败,而模拟对象则可以。要辨别你是否使用了存根,最简单的方法是:存根永远不会导致测试失败,测试总是对被测试类进行断言。使用隔离框架,测试代码会更加易读、易维护,重点是可以帮助我们节省不少时间编写模拟对象和存根。

参考资料



(1)Roy Osherove 著,金迎 译,《单元测试的艺术(第2版)》

(2)匠心十年,《NSubsititue完全手册》

(3)张善友,《单元测试模拟框架:NSubstitute》
【今日微信公号推荐↓】
更多推荐请看值得关注的技术和设计公众号
其中推荐了包括技术设计极客 和 IT相亲相关的热门公众号。技术涵盖:Python、Web前端、Java、安卓、iOS、PHP、C/C++、.NET、Linux、数据库、运维、大数据、算法、IT职场等。点击《值得关注的技术和设计公众号》,发现精彩!


    关注 DotNet


微信扫一扫关注公众号

0 个评论

要回复文章请先登录注册