matsukawar's blog

個人的な技術ブログ。テクニカルアーキテクトを目指しています。Twitter : https://twitter.com/matsukawar

UIMapがカオスにならないようにするためには

C# Advent Calendar 2013」8日目の記事です。
http://www.adventar.org/calendars/119

はじめに

継続的なデリバリーに必須と言えるテスト自動化の仕組み、すでにVisual StudioにはユニットテストWEBテストとならんで、コード化されたUIテストが用意されており、画面操作から内部処理まで幅広いテストをカバーしています。
Visual StudioのPremiumエディション以降

UIMapがカオスになるとは?

コード化されたUIテストの、操作の記録/アサーションの追加を行うと、テストプロジェクト下のUIMap.uitest内のUIMap.designer.csというファイルに、コントロールへのアクセサやオペレーションの順番の記録がC#のコードとして自動的に落ちます。

試しに、以下のような単純な画面を使ってボタンを6個押す操作を記録してみましょう。

出来上がった、デザイナコードを見てみると、このような単純な操作だけでも、たくさんのクラスがこの1ファイル内に蓄積されました

さらにはコードが何百行もできました!

// ------------------------------------------------------------------------------
//  <auto-generated>
//      このコードは、コード化された UI テスト ビルダーによって生成されました。
//      バージョン: 12.0.0.0
//
//      このファイルへの変更は、正しくない動作の原因になる可能性があり、
//      コードが再生成された場合に失われる可能性があります。
//  </auto-generated>
// ------------------------------------------------------------------------------

namespace CodedUITestProject1
{
    using System;
    using System.CodeDom.Compiler;
    using System.Collections.Generic;
    using System.Drawing;
    using System.Text.RegularExpressions;
    using System.Windows.Input;
    using Microsoft.VisualStudio.TestTools.UITest.Extension;
    using Microsoft.VisualStudio.TestTools.UITesting;
    using Microsoft.VisualStudio.TestTools.UITesting.WinControls;
    using Microsoft.VisualStudio.TestTools.UnitTesting;
    using Keyboard = Microsoft.VisualStudio.TestTools.UITesting.Keyboard;
    using Mouse = Microsoft.VisualStudio.TestTools.UITesting.Mouse;
    using MouseButtons = System.Windows.Forms.MouseButtons;
    
    
    [GeneratedCode("コード化された UI テスト ビルダー", "12.0.21005.1")]
    public partial class UIMap
    {
        
        /// <summary>
        /// RecordedMethod1
        /// </summary>
        public void RecordedMethod1()
        {
            #region Variable Declarations
            WinButton uIButton1Button = this.UIForm1Window.UIButton1Window.UIButton1Button;
            WinButton uIButton5Button = this.UIForm1Window.UIButton5Window.UIButton5Button;
            WinButton uIButton9Button = this.UIForm1Window.UIButton9Window.UIButton9Button;
            WinButton uIButton3Button = this.UIForm1Window.UIButton3Window.UIButton3Button;
            WinButton uIButton7Button = this.UIForm1Window.UIButton7Window.UIButton7Button;
            #endregion

            // クリック 'button1' ボタン
            Mouse.Click(uIButton1Button, new Point(31, 11));

            // クリック 'button5' ボタン
            Mouse.Click(uIButton5Button, new Point(28, 12));

            // クリック 'button9' ボタン
            Mouse.Click(uIButton9Button, new Point(48, 18));

            // クリック 'button3' ボタン
            Mouse.Click(uIButton3Button, new Point(27, 4));

            // クリック 'button5' ボタン
            Mouse.Click(uIButton5Button, new Point(10, 17));

            // クリック 'button7' ボタン
            Mouse.Click(uIButton7Button, new Point(29, 13));
        }
        
        #region Properties
        public UIForm1Window UIForm1Window
        {
            get
            {
                if ((this.mUIForm1Window == null))
                {
                    this.mUIForm1Window = new UIForm1Window();
                }
                return this.mUIForm1Window;
            }
        }
        #endregion
        
        #region Fields
        private UIForm1Window mUIForm1Window;
        #endregion
    }
    
    [GeneratedCode("コード化された UI テスト ビルダー", "12.0.21005.1")]
    public class UIForm1Window : WinWindow
    {
        
        public UIForm1Window()
        {
            #region 検索条件
            this.SearchProperties[WinWindow.PropertyNames.Name] = "Form1";
            this.SearchProperties.Add(new PropertyExpression(WinWindow.PropertyNames.ClassName, "WindowsForms10.Window", PropertyExpressionOperator.Contains));
            this.WindowTitles.Add("Form1");
            #endregion
        }
        
        #region Properties
        public UIButton1Window UIButton1Window
        {
            get
            {
                if ((this.mUIButton1Window == null))
                {
                    this.mUIButton1Window = new UIButton1Window(this);
                }
                return this.mUIButton1Window;
            }
        }
        
        public UIButton5Window UIButton5Window
        {
            get
            {
                if ((this.mUIButton5Window == null))
                {
                    this.mUIButton5Window = new UIButton5Window(this);
                }
                return this.mUIButton5Window;
            }
        }
        
        public UIButton9Window UIButton9Window
        {
            get
            {
                if ((this.mUIButton9Window == null))
                {
                    this.mUIButton9Window = new UIButton9Window(this);
                }
                return this.mUIButton9Window;
            }
        }
        
        public UIButton3Window UIButton3Window
        {
            get
            {
                if ((this.mUIButton3Window == null))
                {
                    this.mUIButton3Window = new UIButton3Window(this);
                }
                return this.mUIButton3Window;
            }
        }
        
        public UIButton7Window UIButton7Window
        {
            get
            {
                if ((this.mUIButton7Window == null))
                {
                    this.mUIButton7Window = new UIButton7Window(this);
                }
                return this.mUIButton7Window;
            }
        }
        #endregion
        
        #region Fields
        private UIButton1Window mUIButton1Window;
        
        private UIButton5Window mUIButton5Window;
        
        private UIButton9Window mUIButton9Window;
        
        private UIButton3Window mUIButton3Window;
        
        private UIButton7Window mUIButton7Window;
        #endregion
    }
    
    [GeneratedCode("コード化された UI テスト ビルダー", "12.0.21005.1")]
    public class UIButton1Window : WinWindow
    {
        
        public UIButton1Window(UITestControl searchLimitContainer) : 
                base(searchLimitContainer)
        {
            #region 検索条件
            this.SearchProperties[WinWindow.PropertyNames.ControlName] = "button1";
            this.WindowTitles.Add("Form1");
            #endregion
        }
        
        #region Properties
        public WinButton UIButton1Button
        {
            get
            {
                if ((this.mUIButton1Button == null))
                {
                    this.mUIButton1Button = new WinButton(this);
                    #region 検索条件
                    this.mUIButton1Button.SearchProperties[WinButton.PropertyNames.Name] = "button1";
                    this.mUIButton1Button.WindowTitles.Add("Form1");
                    #endregion
                }
                return this.mUIButton1Button;
            }
        }
        #endregion
        
        #region Fields
        private WinButton mUIButton1Button;
        #endregion
    }
    
    [GeneratedCode("コード化された UI テスト ビルダー", "12.0.21005.1")]
    public class UIButton5Window : WinWindow
    {
        
        public UIButton5Window(UITestControl searchLimitContainer) : 
                base(searchLimitContainer)
        {
            #region 検索条件
            this.SearchProperties[WinWindow.PropertyNames.ControlName] = "button5";
            this.WindowTitles.Add("Form1");
            #endregion
        }
        
        #region Properties
        public WinButton UIButton5Button
        {
            get
            {
                if ((this.mUIButton5Button == null))
                {
                    this.mUIButton5Button = new WinButton(this);
                    #region 検索条件
                    this.mUIButton5Button.SearchProperties[WinButton.PropertyNames.Name] = "button5";
                    this.mUIButton5Button.WindowTitles.Add("Form1");
                    #endregion
                }
                return this.mUIButton5Button;
            }
        }
        #endregion
        
        #region Fields
        private WinButton mUIButton5Button;
        #endregion
    }
    
    [GeneratedCode("コード化された UI テスト ビルダー", "12.0.21005.1")]
    public class UIButton9Window : WinWindow
    {
        
        public UIButton9Window(UITestControl searchLimitContainer) : 
                base(searchLimitContainer)
        {
            #region 検索条件
            this.SearchProperties[WinWindow.PropertyNames.ControlName] = "button9";
            this.WindowTitles.Add("Form1");
            #endregion
        }
        
        #region Properties
        public WinButton UIButton9Button
        {
            get
            {
                if ((this.mUIButton9Button == null))
                {
                    this.mUIButton9Button = new WinButton(this);
                    #region 検索条件
                    this.mUIButton9Button.SearchProperties[WinButton.PropertyNames.Name] = "button9";
                    this.mUIButton9Button.WindowTitles.Add("Form1");
                    #endregion
                }
                return this.mUIButton9Button;
            }
        }
        #endregion
        
        #region Fields
        private WinButton mUIButton9Button;
        #endregion
    }
    
    [GeneratedCode("コード化された UI テスト ビルダー", "12.0.21005.1")]
    public class UIButton3Window : WinWindow
    {
        
        public UIButton3Window(UITestControl searchLimitContainer) : 
                base(searchLimitContainer)
        {
            #region 検索条件
            this.SearchProperties[WinWindow.PropertyNames.ControlName] = "button3";
            this.WindowTitles.Add("Form1");
            #endregion
        }
        
        #region Properties
        public WinButton UIButton3Button
        {
            get
            {
                if ((this.mUIButton3Button == null))
                {
                    this.mUIButton3Button = new WinButton(this);
                    #region 検索条件
                    this.mUIButton3Button.SearchProperties[WinButton.PropertyNames.Name] = "button3";
                    this.mUIButton3Button.WindowTitles.Add("Form1");
                    #endregion
                }
                return this.mUIButton3Button;
            }
        }
        #endregion
        
        #region Fields
        private WinButton mUIButton3Button;
        #endregion
    }
    
    [GeneratedCode("コード化された UI テスト ビルダー", "12.0.21005.1")]
    public class UIButton7Window : WinWindow
    {
        
        public UIButton7Window(UITestControl searchLimitContainer) : 
                base(searchLimitContainer)
        {
            #region 検索条件
            this.SearchProperties[WinWindow.PropertyNames.ControlName] = "button7";
            this.WindowTitles.Add("Form1");
            #endregion
        }
        
        #region Properties
        public WinButton UIButton7Button
        {
            get
            {
                if ((this.mUIButton7Button == null))
                {
                    this.mUIButton7Button = new WinButton(this);
                    #region 検索条件
                    this.mUIButton7Button.SearchProperties[WinButton.PropertyNames.Name] = "button7";
                    this.mUIButton7Button.WindowTitles.Add("Form1");
                    #endregion
                }
                return this.mUIButton7Button;
            }
        }
        #endregion
        
        #region Fields
        private WinButton mUIButton7Button;
        #endregion
    }
}

つまり、UIMap.uitestには、UIテストの操作の記録やアサーションを追加するびに、このようなコードが蓄積されていくのです。
一応、一応ですが、メンテナンス用に編集画面が用意されていますが、1つのファイルに何百個もクラスが出来上がった暁には手に負えなくなります。

かといって、UIMapのデザイナコードを手で編集しようとしたときに、フォームデザイナのように、「いじるな」とは書いてませんが、いじると、操作の記録に変更があった場合、自動的に消えたりするので、いじれません(笑)

Formのデザイナコードに書かれている注釈

UIMap.designer.csに書かれている注釈

解決策=自分で作る

※以下は、WinFormsの場合ですが、Web系の場合も同じ原理です。

そこで、汎用的な独自のUIMapを自作して標準のUIMap.designer.cs内に操作の記録のコードを溜め込まないようにします。

独自のUIMapとは、WinFormsの場合ですと、WinWindowクラスを継承したクラスになります。
テストケースと独自のUIMapの関係をクラス図で示すとこうなります

その中に、たとえば、ボタンまでのアクセサを汎用的なメソッド(GetWinButtonなど)として定義し、それらを呼ぶようにします。
独自のUIMapでは多数のコントロールのクラス定義をするのではなく、このようなアクセサをつないで、コントロールにアクセスできるようにします。

/// <summary>
/// WinWindow内のボタンを取得する
/// </summary>
/// <param name="sName">ボタンのName属性</param>
/// <returns>WinButton</returns>
public WinButton GetWinButton(string sName)
{
    WinButton oButton = new WinButton(this);
    oButton.SearchProperties[WinWindow.PropertyNames.ControlName] = sName;
    oButton.WindowTitles.Add(this.WindowTitles[0]);
    return oButton;
}

UIテストクラス内では、既定のUIMapを使うようになっていますので、独自のUIMapに差し替えます。

さらに、UIテストクラス内の操作の記録の実行処理(RecordedMethod)も変更する必要があります。

//ボタン1を探してクリックする例

//Form1上のbutton1を取得
WinButton uButton = this.OriginalUIMap.GetWindow("Form1").GetWindow("button1").GetButtun("button1");
// クリック 'button1' ボタン
Mouse.Click(uIButton1Button, new Point(58, 18));

※その後、同様にアサーションの部分についても同じように変更していきます。

ここで、独自のUIMapは各UIテストケースで保有する必要はありませんので、
UIテストクラスの基底クラスを1つ用意しておくといいでしょう。

目的

UITestを手で作る意味としては、規定のUIMapがメンテナンス不能にならないようにするためのほか、
より複雑な画面操作が求められる場合があるためでもあります。

たとえばパラメータが動的に変わるなどの何らかのロジックが噛んでいる場合は規定のUIMapでは対応し切れませんし、静的解析ツールの制約でクラス内の行数が制限されている場合などもやむ終えないような気がします。

UIテストのメンテナンス性を高めるために

小規模のテストを組み合わせる(順次指定テストの活用)

私のUIテストの作成の方針は、操作や検証のメソッドを細かく分けるです。
ユニットテスト、ウェブテストも同様です。
これらを組み合わせて、順次指定テストで1テストケースの流れを作ります。

そうすることで、操作の1箇所に変更が発生しても、1つの操作を見直すことで、今まで通りにテストが継続できるようになります。複雑な手順を何十行にわたってテストコードを書くより楽なのでオススメです。

ビジネス価値の大きい部分を優先する

たとえば、アプリケーションの起動処理がNGですと、致命的です。
ソフトウェアのビジネス価値に大きく影響する部分からテスト自動化することでコストの回収を効率的に進めることができます。
逆に、変更が多く、画面が複雑でわかりにくい部分などは、テストコードの生存期間が短いため、作っても無駄になる場合がありますので、手動テストとし、古典的なテスト手法や探索的テストで健全性を見るほうがいいかもしれません。