65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (4投票s)

2011 年 9 月 2 日

CPOL

14分钟阅读

viewsIcon

33674

downloadIcon

546

本文是关于 Windows Phone 7 Mango 和相机单元测试的。

文章浏览器

目录

  1. 引言
  2. 演示应用程序
  3. 第一部分 - Windows Phone 7 上的单元测试

  4. 设置单元测试
  5. 4. 无需模拟的单元测试
  6. 第二部分 - 相机服务

  7. 5. 相机服务基础
  8. 6. 在模拟器中使用 CameraService
  9. 7. 在单元测试中使用 CameraService
  10. 8. 结论

1. 引言

欢迎阅读 Catel 系列文章的第七部分。如果您还没有阅读过 Catel 的先前文章,建议您阅读。它们已按编号排列,因此查找起来应该不难。

您可能现在正在想:为什么这个人要实现一个 CameraService?有一个使用 PhotoCamera 类的漂亮 API。请再次提醒自己,您为什么一开始就想用 MVVM 编写应用程序?是因为它是本世纪最热门的词语,还是……哦,是的,我想起来了,您想能够对所有视图模型进行单元测试。现在告诉我,如果您实例化一个 PhotoCamera 对象,这怎么可能?而且,例如,您将如何支持市面上的各种相机(支持所有闪光灯模式的相机,不支持所有闪光灯模式的相机等)?

本文将解释 CameraService 是如何创建的,更重要的是,为什么创建它。CameraService 允许您以真正的 MVVM 方式与 Windows Phone 7 Mango 设备上的相机进行交互。本文使用了 Catel,但如果您愿意,也可以单独使用该服务。

本文分为几个部分。第一部分是关于 Windows Phone 7 的单元测试。第二部分是关于相机服务的单元测试。最后一部分是关于结论等等。

2. 演示应用程序

2.1. 功能性需求

image003.jpg

本文中使用的演示应用程序非常简单。第一个屏幕允许用户使用相机拍照。如果应用程序中有现有照片,用户还可以通过向右“滑动”来浏览照片。

在照片屏幕中,可以向右滑动到下一张图片,或向左滑动到上一张图片。当显示第一张图片且用户向左滑动时,主页面应该可见。也可以删除图片。删除图片后,应始终选择被删除图片索引左侧的图片。如果选定的图片是第一张且仍有剩余图片,则应显示下一张第一张图片。删除项目后如果没有剩余图片,应用程序应导航到主页面。

image004.jpg

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 应用程序

image004.jpg

由于我们正在使用 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 中编写单元测试,这些屏幕应该很熟悉)

image006.jpg image007.jpg

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 文档不断强调相机对象必须正确创建和处置。在该服务中,这由 StartServiceStopService 方法封装。要启动服务,请使用下面的代码

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));

如果如 显示相机视频 中所述,该服务已在视图模型中正确实现,您将在主页面上看到缩略图正在更新

image008.jpg

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

© . All rights reserved.