Silverlight 中的无缝 WCF 服务消耗 - 第 3 部分:透明异步相对于单元测试的好处






4.78/5 (5投票s)
简化使用异步 WCF 服务调用的视图模型的单元测试。
引言
在 上一篇文章 中,我展示了透明处理异步 WCF 调用和使用协程如何极大地简化你的 Silverlight 应用程序代码。现在,是时候展示“透明异步”和协程如何极大地简化单元测试了。
必备组件
熟悉本系列文章的第一部分,尤其是第二部分。在本文中,我将使用第二篇文章中的方法和示例应用程序,因此你需要对此有牢固的理解。你还需要对单元测试和模拟框架(可能包括 NUnit、MS Silverlight Unit Test Framework 和 Rhino.Mocks)有基本的了解。如果你熟悉 MS Silverlight Unit Test Framework,知道如何使用它,并且了解它带来的问题,那么你可能会对阅读本文感兴趣,看看我提出的方法如何以一种非常不寻常的方式解决这些问题。
背景
“在为并发代码编写自动化(单元)测试时,你必须处理与测试异步执行的系统”
在 Nat Pryce 关于“异步系统的测试驱动开发”的演讲中,他很好地阐述了同步代码与异步代码的自动化(单元)测试之间的区别,并总结了异步代码自动化测试固有的主要问题。
同步测试
“被测系统”完成后,控制权会返回到测试。错误会立即检测到。
异步测试
“被测系统”继续运行时,控制权会返回到测试。“被测系统”会吞掉错误。通过系统未在某个超时时间内进入预期状态或发送预期通知来检测失败。
异步测试必须与“被测系统”同步,否则就会出现问题
- 不稳定的测试:测试通常通过,但偶尔会毫无明显原因地随机失败,通常在令人尴尬的时候。随着测试套件的增大,越来越多的运行包含测试失败。最终,几乎不可能获得成功的测试运行。
- 假阳性:测试通过,但系统实际上无法工作。
- 缓慢的测试:测试中充满了 sleep 语句,以便让系统赶上。一个测试中一两个亚秒级的 sleep 可能不会被注意到,但当你有成千上万个测试时,每一秒都会累积成数小时的延迟。
- 混乱的测试(代码):在测试中随意散布 sleep 和 timeout 会使测试难以理解:测试在测试什么被其如何测试所掩盖。
让我们来看看当前处理涉及异步 WCF 调用的视图模型逻辑的单元测试方法的上述问题是如何解决的,以及我在上一篇文章中开发的方法带来了什么。
现状
工具
首先,我想指出,目前主流的测试框架(如 MSTest、NUnit、xUnit 等)都没有内置对异步测试的支持。2008 年,微软将其内部 Silverlight 单元测试框架(支持异步测试)提供给所有开发人员。Silverlight Unit Test Framework,现在随Silverlight Toolkit一起发布,引入了许多特殊构造来帮助开发人员编写异步测试。目前,Silverlight Unit Test Framework (SUTF) 是唯一允许你为 Silverlight 应用程序编写异步测试的测试框架。
让我们来看看 Silverlight 开发人员社区目前是如何通过(SUTF 的帮助下)对处理异步 WCF 调用的视图模型进行单元测试的。
测试服务代理
暂时忘记我在本系列第一部分和第二部分中概述的方法。让我们按常规方式进行。我们有一个 WCF 服务,并通过 VS 的“添加服务引用”对话框生成了一个服务代理。这将是我们最终得到的视图模型代码
/// TaskManagementViewModel.cs
public class TaskManagementViewModel : ViewModelBase
{
Task selected;
public TaskManagementViewModel()
{
Tasks = new ObservableCollection<Task>();
}
public ObservableCollection<Task> Tasks
{
get; private set;
}
public Task Selected
{
get { return selected; }
set
{
selected = value;
NotifyOfPropertyChange(() => Selected);
}
}
public void Activate()
{
var service = new TaskServiceClient();
service.GetAllCompleted += (o, args) =>
{
if (args.Error != null)
return;
foreach (Task each in args.Result)
{
Tasks.Add(each);
}
Selected = Tasks[0];
};
service.GetAllAsync();
}
...
}
要使用 SUTF 编写异步测试,你需要从一个基类 `SilverlightTest` 派生,并用 `Asynchronous` 属性标记你的方法。通过从 `SilverlightTest` 类派生,你将获得对几个 `EnqueueXXX` 方法的访问权限。这些方法允许你“排队”代码块(以委托的形式),以及你想异步执行的代码。
例如,为了测试在首次激活时从服务查询当前任务,并且第一个任务自动被选中(即,列表中的第一个任务被高亮显示为活动选择),相应的 SUTF 测试可能如下所示
[TestClass]
public class TaskManagementViewModelFixture : SilverlightTest
{
[TestMethod]
[Asynchronous]
public void When_first_activated()
{
// arrange
var model = new TaskManagementViewModel();
// this will be used as a wait handle for call completion
bool eventRaised = false;
model.PropertyChanged += (o, args) =>
{
if (args.PropertyName == "Selected")
eventRaised = true;
};
// will not execute next queued code block until true
EnqueueConditional(() => eventRaised);
// act
model.Activate();
// assert (queue some assertions)
EnqueueCallback(() =>
{
Assert.AreEquals(2, model.Tasks.Count);
Assert.AreSame(model.Tasks[0], model.Selected);
});
// queue test completion code block (tell SUTF we're done)
EnqueueTestComplete();
}
}
我们暂且不谈视图模型代码的“美观”问题,而是讨论测试本身。虽然随着时间的推移,你会习惯所有这些特殊构造,并能够将它们过滤掉作为不必要的干扰,但恕我直言,当测试被所有这些特殊构造所污染时,很难理解测试的意图。
测试表达力损失不是唯一的问题。你仍然需要显式地与“被测系统”同步。Silverlight 单元测试框架需要知道异步调用何时*实际*完成(这样它就知道何时可以执行剩余的排队代码块)。
在上面的示例测试中,特定属性(“Selected”)的值更改被用作等待句柄,它向 SUTF 发出信号表明异步调用已完成(因此它可以继续进行)。这种惯用的、不幸的是经常使用的方法是脆弱的,并且会使测试代码变得混乱。想象一下,如果你的逻辑发生变化,并且在激活时不再需要选择第一个任务。如果你期望上面的单元测试在以下行中断
Assert.AreSame(model.Tasks[0], model.Selected)
那么你会惊讶地发现,测试并没有在该行失败断言,而是完全挂起。你需要手动调试才能找出原因。这里的问题是,使用了间接相关的指示作为调用完成的标准。属性值的更改是调用完成的*结果*之一,而不是一个*真实*的事实。当然,你可以采取更健壮的调用完成指示器,例如使用 `ManualResetEvent`(或类似的信号概念)。但那样的话,你就会用与主要功能无关的东西来污染你的生产代码,用那些仅仅是为了测试而存在的东西。
但最大的问题是,通过测试真正的服务代理,我们是在测试*真正的东西*!这会给我们带来各种各样的问题,例如
- 不稳定的测试 - 尽管行为仍然正常,但测试数据的更改会破坏测试。
- 缓慢的测试 - 几乎任何现有的服务都有某种形式的持久化和 IO 操作,会增加很多延迟,加上服务器逻辑本身的执行时间,加上真实网络调用的延迟,再加上...
- 混乱的测试 - 除了特殊构造和同步开销外,在测试真实事物时,还需要处理复杂的测试设置开销。这里你需要正确设置服务器端和客户端部分。
Nat Pryce 提到的所有问题仍然适用于这种自动化测试。此外,很难模拟异常场景并测试异常处理逻辑等备用代码分支,因为你需要以某种方式在服务器端设置一个异常场景。
考虑到以上所有问题和障碍,开发人员更倾向于跳过此类逻辑的单元测试,而不是承担所有负担,这不足为奇。
那么,有什么办法可以解决这种情况呢?
模拟异步接口
测试真实服务违背了“一次只测试一件事”的原则。此外,你可能已经在隔离环境中测试过服务器端服务了,不是吗?考虑到服务器端提供了服务合同方面的既定协议,我们现在有所有理由只“模拟”与之交互。
但是模拟生成的服务代理是不可能的,所有生成的类方法都是密封的。你可以尝试手动包装它,但开销巨大,会大大降低单元测试的投资回报率。
相反,我们可以尝试使用与服务代理代码一起生成的异步接口,并借助模拟框架来模拟异步调用。这是 Silverlight 开发人员社区使用的第二种主要但不太受欢迎的选项。
生成的服务代理类利用基于事件的异步模式(EAP),而生成的异步接口使用异步编程模型模式(APM)。这会以以下方式改变代码
/// TaskManagementViewModel.cs
public class TaskManagementViewModel : ViewModelBase
{
...
ITaskService service;
public TaskManagementViewModel(ITaskService service)
{
this.service = service;
...
}
...
public void Activate()
{
service.BeginGetAll(ar =>
{
try
{
var all = service.EndGetAll(ar);
foreach (Task each in all)
{
Tasks.Add(each);
}
Selected = Tasks[0];
}
catch (FaultException exc)
{
// ... some exception handling code ...
}
}, null);
}
}
正如你所看到的,这是一段相当标准的基于 APM 回调的代码。注意接口是如何通过构造函数注入的。这允许在运行时传递实现该接口的生成服务代理类的实例,并且还可以在测试环境中传递一个模拟对象。
/// TaskManagementView.xaml.cs
public partial class TaskManagementView
{
public TaskManagementView()
{
InitializeComponent();
}
private void UserControl_Loaded(object sender, RoutedEventArgs e)
{
// here we pass an instance of generated service proxy class
var viewModel = new TaskManagementViewModel(new TaskServiceClient());
DataContext = viewModel;
viewModel.Activate();
}
}
使用模拟的单元测试可能如下所示(我在这里使用 Rhino.Mocks 作为我选择的模拟框架)
/// TaskManagementViewModelFixture.cs
[TestMethod]
public void When_first_activated()
{
// arrange
var tasks = new ObservableCollection<Task>
{
CreateTask("Task1"),
CreateTask("Task2")
};
var mock = MockRepository.GenerateMock<ITaskService>();
var asyncResult = MockRepository.GenerateMock<IAsyncResult>();
// need to setup expectations for both APM methods
mock.Expect(service => service.BeginGetAll(null, null))
.IgnoreArguments().Return(asyncResult);
mock.Expect(service => service.EndGetAll(asyncResult))
.Return(tasks);
// pass mock to view model
var model = new TaskManagementViewModel(mock);
// act
model.Activate();
// we need this to actually complete the call
Callback(mock, service => service.BeginGetAll(null, null))(asyncResult);
// assert
Assert.AreEqual(tasks.Count, model.Tasks.Count);
Assert.AreSame(model.Tasks[0], model.Selected);
}
static AsyncCallback Callback<TService>(TService mock, Action<TService> method)
{
var arguments = mock.GetArgumentsForCallsMadeOn(method);
return (AsyncCallback)arguments[0][0];
}
static Task CreateTask(string description)
{
return new Task {Id = Guid.NewGuid(), Description = description};
}
虽然“模拟”方法比测试真实服务更可靠、更容易使用,但测试代码仍然显得笨拙。问题不在于方法本身,而在于我们试图模拟一个异步接口,而这正是所有不便的来源。
模拟 APM 基础代码会产生显著的开销,并模糊测试的清晰度。测试的本质仅仅在于“行间”。与常规同步接口模拟进行比较
// arrange
var mock = MockRepository.GenerateMock<ITaskService>();
var model = new TaskManagementViewModel(mock);
var tasks = new[] { ... };
mock.Expect(service => service.GetAll()).Return(tasks);
// act
model.Activate();
// assert
Assert.AreEqual(tasks.Count, model.Tasks.Count);
Assert.AreSame(model.Tasks[0], model.Selected);
清晰度的差异巨大
- 你不需要设置任何额外的预期 - 对于 APM,你需要为 APM 方法对中的两个方法设置预期
- 你不需要不断过滤掉 Begin\End 前缀的噪音
- 你可以清楚地看到传递给方法的内容 - 对于每个 `BeginXXX` 方法,都有两个额外的特殊参数,这些参数在测试中完全不相关
- 很容易看到从模拟方法返回的内容 - 使用 APM 模拟,方法调用被分成两个方法:`BeginXXX` 方法始终返回 `IAsyncResult`,实际值从 `EndXXX` 方法返回
- 没有额外的、纯技术性的东西,比如处理 `IAsyncResult`,这进一步模糊了测试的意图
- 你不需要任何特殊的调用完成构造,比如调用 `AsyncCallback` 委托
然而,模拟异步服务接口也有一个巨大的积极成果——模拟方法解决了异步测试固有的所有主要问题。如何?嗯,事实证明,通过在单元测试中模拟异步调用,“被测系统”开始变得*同步*,因此单元测试也变得*同步*!
在正常环境中,系统仍然是异步运行的,只是当它在测试环境中运行时,它以同步模式运行。这种执行的*二元性*是一个强大的概念,可以加以利用。让我们看看如何将其应用于上一篇文章中实现的方法。
无缝单元测试
透明异步和双模式执行
在上一篇文章中,我通过让开发人员能够完全使用同步接口进行工作,消除了 Silverlight 中强制(平台)要求使用异步与 WCF 服务交互带来的摩擦。
让我们回顾一下上一篇文章中的一些代码(视图模型代码的样子)
public class TaskManagementViewModel : ViewModelBases
{
const string address = "https://:2210/Services/TaskService.svc";
ServiceCallBuilder<ITaskService> build;
...
public TaskManagementViewModel()
{
build = new ServiceCallBuilder<ITaskService>(address);
...
}
...
public IEnumerable<IAction> Activate()
{
var action = build.Query(service => service.GetAll());
yield return action;
foreach (Task each in action.Result)
{
Tasks.Add(each);
}
Selected = Tasks[0];
}
...
在这里,我们使用了一个普通的同步接口。然而,调用的方式存在显著差异:不是直接调用其方法,而是通过 Lambda 表达式指定调用。这允许底层基础结构检查表达式,识别方法调用签名和传递给它的参数,然后将调用投影到异步双接口,同时透明地处理 APM 回调。
使用 Lambda 表达式指定方法调用这一事实,使得实现同步调用以进行单元测试的目的变得简单,同时在运行时保持异步行为。
所以,首先,我们需要让开发人员能够在测试环境中传递一个模拟实例。我们可以通过“构造函数注入”来实现
public class TaskManagementViewModel : ViewModelBases
{
const string address = "https://:2210/Services/TaskService.svc";
ServiceCallBuilder<ITaskService> build;
...
public TaskManagementViewModel(ITaskService service)
{
build = new ServiceCallBuilder<ITaskService>(service, address);
...
}
...
然后我们需要提供一种指定所需执行模式的方式。根据是设置为同步还是异步 - 分别编译 lambda 表达式并在传入的模拟上执行它,或者透明地将其投影到异步接口。在这里,我们可以一石二鸟——底层基础结构可以自动确定执行模式,具体取决于传入的接口实例(即模拟)是否存在
public class TaskManagementViewModel : ViewModelBases
{
public TaskManagementViewModel()
: this(null)
{}
public TaskManagementViewModel(ITaskService service)
{
build = new ServiceCallBuilder<ITaskService>(service, address);
...
}
这里,默认构造函数将在运行时使用,而重载将在测试环境中用于注入模拟实例。
对于基础结构部分,更改相当直接
public abstract class ServiceCall<TService> : IAction where TService: class
{
readonly ServiceChannelFactory<TService> factory;
readonly TService instance;
readonly MethodCallExpression call;
object channel;
protected ServiceCall(ServiceChannelFactory<TService> factory,
TService instance, MethodCallExpression call)
{
this.factory = factory;
this.instance = instance;
this.call = call;
}
public override void Execute()
{
if (instance != null)
{
ExecuteSynchronously();
return;
}
ExecuteAsynchronously();
}
void ExecuteSynchronously()
{
try
{
object result = DirectCall();
HandleResult(result);
}
catch (Exception exc)
{
Exception = exc;
}
SignalCompleted();
}
object DirectCall()
{
object[] parameters = call.Arguments.Select(Value).ToArray();
return call.Method.Invoke(instance, parameters);
}
static object Value(Expression arg)
{
return Expression.Lambda(arg).Compile().DynamicInvoke();
}
void ExecuteAsynchronously()
{
channel = factory.CreateChannel();
object[] parameters = BuildParameters();
MethodInfo beginMethod = GetBeginMethod();
beginMethod.Invoke(channel, parameters);
}
...
现在你可以写一个如下所示的单元测试
[TestClass]
public class TaskManagementViewModelFixture : SilverlightTest
{
[TestMethod]
public void When_first_activated()
{
// arrange
var mock = MockRepository.GenerateMock<ITaskService>();
var model = new TaskManagementViewModel(mock);
var tasks = new[] { CreateTask("Task1"), CreateTask("Task2") };
mock.Expect(service => service.GetAll()).Return(tasks);
// act
Execute(model.Activate());
// assert
Assert.AreEqual(tasks.Length, model.Tasks.Count);
Assert.AreSame(model.Tasks[0], model.Selected);
}
}
这里唯一令人头疼的是,由于我们依赖于基于迭代器的协程,为了触发方法代码的执行,我们需要*实际*遍历迭代器。这可以通过使用一个简单的通用辅助方法来完成
void Execute(IEnumerable<IAction> routine)
{
foreach (var action in routine)
{
action.Execute();
}
}
就是这样。清晰的应用程序代码和清晰的单元测试代码。
我们似乎消除了所有的摩擦,但仍然有一件大事需要改进……
奖金是无缝的工具
Microsoft Silverlight Unit Testing Framework 是一个非常强大的工具,但强大的同时也有其局限性
- 你只能在浏览器进程中运行测试
- 你无法使用替代的测试运行程序,如 TestDriven.NET、ReSharper 等来运行测试
- 你只能通过捆绑的 GUI 来获取/查看测试运行结果,这使得持续集成变得不可能(可以用 StatLight 项目解决)
- 由于上述所有限制,你无法让代码覆盖率工具工作
好吧,对我来说,这个列表已经是一个阻碍。我发现很难证明使用 SUTF 作为我的首选单元测试框架的合理性。由于我已经摆脱了单元测试中的异步问题,因此不再需要任何特殊的测试框架支持,并且我的视图模型是简单的 POCO(参见脚注),我立即开始寻找其他选择。
脚注:如果你真正理解 MVVM 模式,那么你就知道视图模型只是关于状态和行为。因此,最好将它们实现为简单的 POCO 对象。视图模型不应依赖于 UI 关注点(如 DependencyProperties、主线程亲和性等);否则,保持其可测试性将非常困难甚至不可能。
有一种普遍的误解,认为 Silverlight 代码只能在浏览器环境中运行(通过 Silverlight 插件)。事实上,很久以前(早在 2008 年),Jamie Cansdale 就研究过 Visual Studio 设计器如何在设计器窗口中使用 Silverlight 托管 Silverlight,并且设计器没有托管 CoreCLR 的独立实例,而是将 Silverlight 程序集加载到托管运行时中——即 .NET CLR!这是因为 CoreCLR 与 .NET CLR 兼容。
Jamie 调整了 'nunit.framework' 程序集,使其与 Silverlight 项目兼容,这使得运行、调试甚至对 Silverlight 单元测试进行代码覆盖成为可能!所有支持 NUnit 的现有 .NET CLR 工具(如 TeamCity、ReSharper、TestDriven.NET、NCover 等)都将能够识别、加载和运行 Silverlight 程序集中的 NUnit 测试。
由于这是很久以前的事了,Jamie 重新编译的 NUnit 框架版本已经过时,但感谢 Wesley McClure 的努力,我们现在有了兼容 Silverlight 的 NUnit 2.5.1 版本。你可以从他的博客上获取它。然后,你只需创建一个新的“Silverlight 类库”项目,引用 Wesley 的程序集,然后开始编写单元测试。就是这么简单!
脚注:有一个诀窍:为了让你的测试成功执行,你还需要确保所有引用的 Silverlight 程序集('mscorlib' 除外)都设置为 'Copy Local: True'。并确保你的视图模型实际上是 POCO,这样你就不需要在浏览器上下文来运行它们。特别是,这促使我将分派实现从使用 `System.Deployment.Dispatcher`(仅在浏览器上下文中可用)更改为更普遍和健壮的 `SynchronizationContext`(参见附加示例)。
如果你是 MSTest 的长期用户,你实际上可以使用它的 Silverlight 版本而不是重新编译的 NUnit 程序集来编写单元测试,只要你遵守脚注中概述的规则。
结论
为了使本文简短,在我的下一篇(也是最后一篇)系列文章中,我将向你展示一系列全新的技巧和技术,这些技术可以使用协程和透明异步来单元测试异步代码。我们将了解如何利用“历史跟踪”这样简单的技术来轻松测试“可重复操作”和“弹性转换”。此外,我们将探讨如何单元测试我在上一篇文章中讨论的高级场景,例如 Fork/Join 和异步轮询。我还会触及因环境易变性和外部作用域绑定而可能引入的细微错误,以及如何编写单元测试来覆盖这些情况。
下载附加的示例应用程序,了解如何编写视图模型单元测试。最新版本的源代码可以在这里找到。你还可以找到很多其他有用的东西,例如在阻塞等待中使用协程以及在非 Silverlight 环境中使用透明异步。我将在我的博客上发布与该主题相关的其他材料。