TDD:测试与方法实现过于接近

3

我们已经进行了相当长时间的TDD,但在重构时仍然存在一些问题。由于我们尽可能地尊重SRP(单一职责原则),因此我们创建了许多组合,供我们的类用于处理常见的职责(例如验证、日志记录等)。

让我们来看一个非常简单的例子:

public class Executioner
{
    public ILogger Logger { get; set; }
    public void DoSomething()
    {
        Logger.DoLog("Starting doing something");
        Thread.Sleep(1000);
        Logger.DoLog("Something was done!");
    }
}

public interface ILogger
{
    void DoLog(string message);
}

由于我们使用了一个模拟框架,因此我们在这种情况下要进行的测试类型将类似于:

[TestClass]
public class ExecutionerTests
{
    [TestMethod]
    public void Test_DoSomething()
    {
        var objectUnderTests = new Executioner();

        #region Mock setup

        var loggerMock = new Mock<ILogger>(MockBehavior.Strict);
        loggerMock.Setup(l => l.DoLog("Starting doing something"));
        loggerMock.Setup(l => l.DoLog("Something was done!"));

        objectUnderTests.Logger = loggerMock.Object;

        #endregion

        objectUnderTests.DoSomething();

        loggerMock.VerifyAll();
    }
}

正如您所看到的,测试明显知道我们正在测试的方法实现。我必须承认,这个例子太简单了,但有时我们会有一些涵盖不为测试增加任何价值的职责的组合。

让我们给这个例子增加一些复杂性。

public interface ILogger
{
    void DoLog(LoggingMessage message);
}

public interface IMapper
{
    TTarget DoMap<TSource, TTarget>(TSource source);
}

public class LoggingMessage
{
    public string Message { get; set; }
}

public class Executioner
{
    public ILogger Logger { get; set; }
    public IMapper Mapper { get; set; }
    public void DoSomething()
    {
        DoLog("Starting doing something");

        Thread.Sleep(1000);

        DoLog("Something was done!");
    }

    private void DoLog(string message)
    {
        var startMessage = Mapper.DoMap<string, LoggingMessage>(message);
        Logger.DoLog(startMessage);
    }
}

好的,这是一个例子。我会在我的日志记录器实现中包含Mapper相关内容,并在我的接口中保留DoLog(string message)方法,但这只是为了演示我的担忧。

相应的测试结果如下:

[TestClass]
public class ExecutionerTests
{
    [TestMethod]
    public void Test_DoSomething()
    {
        var objectUnderTests = new Executioner();

        #region Mock setup

        var loggerMock = new Mock<ILogger>(MockBehavior.Strict);
        var mapperMock = new Mock<IMapper>(MockBehavior.Strict);
        var mockedMessage = new LoggingMessage();

        mapperMock.Setup(m => m.DoMap<string, LoggingMessage>("Starting doing something")).Returns(mockedMessage);
        mapperMock.Setup(m => m.DoMap<string, LoggingMessage>("Something was done!")).Returns(mockedMessage);

        loggerMock.Setup(l => l.DoLog(mockedMessage));

        objectUnderTests.Logger = loggerMock.Object;
        objectUnderTests.Mapper = mapperMock.Object;

        #endregion

        objectUnderTests.DoSomething();

        mapperMock.VerifyAll();
        loggerMock.Verify(l => l.DoLog(mockedMessage), Times.Exactly(2));
        loggerMock.VerifyAll();
    }
}

哇...想象一下,如果我们使用另一种方式来翻译我们的实体,我将不得不更改每个使用映射器服务的方法的测试。

无论如何,当我们进行重大重构时,我们确实感到一些痛苦,因为我们需要更改一堆测试。

我很想讨论这种问题。我有遗漏什么吗?我们测试的东西太多了吗?

2个回答

3

提示:

明确指定需要发生的事情,不多也不少。

在您编制的示例中,

  1. 测试 E.DoSomething 请求 Mapper 映射 string1 和 string2(忽略 Logger - 无关紧要)
  2. 测试 E.DoSomething 告诉 Logger 记录映射后的字符串(模拟/伪造 Mapper 返回 message1 和 message2)

讲述,而非询问

就像您自己暗示的那样,如果这是一个真实的示例,我期望 Logger 通过哈希表或使用 Mapper 在内部处理翻译。因此,我将为 E.DoSomething 编写一个简单的测试

  1. 测试 E.DoSomething 告诉 Logger 记录 string1 和 string2

Logger 的测试将确保 L.Log 请求 mapper 翻译 s1 并记录结果

询问方法通过耦合协作者来使测试复杂化(请 Mapper 翻译 s1 和 s2。然后将返回值 m1 和 m2 传递给 Logger)。

忽略不相关的对象

通过测试交互来实现隔离的权衡是测试意识到实现的地方。 技巧是最小化这一点(通过不随意创建接口/指定期望)。DRY 同样适用于期望值。最小化指定期望的位置...理想情况下只需一次。

最小化耦合

如果有很多协作者,则耦合度很高,这是一件坏事。因此,您可能需要重新设计您的设计,看看哪些协作者不属于同一抽象级别


非常感谢!关于耦合和内聚设计问题的解释很好。我确实明白高度耦合是不好的,但我不明白为什么有很多协作者是不好的。我们总是需要某种协调者来推动所有这些协作...感谢您的澄清! - Jose Jones
@Jose 大量的合作者通常是过程式代码的标志...其中一个中心或上帝对象/控制器正在请求其他对象的内部状态,然后做出它们的决策(在对象外部)。因此,上帝对象非常复杂...这使得测试更加困难。 - Gishu
另一个评论是基于交互的测试会推动代码中的某些特质(比如说tell不要问,分布式控制等)。如果你选择相应地编写代码,那么测试就更容易。如果不这样做,那么测试编写起来就会更困难。所以,请听从它们的声音。 - Gishu

0
你的困难源于测试行为而非状态。如果你重写测试,只需查看日志中的内容而不是验证是否调用了日志,那么当实现发生变化时,你的测试就不会失败了。

谢谢你的建议,Assaf。我很想看看你如何解决这个问题,因为日志记录器的实现不应该被方法所知道。那么在日志中该去哪里查找呢? - Jose Jones
这取决于我正在进行的测试类型。对于单元测试,我根本不会测试它。我会测试组件的功能和记录器(如果我编写了它),但使用严格是集成的。对于集成测试,我会查看日志应该在哪里。例如,如果我正在测试函数调用是否最终被记录在文件中,我将运行该函数,并查看日志文件以查看预期消息是否存在。 - Assaf Stone
是的,我们确实有所谓的集成测试,不会模拟任何实现,这样我们就可以在持久层验证日志是否存在。 - Jose Jones
很好。所以你想要做的是对SUT(测试对象系统)进行测试,并查询日志以搜索预期的消息。当然,为了使测试尽可能快地运行,我建议你使用内存数据库,例如sqlite。 - Assaf Stone

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