Silverlight 2の単体テストをやってみる

普段はNUnitを使って単体テストをやっているけど、まだSilverlightには対応していないのでSilverlightアプリの単体テストはやったことがなかった。

でも、そろそろやった方がいいかなぁと思い始めてSilverlight用の単体テストフレームワークを調べたところJeff Wilcoxさんが作っているフレームワークがあった。

日本語の紹介記事

結構簡単に使えそうなのでやってみた(環境構築についてはGihyo.jpの記事を参考にして下さい)。

テスト!

まずテストするページを作る。

Page.xaml
<UserControl x:Class="SilverlightApplication1.Page"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  Width="400" Height="300">
  <Grid x:Name="LayoutRoot" Background="White">
      <Button x:Name="button" Width="80" Height="30" Content="button" Click="button_Click" />
        
      <TextBlock x:Name="label" />
  </Grid>
</UserControl>

Page.xaml.cs
using System;
using System.Windows;
using System.Windows.Controls;

namespace SilverlightApplication1 {
  public partial class Page : UserControl {
      public Page() {
          InitializeComponent();
      }

      private void button_Click(object sender, RoutedEventArgs e) {
          label.Text = "Hoge";
      }
  }
}

ボタンをクリックするとテキストブロックに「Hoge」と設定されるだけ。これをテストする。

テストプロジェクトを追加して、テストクラスを追加する。

PageTest.cs
using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Diagnostics;

using Microsoft.Silverlight.Testing;
using Microsoft.VisualStudio.TestTools.UnitTesting;

using SilverlightApplication1;

namespace SilverlightApplication1_Test {
  [TestClass]
  public class Page_Test : SilverlightTest {
      private Page page;

      [TestInitialize]
      public void Initialize() {
          page = new Page();

          this.Silverlight.TestSurface.Children.Add(page);
      }

      [TestMethod]
      public void ボタンをクリックするとテキストが設定される() {
      }
  }
}

テストクラスは「TestClass」属性でマークする必要がある。必須ではないがSilverlightTestクラスを継承しておくと色々と便利なメソッドを使用できるので継承しておこう。

テスト毎に初期化するメソッドには「TestInitialize」属性をマークする。この属性でマークしたメソッドはテストが呼び出される前に呼び出される事になる。ここではPageクラスをインスタンス化し、「Silverlight.TestSurface.Children.Add」メソッドの引数に渡している。

そしてテストメソッドは「TestMethod」属性でマークしておく。ここにテストコードを記述していくわけだ。

で、テストをするには

  1. Pageインスタンスからボタンを取ってくる。
  2. ボタンのClickイベントを呼び出す。
  3. Pageインスタンスからテキストブロックを取ってくる。
  4. テキストブロックのTextプロパティの値をアサートする。

とやればいいわけだけど、この内1,3,4は問題なくできるけど2のClickイベントを呼び出す事は外部からできない。
元記事やGihyo.jpの記事を見てみるとPageクラスの内部でボタンのClickイベントに関連付けられたイベントハンドラを呼び出す為のInternalなメソッドを作成して、このメソッドをInternalsVisibleTo属性でテストアセンブリに呼び出せる用にしていた。

その方法はあんまりスマートな感じがしないので、別の方法でやってみることにした。幸いにもBeta 2からUIAutomation機能が追加されたのでそれを使ってやる。

まずUIAutomationの機能をさくっと使うために以下のクラスを定義する。

UIElementExtension.cs
using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Automation.Peers;
using System.Windows.Automation.Provider;
using System.Diagnostics;

namespace SilverlightApplication1_Test {
   /// <summary>
   /// UIElementの拡張クラス
   /// </summary>
   [DebuggerStepThrough]
   static class UIElementExtension {
       /// <summary>
       /// 指定したボタンのClickイベントを呼び出します。
       /// </summary>
       /// <param name="button">ボタン</param>
       public static void FireClickEvent(this Button button) {
           var peer = new ButtonAutomationPeer(button);
           var invoker = (IInvokeProvider)peer.GetPattern(PatternInterface.Invoke);

           invoker.Invoke();
       }

       /// <summary>
       /// 指定した識別名を持つ要素を取得します。
       /// </summary>
       /// <typeparam name="T">UIElementの派生型</typeparam>
       /// <param name="element">UI要素</param>
       /// <param name="name">識別名</param>
       /// <returns>UI要素</returns>
       public static T FindName<T>(this FrameworkElement element, string name) where T : UIElement {
           return element.FindName(name) as T;
       }

   }
}

これを使って書いたテストコードが以下。

PageTest.cs
[TestMethod]
public void ボタンをクリックするとテキストが設定される() {
   // ボタンをクリック!!
   page.FindName<Button>("button").FireClickEvent();

   var text = page.FindName<TextBlock>("label").Text;

   Assert.AreEqual(text, "Hoge");
}

「FireClickEvent」メソッドでボタンのClickイベントを呼び出せる。後はテキストブロックのTextプロパティの値をAssertしている。

これでいけると思ったら、そうはうまくいかないんだなーこれが。

WPFでもそういう仕様なのか知らないけどUIAutomationで呼び出したイベントはその時点では呼び出されなくて、一度キューに溜められてからあるタイミングで一度に呼び出しがかかるみたいなのだ。

なので、最初のFireClickEventでは実際にはClickイベントは呼び出されずに、Assertの後で呼びだされているみたいなのだ。これではまったくこのコードは意味が無いので、「どうしたもんかな〜」としばらく悩んだけどいい解決策が見つからなかったので、以下の方法でやることにした。

UIElementExtension.cs
/// <summary>
/// 指定したボタンのClickイベントを呼び出します。
/// </summary>
/// <param name="button">ボタン</param>
/// <param name="callback">コールバック</param>
public static void FireClickEvent(this Button button, Action<Button> callback) {
   button.Click += (s, e) => callback(button);

   var peer = new ButtonAutomationPeer(button);
   var invoker = (IInvokeProvider)peer.GetPattern(PatternInterface.Invoke);

   invoker.Invoke();
}

FireClickEventメソッドにボタンがクリックされた時に呼び出すコールバックメソッドを指定できるようにした。

テストメソッドも以下のように変更する。

PageTest.cs
[TestMethod, Asynchronous]
public void ボタンをクリックするとテキストが設定される() {
   page.FindName<Button>("button").FireClickEvent(button => {
       var text = page.FindName<TextBlock>("label").Text;
       Assert.AreEqual(text, "Hoge");

       this.EnqueueTestComplete();
   });

コールバックメソッドの中でアサートを行っている。この方法では非同期テストという形になるみたいなので、TestMethod属性の他に非同期テストであることを表す「Asynchronous」属性でもマークしておく必要がある。

そして、非同期テストではテストがどこで終わったかを知らせるために必ず「EnqeueTestComplete」メソッドを呼び出す必要がある。

これで実行すると以下のようにテスト結果が表示される。

複雑なUIなテストとかは結構難しいかもしれないけど、単純なUIのテストなら結構楽にできそう(まぁそもそも簡単なUIなら単体テストなんていらないんだけどね・・・)。
あとはUIAutomationの挙動がこのままなのかどうかが気になる。WPFを見る限り違うように思うけど。。。