🍃

Poser-テスト時にC#でstaticメソッドなどを置き換える

に公開

はじめに

C#などの.NETではテストに依存するコンポーネントを置き換えることにハードルがあります
Poserは依存注入なしでstaticメソッドだろうが、インスタンスのメソッドだろうが置き換えることが可能です。
この記事はPoserの実験を行い、あわせて簡単な使い方を説明します。
この記事は.NETでテストを書いたことのある読者を想定しているため、C#の書き方やMSTestの書き方などは一切説明しません。

Poserについて

もともとはtonerdo氏が開発したposeというオープンソースでMicrosoft Fakesの代替を目指していたライブラリがありました。
残念なことに、数年間その活動が停滞していました。
しかしMiista氏がposeを派生させて"Poser"という名前で開発を続けています

Miista氏のGitHub
https://github.com/Miista/pose

Poserを導入するには以下のようにしてください。

# Visual Studio:
Install-Package Poser
or
# .NET Core CLI:
dotnet add package Poser

また、プロセス中のバイトコードを書き換える都合上、テストを並列で動かすのは避けるべきです。
MSTestを使用している場合は以下のようにテストの並列実行をOFFにする設定をして実行してください。

<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
  <RunConfiguration>
    <!-- CPU 1個分だけ使う(=プロセス並列なし) -->
    <MaxCpuCount>1</MaxCpuCount>
  </RunConfiguration>
  <MSTest>
    <Parallelize>
      <!-- テストメソッドの並列実行を実質オフにする -->
      <Workers>1</Workers>
      <Scope>MethodLevel</Scope>
    </Parallelize>
  </MSTest>
</RunSettings>

上記の設定を有効にしてテストを実施するには以下のようにします。

dotnet test --logger "console;verbosity=detailed" --settings test.runsettings

Poserの実験と使用方法

実験環境
OS: MacOS
TargetFramework: net9.0

最低限の使用方法は以下のREADME.mdに記載されているので参照してください。
https://github.com/Miista/pose

標準ライブラリのstaticメソッドを差し替える

以下の例ではConsole.WriteLineDateTime.Nowに差し替えます。

using System.Collections.Generic;
using Example.External;
using Example.Service;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Pose;

namespace Example.Tests.Service
{
    [TestClass]
    public class UserServicePoserTests
    {
        [TestMethod]
        public void ConsoleTest()
        {
            Console.WriteLine("テスト中のコンソール出力です");
            Console.WriteLine(DateTime.Now);
            Shim consoleShim = Shim.Replace(
                () => Console.WriteLine(Is.A<string>())
            ).With(
                delegate (string s) {
                    Console.WriteLine("Hijacked: {0}", s);
                }
            );
            Shim dateTimeShim = Shim.Replace(
                () => DateTime.Now
            ).With(
                () => new DateTime(2004, 4, 4)
            );

            PoseContext.Isolate(() =>
            {
                Console.WriteLine("テスト中のコンソール出力です");
                Console.WriteLine(DateTime.Now);
            }, consoleShim, dateTimeShim);
            Console.WriteLine("テスト中のコンソール出力です");


            Assert.IsTrue(true);
        }
    }
}

まず、置換するメソッドと置換先のデリゲートをShimクラスに指定します。
PoseContext.Isolateに作成したShim渡し、そのコンテキストの間は置換元のメソッドが置換先のメソッドに置き換わります。

このテストを標準出力が出力されるように実行します。

dotnet test --logger "console;verbosity=detailed" --settings test.runsettings

結果は以下のようになります。

  標準出力メッセージ:
 テスト中のコンソール出力です
 11/20/2025 09:46:53
 Hijacked: テスト中のコンソール出力です
 04/04/2004 00:00:00
 テスト中のコンソール出力です

PoseContext.Isolate中のConsole.WriteLine(string)DateTime.Nowが置換されていることが確認できます。
なお、Console.WriteLine(DateTime.Now)Console.WriteLine(object) のオーバーロードが呼ばれるため、Console.WriteLine(string) をターゲットにした Shim には一致せず、置き換えが発動しません。

依存注入なしでインスタンスのメソッドを置き換える

このサンプルでは依存注入なしでインスタンスメソッドを置き換えるサンプルを紹介します。

テスト対象関連のコード

依存コンポーネントのインターフェイス

// Example/External/IDatabaseClient.cs
using System.Collections.Generic;

namespace Example.External
{
    public interface IDatabaseClient
    {
        Dictionary<string, string> FetchUser(string userId);
    }
}
// Example/External/IMessageClient.cs
using System.Collections.Generic;

namespace Example.External
{
    public interface IMessageClient
    {
        bool SendUser(Dictionary<string, string> userDict);
    }
}
// Example/External/RealDatabaseClient.cs
using System;
using System.Collections.Generic;

namespace Example.External
{
    // 本来は DB に接続する実装だが、ここでは例として NotImplemented
    public class RealDatabaseClient : IDatabaseClient
    {
        public virtual Dictionary<string, string> FetchUser(string userId)
        {
            throw new NotImplementedException("Real DB access is not implemented.");
        }
    }
}
// Example/External/RealMessageClient.cs
using System;
using System.Collections.Generic;

namespace Example.External
{
    public class RealMessageClient : IMessageClient
    {
        // モックしない限りは例外を投げるだけの実装
        public virtual bool SendUser(Dictionary<string, string> userDict)
        {
            throw new NotImplementedException("Real message sending is not implemented.");
        }

        // EasyMock 側で getVersion() を検証していたのに対応
        public virtual string GetVersion() {
          return "ver0.1";
        }
    }
}

テスト対象

// Example/Service/UserService.cs
// Example/Service/UserService.cs
using System.Collections.Generic;
using Example.External;

namespace Example.Service
{
    public class UserService
    {
        private readonly IDatabaseClient _databaseClient;
        private readonly IMessageClient _messageClient;

        public UserService(IDatabaseClient databaseClient, IMessageClient messageClient)
        {
            _databaseClient = databaseClient;
            _messageClient = messageClient;
        }

        public UserService()
        {
            _databaseClient = new RealDatabaseClient();
            _messageClient = new RealMessageClient();
        }

        public bool SendUserInfo(string userId)
        {
            Dictionary<string, string> userDict = _databaseClient.FetchUser(userId);
            return _messageClient.SendUser(userDict);
        }
    }
}

テストコード

using System.Collections.Generic;
using Example.External;
using Example.Service;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Pose;

namespace Example.Tests.Service
{
    [TestClass]
    public class UserServicePoserTests
    {
        [TestMethod]
        public void Testing()
        {
            var dbUser = new Dictionary<string, string>
            {
                ["id"] = "42",
                ["display_name"] = "テスト太郎",
            };
            Shim dbClientFetchUserShim = Shim.Replace(
                () => Is.A<RealDatabaseClient>().FetchUser(Is.A<string>())
            ).With(
                delegate (RealDatabaseClient @this, string userId) {
                    Console.WriteLine($"dummy FetchUser {userId}");
                    return dbUser;
                }
            );
            var callSendUsers = new List<Dictionary<string, string>>();
            Shim msgClientSendUserShim = Shim.Replace(
                () => Is.A<RealMessageClient>().SendUser(Is.A<Dictionary<string, string>>())
            ).With(
                delegate (RealMessageClient @this, Dictionary<string, string> userDict) {
                    Console.WriteLine($"dummy SendUser {userDict}");
                    callSendUsers.Add(userDict);
                    return true;
                }
            );
            bool sendUserInfoResult = false;
            PoseContext.Isolate(() =>
            {
                var service = new UserService();
                sendUserInfoResult = service.SendUserInfo("42");
            }, dbClientFetchUserShim, msgClientSendUserShim);
            // Isolateの中だと不安定なので検証コードは外に出しておく
            Assert.IsTrue(sendUserInfoResult, "sendUserInfoが成功していること");
            Assert.AreEqual(1, callSendUsers.Count, "SendUserが規定の回数実行されている");
            Assert.AreEqual(dbUser, callSendUsers[0], "SendUserの引数が正しいこと");
        }
    }
}

分離コード中で作成されたRealDatabaseClientRealMessageClientについては、Shimで指定したメソッドが置換されています。
また、分離コードはテストコンテキストではなく別のコンテキストで実行されるため、Isolate中にAssertやShimのセットアップは行わないようにしてください。

まとめ

Microsoft Fakesと同じようにstaticメソッドの置換や、依存注入なしでの置換がPoserでもできることを確認しました。
いくつかの制限はありますが、Microsoft Fakesの代替のテストダブルとして有力な候補だと思います。

Discussion