2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

NUnitでVSTOアドイン(Outlook)の単体テストを実施した話

Last updated at Posted at 2018-03-28

#概要
SE1年生の私ですが、先日Visual Studioを使い、Outlook用のVSTOアドインを開発しました。
そのアドインの単体テストの話を、備忘録として記事にします。

今回は、__NUnit__で単体テストを行うにあたり「リフレクション」や、
モックライブラリの「NSubstitute」を用いました。

開発環境

  • Visual Studio 2015 Professional
  • NUnit 3.9.0
  • NSubstitute 3.1.0

構成

開発したアドインのソリューションの構成はこのようになっています。
tree_colored.PNG

以下、この記事に出てくる、クラス名やメソッド名の紹介です。

テストクラス (テストする側)
プロジェクト名: ORCAUnitTest
クラス名: GetRecipientsUnitTest

テスト対象 (テストされる側)
プロジェクト名: OutlookRecipientConfirmationAddin
クラス名: Utility
メソッド名: GetRecipients

GetRecipientsメソッドのソースコードはこちら
Utility.cs
        /// <summary>
        /// アイテムから、宛先(Recipient)のリスト取得する
        /// </summary>
        /// <param name="Item">Outlookアイテムオブジェクト</param>
        /// <param name="type">アイテムの種類</param>
        /// <param name="IgnoreMeetingResponse">会議招集の返信かどうか</param>
        /// <returns>Recipientsインスタンス(会議招集の返信や、MailItem,MeetingItem,AppointmentItemでない場合null)</returns>
        public static List<Outlook.Recipient> GetRecipients(object Item, ref OutlookItemType type, bool IgnoreMeetingResponse = false)
        {
            Outlook.Recipients recipients = null;
            bool isAppointmentItem = false;

            Outlook.MailItem mail = Item as Outlook.MailItem;
            // MailItemの場合
            if (mail != null)
            {
                recipients = mail.Recipients;
                type = OutlookItemType.Mail;
            }

            // (中略)

            else if (Item is Outlook.ReportItem)
            {
                Outlook.ReportItem item = Item as Outlook.ReportItem;

                //ReportItemのままだと送信先が取れないため、
                //いったんIPM.Noteとして別名保存⇒ロードしてからRecipientsを取得する
                Outlook.ReportItem copiedReport = item.Copy();
                copiedReport.MessageClass = "IPM.Note";
                copiedReport.Save();

                //IPM.Noteとして保存してからロードするとMailItemとして扱えるようになる
                var newReportItem = Globals.ThisAddIn.Application.Session.GetItemFromID(copiedReport.EntryID);
                Outlook.MailItem newMailItem = newReportItem as Outlook.MailItem;
                recipients = newMailItem.Recipients;
                type = OutlookItemType.Report;

                copiedReport.Delete();
            }

            // 受信者の情報をリストに入れる
            List<Outlook.Recipient> recipientsList = new List<Outlook.Recipient>();

            int i = isAppointmentItem ? 2 : 1;

            for (; i <= recipients.Count; i++)
            {
                // recipients[i]がBccまたはリソース
                if (recipients[i].Type == (int)Outlook.OlMailRecipientType.olBCC)
                {
                    // Bccや、選択されたリソースの場合
                    if (recipients[i].Sendable)
                    {
                        recipientsList.Add(recipients[i]);
                    }
                    // 選択されていないリソースの場合
                    else
                    {
                        continue;
                    }
                }
                // 送信者、To、Ccの場合
                else
                {
                    recipientsList.Add(recipients[i]);
                }
            }

            return recipientsList;
        }

直面した問題たち

初心者の私が、単体テストを作成するにあたり、直面した問題は以下の通り。

「テスト対象のクラス/メソッドにアクセスできない(publicでない)場合、どうすればテストできるのか?」
「Outlookを起動しないテストプロジェクトから、どのようにGlobals.ThisAddinインスタンスを生成するか?」

セットアップメソッド(テストが実行される前、1度だけ実行されるメソッド)のコーディング時に
少し頑張ることで、これらの問題を解決しました。

#セットアップメソッドに記述したこと

1. アセンブリでクラスメソッドの呼出し

背景

テストコードから、publicでないクラスやメソッドを呼出すと、ビルドエラーになります。
「'〇〇〇(publicでないクラスやメソッド名)'はアクセスできない保護レベルになっています」
との事。

テストプロジェクトとテスト対象の、アクセシビリティの問題です:pensive:

問題「テスト対象のクラス/メソッドにアクセスできない(publicでない)場合、どうすればテストできるのか?」

解決策

ズバリ、__「リフレクションを使った、アセンブリの動的呼出し」__です!

Init()メソッド内で、DLLファイルから動的にテスト対象のメソッドを呼び出しました。

GetRecipientsUnitTest.cs
        [OneTimeSetUp]
        public void Init()
        {
            //(中略)

            // アセンブリを読み込み、モジュールを取得
            Assembly asm = Assembly.LoadFrom(@".\ORCAUnitTest\bin\Debug\OutlookRecipientConfirmationAddin.dll");
            Module mod = asm.GetModule("OutlookRecipientConfirmationAddin.dll");

            // テスト対象のクラス(Utility)のタイプを取得
            Type type = mod.GetType("OutlookRecipientConfirmationAddin.Utility");

            // インスタンスを生成し、メソッドにアクセスできるようにする
            object obj = Activator.CreateInstance(type);
            MethodInfo mi = type.GetMethod("GetRecipients");

            //(中略)
        }

まず、アセンブリからモジュールを取得。
モジュールから、クラスの型(Type)を取得します。
最後に、クラスの型からインスタンスを生成すると、メソッドの属性 「mi」 を取得できます。

セットアップメソッドでの下準備は、以上です。

次に、テストコードです。

GetRecipientsUnitTest.cs
            var objArray = new object[] { testReport, Utility.OutlookItemType.Mail, false };
            object actualObj = mi.Invoke(obj, objArray);

Invoke メソッドを使うと、テストしたいメソッドを呼び出せました!:clap:

2. モックオブジェクトの作成

背景

例えば、テスト対象のメソッドには、このようなコードがあります。

Utility.cs
recResolve = Globals.ThisAddIn.Application.Session.CreateRecipient(sender.Address);

このGlobals.ThisAddinインスタンスは、Outlook起動時に自動で生成されるものです。
しかし、今回の単体テストはNUnitで実行するため、Outlookを起動しません。

問題「Outlookを起動しないテストプロジェクトから、どのようにGlobals.ThisAddinインスタンスを生成するか?」

解決策

ズバリ、__「モックオブジェクトを駆使する」__ことです!

単体テストクラスからは、この「ThisAddIn」にアクセスできないので、__自分でThisAddInクラス__を作成します。
NSubstituteを使い、あたかも本物の「ThisAddIn」が取得できたかのように、振る舞いを定義します。

GetRecipientsUnitTest.cs
        [OneTimeSetUp]
        public void Init()
        {
            //(中略)

            // ThisAddInクラスのインスタンスを生成
            TestFactory testFactory = new TestFactory();
            IServiceProvider testService = Substitute.For<IServiceProvider>();
            ThisAddIn testAddIn = new ThisAddIn(testFactory, testService);
            
            // ThisAddInのApplicaitionフィールドを取得し、モックの値(testApp)をセット
            Type typeThisAddIn = testAddIn.GetType();
            FieldInfo fieldApp = typeThisAddIn.GetField("Application", BindingFlags.NonPublic | BindingFlags.Instance);
            Application testApp = Substitute.For<TestApplication>();
            fieldApp.SetValue(testAddIn, testApp);

            // モックのApplication(testApp)のSessionに値(tesNs)をセットする
            testNs = Substitute.For<NameSpace>();
            testApp.Session.Returns(testNs);
            
            // GlobalsのThisAddinプロパティに、モックなどを使って作った値(testAddIn)をセットする
            Type typeGlobal = mod.GetType("OutlookRecipientConfirmationAddin.Globals");
            PropertyInfo testProp = typeGlobal.GetProperty("ThisAddIn", BindingFlags.NonPublic | BindingFlags.Static);
            testProp.SetValue(null, testAddIn);

            // モックのApplication(testApp)のSessionに値(tesNS)をセットする
            testNs = Substitute.For<NameSpace>();
            testApp.Session.Returns(testNs);

            //(中略)
        }

少しややこしいですが、これでThisAddInやApplication、Sessionの偽装が完了です。

全てのテストケースで、モックオブジェクトに同じ動作を期待する処理は、
以下のようなコードをセットアップメソッドに記述してもいいです。

GetRecipientsUnitTest.cs
Recipient testRec = Substitute.For<Recipient>();
testNs.CreateRecipient(Arg.Any<string>()).Returns(testRec);

ここでは、私は __.Returns() メソッド__を使っています。
テスト対象でCreateRecipientメソッドが呼ばれると、Recipient型の「testRec」が返却されるようになりました。

その他のモックについては、各テストコードで、テスト内容に応じてモックの動作の指定をします。

複数のテストクラスを同時に実行する場合の注意点

ThisAddInクラスを、上記の手順で複数生成すると、System.NotSupportedException になります。
その理由は、ThisAddInのコンストラクタにあります。

ThisAddIn.Designer.cs

        // (中略)

        public ThisAddIn(global::Microsoft.Office.Tools.Outlook.Factory factory, global::System.IServiceProvider serviceProvider) : 
                base(factory, serviceProvider, "AddIn", "ThisAddIn") {
            Globals.Factory = factory;
        }

        // (中略)

        internal static global::Microsoft.Office.Tools.Outlook.Factory Factory {
            get {
                return _factory;
            }
            set {
                if ((_factory == null)) {
                    _factory = value;
                }
                else {
                    throw new System.NotSupportedException();
                }
            }
        }

        // (中略)

ThisAddInのコンストラクタが、Factoryプロパティのセッターにアクセスする際に例外が発生したようです:warning:

手っ取り早い解決法は、ThisAddInクラスを一度生成したら、
「そのインスタンスをクラス間で使い回す」ことです。

おまけ:テスト実行時に困ったこと

その他、単体テストで難しいと感じたポイントを1点紹介します。
動的型付け変数である__dynamic__と、モックについてです。

発生内容

#### 手順

テストコード内で、先程紹介した通りにモックオブジェクトのメソッドの返り値を指定。

GetRecipientsUnitTest.cs
ReportItem reportItem = Substitute.For<ReportItem>();
reportItem.Copy().Returns(reportItem);

テストコードの残りの部分も完成させ、テストを実行。

#### 現象
__RuntimeBinderException__が発生。
追加情報:「'Castle.Proxies.ContactItemProxy'に'Returns'の定義がありません」
RuntimeBinderException.PNG

解析内容

_ReportItem[メタデータから]

    public interface _ReportItem
    {
        //(中略)
        dynamic Copy();
        //(中略)
    }

Copy()メソッドの返り値が「dynamic」となっているので、上手くいかない模様。

単体テスト中、他にもdynamicを返すメソッドを通ることありましたが、
そこでもRuntimeBinderException が発生しました:droplet:
どうやら、__NSubstituteのモックでは、動的型付け変数(dynamic)をうまく扱えない__ようです。

対応

  • ReportItemインターフェースを実装した、TestReportItemクラスを作成
MyTestClasses.cs

    public abstract class TestReportItem : ReportItem
    {
        //(中略)

        public abstract void Close(OlInspectorClose SaveMode);

        public dynamic Copy()
        {
            return CopyHon();
        }

        public virtual ReportItem CopyHon()
        {
            return null;
        }

        //(中略)

__CopyHon()__というメソッドを追加し、モックオブジェクトには、このメソッドを使ってもらうことにします。
Copy()メソッドのreturn値に、ReportItem型を返すCopyHon()メソッドを指定。

テスト対象のメソッドでは、変わりなくCopy()メソッドを使います。

  • 「手順」で紹介したテストコード2行を、以下の通りに修正
GetRecipientsUnitTest.cs
TestReportItem testReport = Substitute.For<TestReportItem>();
testReport.CopyHon().Returns(testReport)

こうすると、モックオブジェクトがdynamicを返り値とするメソッドにアクセスしないようになります。

以上2点を修正すると、RuntimeBinderExceptionを回避でき、思い通りの動作になりました。

完成したテストメソッド

そんなこんなで、遂に完成したテストメソッド!
長いので折りたたんでおきますが、興味のある方はご覧ください。

ソースコードはこちら
ReportItemUnitTest.cs
        /// <summary>
        /// アイテムが、ReportItemの場合</para>
        /// 【期待結果】Recipientsを取得できる
        /// TypeがReportになる
        /// </summary>
        [Test]
        public void ReportItemRecipientsTest()
        {
            // モックでつかうデータを用意
            string[] testRecNames = { "testemailaddress1@example.com", "testemailaddress2@example.com" };
            bool[] testRecSendable = { true, true };
            int[] testRecType = { (int)OlMailRecipientType.olTo, (int)OlMailRecipientType.olCC };

            // 期待結果を入れるリスト
            List<Recipient> expectedRecList = new List<Recipient>();
            expectedRecList.Add(expectedRec1);
            expectedRecList.Add(expectedRec2);

            testReport.CopyHon().Returns(testReport);

            // モックのReturn値と、期待結果のリストの値を設定
            MyTestNs myTestNs = Substitute.For<MyTestNs>();
            testApp.Session.Returns(myTestNs);
            myTestNs.GetItemFromIDHon(Arg.Any<string>()).Returns(testMail);

            // テストするメソッドにアクセスし、実際の結果を取得
            var objArray = new object[] { testReport, Utility.OutlookItemType.Mail, false };
            object actualObj = mi.Invoke(obj, objArray);

            // テスト対象メソッドの返り値をList<Recipient>型にする
            List<Recipient> actualRecList = new List<Recipient>();
            IEnumerable<Recipient> actualEnumList = (IEnumerable<Recipient>)actualObj;
            foreach (var actual in actualEnumList)
            {
                actualRecList.Add(actual);
            }

            SetExpectedValues(testRecNames, testRecSendable, testRecType, expectedRecList);

            // actualとexpectedのリストを比較
            Assert.AreEqual(actualRecList.Count, expectedRecList.Count);
            CompareLists(actualRecList, expectedRecList);

            // ref引数のtypeが正しいことを確認
            Assert.That(objArray[1], Is.EqualTo(Utility.OutlookItemType.Report));
        }

        /// <summary>
        /// 期待する結果リストの値を設定するメソッド
        /// </summary>
        /// <param name="testRecNames">Recipientのアドレス</param>
        /// <param name="testRecSendable">RecipientのSendableプロパティ</param>
        /// <param name="testRecType">RecipientのType</param>
        /// <param name="expectedRecList">期待結果のRecipient型リスト</param>
        private void SetExpectedValues(string[] testRecNames, bool[] testRecSendable, int[] testRecType, List<Recipient> expectedRecList)
        {
            int i = 0;
            foreach (string testRec in testRecNames)
            {
                expectedRecList[i].Address.Returns(testRecNames[i]);
                expectedRecList[i].Sendable = testRecSendable[i];
                expectedRecList[i].Type = testRecType[i];
                i++;
            }
        }

        /// <summary>
        /// 実際の値と、期待する値を比較するメソッド
        /// </summary>
        /// <param name="actualList">メソッドからもどってきたRecipient型リスト</param>
        /// <param name="expectedList">期待する結果を入れたRecipient型リスト</param>
        private void CompareLists(List<Recipient> actualList, List<Recipient> expectedList)
        {
            for (int i = 0; i < expectedList.Count; i++)
            {
                Assert.That(actualList[i].Address, Is.EqualTo(expectedList[i].Address));
                Assert.That(actualList[i].Sendable, Is.EqualTo(expectedList[i].Sendable));
                Assert.That(actualList[i].Type, Is.EqualTo(expectedList[i].Type));
            }
        }

#まとめ
この度は「NSubstitute」に、大々的にお世話になりました。
テスト対象のクラスに渡すデータの偽装ができ、とても便利です。

既存のインターフェースやクラスを継承した、
自作クラス(内容は、テストの都合の良いように変更する)を作成するのも必要不可欠ですね。

また、VSTOアドインの開発者向け情報が、インターネットにあまりなく苦戦しました。
Microsoft社のOutlook VBAリファレンスだけでも、もう少し分かりやすいようにしてほしいところ。

#関連リンク・参考にしたサイト

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?