Help us understand the problem. What is going on with this article?

単体テストを書くクラスでXamarin.*を呼び出すとテストに失敗することがある(Xamarin)

はじめに

本記事は Xamarin Advent Calendar 2020 の1日目の記事です。
XamarinでxUnit + Moqを使って単体テストを実装する際のコツを紹介します。

2020/12/04 追記

コメントでご指摘を頂き、タイトルや本文を変更しました。
Xamarinの経験が浅くてまだ理解できていないため、詳細はコメント欄をご参照ください。

環境

  • Visual Studio Community 2019 for Mac:8.8 (build 2913)
  • xUnit:2.4.0
  • Moq:4.14.7
  • Xamarin.Essentials:1.5.3.2

実装

かんたんなC#のクラスと単体テストを実装します。

テスト対象クラスの実装

Xamarin.Essentials.AppInfo.VersionString を返すだけの GetAppVersion() メソッドを持った FooService クラスを実装します。

FooService.cs
using Xamarin.Essentials;

namespace Foo.Services
{
    public interface IFooService
    {
        string GetAppVersion();
    }

    public class FooService : IFooService
    {
        public FooService()
        {
        }

        public string GetAppVersion()
        {
            return AppInfo.VersionString;
        }
    }
}

テストクラスの実装

GetAppVersion() メソッドの戻り値が空でないことを確認するだけの単体テストを実装します。

FooServiceTests.cs
using Foo.Services;
using Xunit;

namespace Foo.UnitTests.Services
{
    public class FooServiceTests
    {
        [Fact]
        public void OutputAppVersion()
        {
            var fooService = CreateDefaultFooService();

            var appVersion = fooService.GetAppVersion();

            Assert.NotEmpty(appVersion);
        }

        private FooService CreateDefaultFooService()
        {
            return new FooService();
        }
    }
}

単体テストの実行

上記の単体テストを実行すると、以下のエラーで失敗します。

Xamarin.Essentials.NotImplementedInReferenceAssemblyException : This functionality is not implemented in the portable version of this assembly. You should reference the NuGet package from your main application project in order to reference the platform-specific implementation.

どうやら Xamarin.* のクラスを単体テストで呼び出せないことがあるようです。
Xamarin.* だと主語が大きいのですが、適切な表現が思いつかないためこのままとさせてください。

単体テスト失敗の解決策

このままでは単体テストが実行できないため、解決策を考えます。

解決策1: 単体テストから呼び出せないクラスをモックに差し替える

単体テストから呼び出せないクラスはDIして、テスト時にモックへ差し替えるようにします。

AppInfoService クラスを新規作成し、 FooService クラスから Xamarin.Essentials への依存を切り出します。

AppInfoService.cs
using Xamarin.Essentials;

namespace Foo.Services
{
    public interface IAppInfoService
    {
        string GetAppVersion();
    }

    public class AppInfoService : IAppInfoService
    {
        public AppInfoService()
        {
        }

        public string GetAppVersion()
        {
            return AppInfo.VersionString;
        }
    }
}

FooService クラスにコンストラクタ経由で AppInfoService クラスをDIすれば、 Xamarin.Essentials に依存しなくなります。

FooService.cs
- using Xamarin.Essentials;

namespace Foo.Services
{
    public interface IFooService
    {
        string GetAppVersion();
    }

    public class FooService : IFooService
    {
+         private readonly IAppInfoService appInfoService;

-         public FooService()
+         public FooService(IAppInfoService appInfoService)
        {
+             this.appInfoService = appInfoService;
        }

        public string GetAppVersion()
        {
-             return AppInfo.VersionString;
+             return appInfoService.GetAppVersion();
        }
    }
}

単体テストではMoq(モックライブラリ)を使って IAppInfoService のモックを生成し、 FooService クラスにDIします。

モックで GetAppVersion() メソッドの戻り値を 1.0.0 に固定し、アサートで 1.0.0 と一致するか確認します。
Moqはメソッドが何回呼ばれたかかんたんに確認できるので、1回のみ呼ばれたか( Times.Once() )を確認します。

FooServiceTests.cs
using Foo.Services;
+ using Moq;
using Xunit;

namespace Foo.UnitTests.Services
{
    public class FooServiceTests
    {
        [Fact]
        public void OutputAppVersion()
        {
+             var mockIAppInfoService = CreateDefaultMockIAppInfoService();
-             var fooService = CreateDefaultFooService();
+             var fooService = CreateDefaultFooService(mockIAppInfoService);

            var appVersion = fooService.GetAppVersion();

-             Assert.NotEmpty(appVersion);
+             Assert.Equal("1.0.0", appVersion);
+             Mock.Get(mockIAppInfoService).Verify(s => s.GetAppVersion(), Times.Once());
        }

-         private FooService CreateDefaultFooService()
+         private FooService CreateDefaultFooService(IAppInfoService appInfoService)
        {
-             return new FooService();
+             return new FooService(appInfoService);
        }

+         private IAppInfoService CreateDefaultMockIAppInfoService()
+         {
+             var mock = Mock.Of<IAppInfoService>(s =>
+             s.GetAppVersion() == "1.0.0"
+             );
+ 
+             return mock;
+         }
    }
}

これで単体テストを実行すると、無事に成功します :confetti_ball:

解決策2: Xamarin.Essentials.Interfacesを使う

コメント で教えていただいたのですが、Xamarin.Essentials.Interfacesというパッケージが非公式で公開されています。
https://www.nuget.org/packages/Xamarin.Essentials.Interfaces/
https://github.com/rdavisau/essential-interfaces

まだ試していませんが、こちらを使うのもよさそうです。

おわりに

Xamarinでは Xamarin.* に依存しないようにビジネスロジックのクラスを実装すると、テスタブルになります。
依存していてもテストする方法はあるようですが、それでも Xamarin.* を切り出しておくのは設計パターンとして悪くないと思います。

以上、 Xamarin Advent Calendar 2020 の1日目の記事でした。
明日は @ytabuchi さんの記事です。

uhooi
iOSアプリ開発とSwiftが好きです✨ 趣味:テニス、アナログゲーム
https://theuhooi.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away