在C#中打破依赖关系的最佳方法是什么?

5
我们正在考虑为我们的C#代码库添加单元测试。我发现对于简单的类添加单元测试很容易,但是与其他依赖关系交互的类更加困难。我一直在寻找模拟框架,但是想知道在编写类以打破外部依赖项(如文件系统、数据库和消息系统依赖项)方面的最佳方法。
举个例子,一个程序在套接字上监听某种格式的消息 - 比如MessageA。这被解码,进行一些计算,然后重新编码为不同的二进制格式,然后发送结果消息,即MessageB。
我的当前测试方法如下。我提取了所有套接字交互的接口,并创建了一个模拟接口。我将接口设置为单例。然后使用硬编码输入运行类。正在测试的类将使用单例中的接口来发送/接收。
我用类似的方式测试数据库交互。
这似乎不是最灵活的方法,你如何改进这一点以使它更容易测试?如果模拟框架是答案,那么我应该如何设计这些类?
示例代码:
[SetUp]
public void init()
{
    // set message interface in singleton as mock interface
    CommAdapter.Instance.MessageAdapter = new MockMessage();

    // build reference message from hard coded test variables
    initialiseMessageA();

    // set input from mock message socket
    ((MockMessage) CommAdapter.Instance.MessageAdapter).MessageIn = m_messageA;
}

[Test]
public void test_listenMessage_validOutput()
{
    // initialise test class
    MessageSocket tS = new MessageSocket();

    // read from socket
    tS.listenMessage();

    // extract mock interface from singleton
    MockMessage mm = ((MockMessage) CommAdapter.Instance.MessageAdapter);

    // assert sent message is in correct / correstpoinding format
    Assert.AreEqual(1000001, mm.SentMessageB.TestField);

}

1
Drexiya,如果你有时间,我强烈建议你阅读《Growing Object-Oriented Software, Guided by Tests》(http://www.amazon.co.uk/Growing-Object-Oriented-Software-Guided-Signature/dp/0321503627)。这是一本易读的300页书(你可以在周末完成阅读)。这是一本描述如何正确进行单元测试并大量讨论如何使用模拟的优秀书籍。书中的代码示例是用Java编写的,但我相信你可以理解其中的抽象思想,并使用moq或类似的模拟库将其应用到C#中。 - Augusto
谢谢你的推荐,Augusto。我一定会去看看那本书。 - C Mars
2个回答

7

不要使用单例来设置组件实现,而是使用依赖注入和像Ninject这样的DI库。这正是它们设计的场景。

我并不特别推荐你使用Ninject,但是他们有一个不错的教程 :) 这些概念可以应用到其他框架中(如Unity)。

仅使用DI,代码将如下所示:

class Samurai {
  private IWeapon _weapon;
  public Samurai(IWeapon weapon) {
    _weapon = weapon;
  }
  public void Attack(string target) {
    _weapon.Hit(target);
  }
}

class Shuriken : IWeapon {
  public void Hit(string target) {
    Console.WriteLine("Pierced {0}'s armor", target);
  }
}

class Program {
  public static void Main() {
    Samurai warrior1 = new Samurai(new Shuriken());
    Samurai warrior2 = new Samurai(new Sword());
    warrior1.Attack("the evildoers");
    warrior2.Attack("the evildoers");
  }
}

这看起来很干净,但等到你的依赖项有了依赖项或更多时,就会变得混乱。不过你可以使用一个 DI 库来解决这个问题。
使用库来处理连接,它将看起来像这样:
class Program {
  public static void Main() {
    using(IKernel kernel = new StandardKernel(new WeaponsModule()))
    {
      var samurai = kernel.Get<Samurai>();
      warrior1.Attack("the evildoers");
    }
  }
}

// Todo: Duplicate class definitions from above...

public class WarriorModule : NinjectModule {
  public override void Load() {
    Bind<IWeapon>().To<Sword>();
    Bind<Samurai>().ToSelf().InSingletonScope();
  }
}

无论采用哪种方法,再加上像 Moq 这样的模拟对象框架,您的单元测试看起来像这样:

With either of these approaches, plus a mock object framework like Moq, your unit tests look something like this:

[Test]
public void HitShouldBeCalledByAttack()
{
    // Arrange all our data for testing
    const string target = "the evildoers";
    var mock = new Mock<IWeapon>();
    mock.Setup(w => w.Hit(target))
        .AtMostOnce();

    IWeapon mockWeapon = mock.Object;
    var warrior1 = new Samurai(mockWeapon);

    // Act on our code under test
    warrior1.Attack(target);

    // Assert Hit was called
    mock.Verify(w => w.Hit(target));
}

你会注意到,你可以直接将模拟实例传递给被测试的代码,而不必设置单例。这将帮助你避免像需要多次设置状态或在调用之间设置状态等问题。它意味着没有隐藏的依赖关系。
你还会注意到,在测试中我没有使用DI容器。如果你的代码经过良好的分解,它只会测试一个类(尽可能地,只测试一个方法),并且你只需要模拟该类的直接依赖项。

嗨Merlyn,感谢您详细的回复。我会研究这种解决方案。在我深入探讨之前,您认为这对于只需要有限灵活性的应用程序来说是否过度了吗? - C Mars
另一个问题是我会给代码添加额外的开销。我看过一些基准测试,表明Ninject可能有点慢。 - C Mars
有一个网站声称StructureMap是一种快速解决方案,但他们的介绍指出:“如果应用程序或进程需要很少的灵活性,请不要使用StructureMap。在更简单的系统或进程中,StructureMap提供的抽象和间接性是不必要甚至有害的。” - C Mars
@drexiya:在应用程序根目录中只使用DI容器一次。因此,您只应在启动时看到减速。我没有注意到它很慢。无论如何,有大量的DI库 :) 至于“灵活性不足”,您的应用程序需要什么样的灵活性?能够使用模拟对象替换依赖项听起来像是需要灵活性... - Merlyn Morgan-Graham
1
@drexiya:与第一个示例非常相似,只是使用 new TopLevel(new FirstDep(new SecondDep(), new SecondDep2())) 等等。然后您将不需要 DI 库,只需要 DI 模式。这也是 DI 可以达到的最快速度。 - Merlyn Morgan-Graham
显示剩余3条评论

1

除了DI容器(我目前使用的是MS Unity 2.0,但有许多选择),您还需要一个好的模拟框架,我的首选是MOQ。打破具体依赖的常见模式/过程如下:

  • 通过接口定义依赖项;您可能会运气好并且已经有一个接口,例如IDbConnection,或者您需要使用代理来包装具体类型并定义自己的接口。
  • 通过您的DI容器解析具体实现
  • 在测试设置时间将模拟实现注入到DI容器中(在系统启动时注入真实的实现)

抱歉 @Merlyn Morgan-Graham,这基本上是相同的答案。 - Myles McDonnell
不需要道歉。虽然SO并没有明确支持重复答案,但也没有真正反对它们。假设它们是不可避免的——人们经常同时阅读问题。投票可以解决这个问题。+1,因为这个答案仍然很好,而且紧凑。我建议你在单元测试中跳过使用DI容器。我认为这是不必要的。 - Merlyn Morgan-Graham

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接