Catel - 第 8 部分(共 n 部分): WP7 Mango 和相机单元测试






4.89/5 (4投票s)
本文是关于 Windows Phone 7 Mango 和相机单元测试的。
文章浏览器
- Catel - 第 n 部分 1:数据处理之道
- Catel - 第 n 部分 2:使用 WPF 控件和主题
- Catel - 第 3 部分 (共 n 部分):MVVM 框架
- Catel - 第 n 部分 4:使用 Catel 进行单元测试
- Catel - 第 5 部分 (共 n 部分):在 1 小时内构建一个 Catel WPF 示例应用程序
- Catel - 第 6 部分(
共 n 部分): WP7 的 Bing Maps 应用程序 - Catel - 第 n 部分 7:Catel 2.x 有什么新特性
- Catel - 第 n 部分 8:WP7 Mango 和相机单元测试
目录
- 引言
- 演示应用程序
- 设置单元测试
- 4. 无需模拟的单元测试
- 5. 相机服务基础
- 6. 在模拟器中使用 CameraService
- 7. 在单元测试中使用 CameraService
- 8. 结论
第一部分 - Windows Phone 7 上的单元测试
第二部分 - 相机服务
1. 引言
欢迎阅读 Catel 系列文章的第七部分。如果您还没有阅读过 Catel 的先前文章,建议您阅读。它们已按编号排列,因此查找起来应该不难。
您可能现在正在想:为什么这个人要实现一个 CameraService
?有一个使用 PhotoCamera 类的漂亮 API。请再次提醒自己,您为什么一开始就想用 MVVM 编写应用程序?是因为它是本世纪最热门的词语,还是……哦,是的,我想起来了,您想能够对所有视图模型进行单元测试。现在告诉我,如果您实例化一个 PhotoCamera
对象,这怎么可能?而且,例如,您将如何支持市面上的各种相机(支持所有闪光灯模式的相机,不支持所有闪光灯模式的相机等)?
本文将解释 CameraService
是如何创建的,更重要的是,为什么创建它。CameraService
允许您以真正的 MVVM 方式与 Windows Phone 7 Mango 设备上的相机进行交互。本文使用了 Catel,但如果您愿意,也可以单独使用该服务。
本文分为几个部分。第一部分是关于 Windows Phone 7 的单元测试。第二部分是关于相机服务的单元测试。最后一部分是关于结论等等。
2. 演示应用程序
2.1. 功能性需求
本文中使用的演示应用程序非常简单。第一个屏幕允许用户使用相机拍照。如果应用程序中有现有照片,用户还可以通过向右“滑动”来浏览照片。
在照片屏幕中,可以向右滑动到下一张图片,或向左滑动到上一张图片。当显示第一张图片且用户向左滑动时,主页面应该可见。也可以删除图片。删除图片后,应始终选择被删除图片索引左侧的图片。如果选定的图片是第一张且仍有剩余图片,则应显示下一张第一张图片。删除项目后如果没有剩余图片,应用程序应导航到主页面。
2.2. 单元测试要求
太好了,功能性需求已知。现在让我们看看如何对该应用程序进行单元测试。应进行单元测试以下功能。所有测试都已编号,以便轻松引用。
MainPage
- [MP_01] 如果没有图片,则不应向右滑动
- [MP_02] 如果有可用图片,则应向右滑动
- [MP_03] 拍照时,不应向右滑动
- [MP_04] 如果没有图片,则不应单击查看按钮
- [MP_05] 当没有图片时,应该能够单击查看按钮
- [MP_06] 导航到关于视图应始终可用
- [MP_07] 应该能够拍照
- [MP_08] 正在拍照时,不应再次拍照
PhotoView
- [PV_01] 如果右侧没有图片,则不应向右滑动
- [PV_02] 在中间图片上向右滑动应加载下一张图片
- [PV_03] 在中间图片上向左滑动应加载上一张图片
- [PV_04] 滑动到第一张图片应导航到主页面
- [PV_05] 取消删除图片命令不应导航离开
- [PV_06] 删除图片应导航到上一张图片
- [PV_07] 删除第一张图片且仍有图片时,应导航到下一张图片
- [PV_08] 删除第一张图片且没有图片时,应导航到主页面
- [PV_09] 单击相机按钮应始终导航到主页面
本文附带的示例代码包含对上述所有情况的单元测试。
第一部分 Windows Phone 7 上的单元测试
3. 设置单元测试
如果您已经知道如何为 Windows Phone 7 应用程序设置单元测试,则可以跳过本章。
为 Windows Phone 7 设置单元测试并不像您预期的那么容易。没有内置的单元测试框架,也没有允许您创建单元测试项目的模板。有些人试图通过运行常规的 Silverlight 单元测试来“欺骗”系统。我认为这是一个非常糟糕的主意,因为那样您就不会针对 WP7 框架测试逻辑,而是针对 Silverlight 框架测试逻辑,而 Silverlight 框架是不同的。
3.1. 下载正确的库
首先,需要特殊的库来启用 WP7 的单元测试。我使用的库在 Windows Phone 7 工具包中,也位于本文附带的演示项目的 lib 文件夹中。需要以下两个库
- Microsoft.Silverlight.Testing.dll
- Microsoft.VisualStudio.QualityTools.UnitTesting.Silverlight.dll
3.2. 创建单元测试项目
现在我们有了所需的库,让我们去创建单元测试项目。第一步是创建一个新的 Windows Phone 7 应用程序
由于我们正在使用 Mango,请确保选择 Windows Phone 7.1。在此步骤之后,请确保引用您的原始 Windows Phone 7 应用程序以及本章前面下载的测试库。Visual Studio 可能会警告您引用 Silverlight 程序集可能不安全,但您可以忽略它。
3.3. 修改 MainPage
最后要做的是修改项目模板自动生成的 MainPage.xaml.cs
。确保 MainPage
的构造函数如下所示
public MainPage()
{
InitializeComponent();
Content = UnitTestSystem.CreateTestPage();
}
现在您已经准备好创建第一个单元测试了!让我们这样做,以确保您的单元测试能够运行。添加一个名为 DemoTest
的新类,并使用以下内容
using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class DemoTest
{
[TestMethod]
public void MyFirstUnitTest()
{
Assert.Inconclusive("We need to write our first unit test");
}
}
最后,如果您运行单元测试项目,您将看到以下屏幕(如果您习惯在 Silverlight 中编写单元测试,这些屏幕应该很熟悉)
4. 无需模拟的单元测试
在 WPF 和 Silverlight 的单元测试中,您可能习惯于模拟所有接口。在 Catel 中,我们始终提供每个服务的测试实现,因此您不必始终模拟服务。当然,仍然可以为 WPF 和 Silverlight 模拟服务,但对于 Windows Phone 7,情况有所不同。
目前 WP7 上没有可用的模拟框架,而且很可能永远不会有模拟框架。您可能会想?为什么不,那里有很大的市场,我想能够为我的 WP7 应用程序使用单元测试。不幸的是,Reflection.Emit
方法在 WP7 中缺失。此方法对于模拟任何对象都是必需的。
4.1. 测试事件
编写本文代码时遇到的第一个问题是 GestureListener
必须在代码中声明。如果您不熟悉 GestureListener
,它是一个类,允许您订阅 WP7 页面上的特定手势。因此,我在视图模型上创建了一个命令,该命令应该在检测到滑动(flick)手势时执行。这就是我保持 MVVM 模式完整的方式
var gestureListener = GestureService.GetGestureListener(this);
gestureListener.Flick += (sender, e) =>
{
if (ViewModel != null)
{
ViewModel.Flick.Execute(e);
}
};
如您所见,只要检测到 Flick
手势,就会执行 Flick
命令。
下一个问题是需要单元测试向左或向右滑动是否可行,因为 FlickGestureEventArgs
的构造函数是内部的。开发团队为什么决定将其设为内部,我仍然不知道,但我必须找到解决办法。
我提出的想法很简单。在应用程序中,我创建了一个新的对象 FlickData
,它具有与 FlickGestureEventArgs
完全相同的属性。然后我创建了几个构造函数,它们允许轻松进行单元测试,并且也可以在实际应用程序逻辑中使用。以下是 FlickData
类的代码
/// <summary>
/// Flick movements, easy to emulate flick gestures for testing purposes.
/// </summary>
public enum FlickMovement
{
/// <summary>
/// Left to right.
/// </summary>
LeftToRight,
/// <summary>
/// Right to left.
/// </summary>
RightToLeft
}
/// <summary>
/// Custom implementation for flick data because it's impossible to instantiate
/// the <see cref="FlickGestureEventArgs"/> class.
/// </summary>
public class FlickData
{
/// <summary>
/// Initializes a new instance of the <see cref="FlickData"/> class.
/// </summary>
/// <param name="eventArgs">The <see cref="Microsoft.Phone.Controls.FlickGestureEventArgs"/> instance containing the event data.</param>
public FlickData(FlickGestureEventArgs eventArgs)
: this(eventArgs.Angle, eventArgs.Direction, eventArgs.HorizontalVelocity, eventArgs.VerticalVelocity) { }
/// <summary>
/// Initializes a new instance of the <see cref="FlickData"/> class.
/// </summary>
/// <param name="angle">The angle.</param>
/// <param name="direction">The direction.</param>
/// <param name="horizontalVelocity">The horizontal velocity.</param>
/// <param name="verticalVelocity">The vertical velocity.</param>
public FlickData(double angle, Orientation direction, double horizontalVelocity, double verticalVelocity)
{
Angle = angle;
Direction = direction;
HorizontalVelocity = horizontalVelocity;
VerticalVelocity = verticalVelocity;
}
/// <summary>
/// Initializes a new instance of the <see cref="FlickData"/> class.
/// </summary>
/// <param name="movement">The movement.</param>
/// <remarks>
/// This method should only be used for test purposes.
/// </remarks>
public FlickData(FlickMovement movement)
{
Angle = 0;
Direction = Orientation.Horizontal;
VerticalVelocity = 0;
switch (movement)
{
case FlickMovement.LeftToRight:
HorizontalVelocity = 15;
break;
case FlickMovement.RightToLeft:
HorizontalVelocity = -15;
break;
default:
throw new ArgumentOutOfRangeException("movement");
}
}
/// <summary>
/// Gets or sets the angle.
/// </summary>
/// <value>The angle.</value>
public double Angle { get; set; }
/// <summary>
/// Gets or sets the direction.
/// </summary>
/// <value>The direction.</value>
public Orientation Direction { get; set; }
/// <summary>
/// Gets or sets the horizontal velocity.
/// </summary>
/// <value>The horizontal velocity.</value>
public double HorizontalVelocity { get; set; }
/// <summary>
/// Gets or sets the vertical velocity.
/// </summary>
/// <value>The vertical velocity.</value>
public double VerticalVelocity { get; set; }
}
除了这个类之外,还需要更新对命令的订阅(因为现在它不能接受 FlickGestureEventArgs
类型的参数,而是 FlickData
类型的参数)。因为 FlickData
类接受 FlickGestureEventArgs
类型的对象作为参数,所以代码更改非常小。这是更新后的代码
var gestureListener = GestureService.GetGestureListener(this);
gestureListener.Flick += (sender, e) =>
{
if (ViewModel != null)
{
ViewModel.Flick.Execute(new FlickData(e));
}
};
现在所有东西都已到位,可以测试视图模型了,让我们来看一个简单的单元测试 [MP_01],它测试在主页面上没有图片可用时是否不允许滑动
[TestMethod]
public void Flick_NoImagesAvailable()
{
var serviceLocator = ServiceLocator.Instance;
var photoRepository = new TestPhotoRepository(0);
serviceLocator.RegisterInstance<IPhotoRepository>(photoRepository);
var vm = new MainPageViewModel();
Assert.IsFalse(vm.Flick.CanExecute(new FlickData(FlickMovement.LeftToRight)), "Flick from left to right is never allowed");
Assert.IsFalse(vm.Flick.CanExecute(new FlickData(FlickMovement.RightToLeft)));
}
正如您所看到的,注册了一个 IPhotoRepository
的测试实现,以确保视图模型中没有可用的图片。然后,单元测试检查向左滑动是否不可行(在主页面上向左滑动永远不应可能),以及向右滑动是否不可行(如果没有图片,则向右滑动不应可能)。
4.2. 测试服务
在为 Catel 编写服务时,我们作为开发人员坚信应用程序的可测试性(否则,使用 MVVM 编写应用程序的意义何在?)。因此,我们为 Catel MVVM 工具包编写的每项服务都提供了测试实现。有些人觉得这有点多余,因为他们喜欢使用模拟框架。我们不介意您使用什么,我们只是想开箱即用地支持这两种方式。
这个原则对于 Windows Phone 7 来说非常有用,因为 WP7 上没有可用的模拟框架。因此,测试 Catel 提供的服务非常、非常简单。在此示例中,将使用 IMessageService
,以便单元测试可以检查结果是否被正确处理。
首先,让我们开始一个有意义的单元测试。在演示应用程序中,可以删除现有图片。应该向用户询问确认,因此必须编写一个单元测试来检查此确认是否已正确实现。整个单元测试的代码如下所示
[TestMethod]
public void DeleteImage_Cancel()
{
var serviceLocator = ServiceLocator.Instance;
var photoRepository = new TestPhotoRepository(3);
serviceLocator.RegisterInstance<IPhotoRepository>(photoRepository);
_testNavigationService.ClearLastNavigationInfo();
_testMessageService.ExpectedResults.Enqueue(MessageResult.Cancel);
var vm = new PhotoViewModel();
vm.UpdateNavigationContext(CreateNavigationContextWithId(2));
vm.Delete.Execute();
Assert.AreEqual(3, photoRepository.Photos.Count, "Photo count should not have changed");
Assert.AreEqual(null, _testNavigationService.LastNavigationUri, "Should have canceled");
Assert.AreEqual(null, _testNavigationService.LastNavigationParameters, "Should have canceled");
}
正如您所看到的,在此单元测试中使用了多个服务。其中一个,IPhoneRepository
,是在单元测试中创建的。其他似乎是字段。字段在下面显示的 TestInitialize
方法中初始化
private Catel.MVVM.Services.Test.NavigationService _testNavigationService;
private Catel.MVVM.Services.Test.MessageService _testMessageService;
[TestInitialize]
public void Initialize()
{
var serviceLocator = ServiceLocator.Instance;
if (_testNavigationService == null)
{
_testNavigationService = new Catel.MVVM.Services.Test.NavigationService();
serviceLocator.RegisterInstance<INavigationService>(_testNavigationService);
}
if (_testMessageService == null)
{
_testMessageService = new Catel.MVVM.Services.Test.MessageService();
serviceLocator.RegisterInstance<IMessageService>(_testMessageService);
}
}
而不是真正的 MessageService
,实例化并注册了一个 MessageService
的测试实现到 IoC 容器中。
回到我们的单元测试,关注下面显示的部分
_testNavigationService.ClearLastNavigationInfo();
_testMessageService.ExpectedResults.Enqueue(MessageResult.Cancel);
var vm = new PhotoViewModel();
vm.UpdateNavigationContext(CreateNavigationContextWithId(2));
vm.Delete.Execute();
Assert.AreEqual(3, photoRepository.Photos.Count, "Photo count should not have changed");
Assert.AreEqual(null, _testNavigationService.LastNavigationUri, "Should have canceled");
Assert.AreEqual(null, _testNavigationService.LastNavigationParameters, "Should have canceled");
清除 INavigationService
的最后一个导航信息,以便我们可以确保在单元测试期间不会调用 INavigationService
。我们还排队了 IMessageService
的预期结果。对 IMessageService
的第一次调用将返回指定的 MessageResult
。
换句话说,该测试检查取消是否被正确处理。应用程序不应导航离开,图片数量应保持不变。
第二部分 - 相机服务
5. 相机服务基础
CameraService
提供了实际实现,也提供了测试实现。当视图模型首次实例化时,实际实现会自动注册到 IoC 容器中。如果您不喜欢使用 Catel 附带的 ViewModelBase
,则需要像这样注册相机服务
serviceLocator.RegisterInstance<ICameraService>(CameraService);
CameraService
尽可能遵循 PhotoCamera
API。这样,在使用该 API 时就没有学习曲线。
5.1. 启动和停止服务
PhotoCamera
文档不断强调相机对象必须正确创建和处置。在该服务中,这由 StartService
和 StopService
方法封装。要启动服务,请使用下面的代码
var cameraService = GetService<ICameraService>();
cameraService.CaptureThumbnailAvailable += OnCameraServiceCaptureThumbnailAvailable;
cameraService.CaptureImageAvailable += OnCameraServiceCaptureImageAvailable;
cameraService.Start();
要停止服务,请使用下面的代码:(注意:Close
方法是 Catel 的一项功能)
protected override void Close()
{
var cameraService = GetService<ICameraService>();
cameraService.Stop();
cameraService.CaptureThumbnailAvailable -= OnCameraServiceCaptureThumbnailAvailable;
cameraService.CaptureImageAvailable -= OnCameraServiceCaptureImageAvailable;
}
5.2. 拍摄图像
要拍摄图像,需要执行几项操作。首先要完成的操作是订阅 ICameraService.CaptureImageAvailable
事件。下一步是调用 CaptureImage
方法,如下所示
CameraService.CaptureImage();
最后一部分非常重要。您需要从 CaptureImageAvailable
事件中读取图像流
BitmapImage bitmap = new BitmapImage();
bitmap.SetSource(e.ImageStream);
5.3. 在视图中显示相机视频
要在手机上显示相机输入的预览,首先订阅 ICameraService.CaptureThumbnailImageAvailable
事件。下一步是在视图模型中创建一个属性
/// <summary>
/// Gets or sets the current photo.
/// </summary>
public BitmapImage CurrentPhoto
{
get { return GetValue<BitmapImage>(CurrentPhotoProperty); }
set { SetValue(CurrentPhotoProperty, value); }
}
/// <summary>
/// Register the CurrentPhoto property so it is known in the class.
/// </summary>
public static readonly PropertyData CurrentPhotoProperty = RegisterProperty("CurrentPhoto", typeof(BitmapImage));
此属性定义是一个 Catel 属性,但如果您喜欢使用不同的 MVVM 框架或自己的属性定义样式,您也可以这样做。
在视图中,使用 Image
控件显示当前照片
<Image Grid.Row="0"
Source="{Binding
CurrentPhoto}" />
最后但同样重要的是,当有新的缩略图可用时,我们需要更新 CurrentPhoto
属性。
private void OnCameraServiceCaptureThumbnailAvailable(object sender, ContentReadyEventArgs e)
{
BitmapImage bitmap = new BitmapImage();
bitmap.SetSource(e.ImageStream);
CurrentPhoto = bitmap;
}
5.4. 实例化测试实现
CameraService
的测试实现需要用一个图像来实例化。该服务将使用该图像来创建动画。将应用的动画是逐像素向右滚动的图像。
要实例化测试服务,请将一个图像添加到 Windows Phone 7 项目中,并将其构建操作设置为 Resource。然后像下面的代码一样实例化服务
var testImage = new BitmapImage();
var streamResourceInfo = Application.GetResourceStream(new Uri("/MyAssembly;component/Resources/Images/MyImage.png", UriKind.RelativeOrAbsolute));
testImage.CreateOptions = BitmapCreateOptions.None;
testImage.SetSource(streamResourceInfo.Stream);
_testCameraService = new CameraService(testImage);
serviceLocator.RegisterInstance<ICameraService>(_testCameraService);
默认情况下,CameraService
每 50 毫秒生成一个新的缩略图图像。可以通过构造函数重载来自定义此设置。
5.5. 为测试自定义相机设置
有时需要测试不同的分辨率。一种方法是购买所有可用的 Windows Phone 7 设备,并在所有相机上测试软件。更简单的方法是使用 ICameraService
并自定义相机选项,以测试应用程序如何响应不同的设置。
设置存储在 CameraServiceTestData
类中。该类允许自定义 PhotoCamera
类上通常找到的所有属性。例如,要仅允许主相机(因为并非所有设备都支持前置摄像头),请使用以下代码
var cameraTestSettings = new CameraServiceTestData();
cameraTestSettings.SupportedCameraTypes = CameraType.Primary;
cameraService.UpdateTestData(cameraTestSettings);
还可以更改图像的缩略图和最终分辨率
var cameraTestSettings = new CameraServiceTestData();
cameraTestSettings.PreviewResolution = new Size(400, 800);
cameraTestSettings.Resolution = new Size(1200, 2400);
cameraService.UpdateTestData(cameraTestSettings);
6. 在模拟器中使用 CameraService
首先,必须有一种方法来确定代码是在模拟器还是在真实设备上运行。在应用程序的 App.xaml.cs 中,将相机服务的测试版本注册到 IoC 容器中
var serviceLocator = ServiceLocator.Instance;
// Since we are running in an emulator, use the test version of the ICameraService
var testImage = new BitmapImage();
var streamResourceInfo = Application.GetResourceStream(new Uri("/MyAssembly;component/Resources/Images/MyImage.png", UriKind.RelativeOrAbsolute));
testImage.CreateOptions = BitmapCreateOptions.None;
testImage.SetSource(streamResourceInfo.Stream);
serviceLocator.RegisterInstance<ICameraService>(new MVVM.Services.Test.CameraService(testImage));
如果如 显示相机视频 中所述,该服务已在视图模型中正确实现,您将在主页面上看到缩略图正在更新
7. 在单元测试中使用 CameraService
到目前为止,我们只涵盖了 Windows Phone 7 的基本单元测试。现在让我们看看如何测试相机实现是否在应用程序中正常工作。
我们开始编写 TestInitialize
方法,该方法实例化测试实例
[TestClass]
public class MainPageViewModelTest
{
private CameraService _testCameraService;
[TestInitialize]
public void Initialize()
{
var serviceLocator = ServiceLocator.Instance;
if (_testCameraService == null)
{
var testImage = new BitmapImage();
var streamResourceInfo = Application.GetResourceStream(new Uri("/MyAssembly;component/Resources/Images/MyImage.png", UriKind.RelativeOrAbsolute));
testImage.CreateOptions = BitmapCreateOptions.None;
testImage.SetSource(streamResourceInfo.Stream);
_testCameraService = new CameraService(testImage);
serviceLocator.RegisterInstance<ICameraService>(_testCameraService);
}
}
}
现在将要进行单元测试的视图模型将使用测试实现。
8. 结论
本文展示了我们 Catel 的开发者为何选择将 PhotoCamera
类实现为一项服务。我们希望您能真正理解我们为什么创建这项服务以及它有多强大。
一些开发人员出于某些原因(例如,因为其他人告诉他们使用 MVVM light(或其他 MVVM 框架))确实不想使用 Catel。在这种情况下,仍然可以使用 Catel 提供的所有服务。只需在您选择的 IoC 容器中手动注册所有服务即可。
我们很乐意听取您的反馈!
有关 Catel 的更多信息,请访问 http://catel.codeplex.com