65.9K
CodeProject 正在变化。 阅读更多。
Home

不只在于如何注入你的服务,而在于如何测试它们

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2022年4月18日

CPOL

5分钟阅读

viewsIcon

6865

简谈脆弱的单元测试及其避免方法

引言

关于单元测试的价值已经有很多论述,但仍有许多开发者可能已经见证过代码库中的单元测试过于脆弱,或者很少能发现软件中的实际缺陷。此外,有些人还质疑了旨在使代码可测试的默认架构风格。这些原因导致许多开发者公开质疑单元测试,而另一些人则只是默默地破坏编写单元测试的过程。

在本文中,我将分享我对上述问题的看法。

你可以在这个仓库中查看提供的代码。在文章的讨论过程中,我将展示两种不同的设计,它们位于两个不同的分支中,所以请随意探索它们。

安装

让我们来看一个注入了服务的简单控制器。虽然我使用了“控制器”这个术语,但为了示例的简洁性,我没有使用任何框架。尽管如此,你可能认为我们正在谈论的是一个流行的MVC框架。

public class ItemService
{
    public string Serialize(Item input)
    {
        switch (input.Type)
        {
            case ItemType.String:
                return input.Value;
            case ItemType.Geo:
                return SeriazlieGeo(input.Value);
            case ItemType.Range:
                return SerializeRange(input.Value);
            default:
                throw new ArgumentOutOfRangeException(nameof(input.Type));
        }
    }

    private string SerializeRange(string value)
    {
        var items = value.Split(new[] { '-', ' ' }, 
                    StringSplitOptions.RemoveEmptyEntries);
        if (items.Length != 2)
        {
            throw new ArgumentException(nameof(value));
        }
        return $"gte:{items[0]},lte:{items[1]}";
    }

    private string SeriazlieGeo(string value)
    {
        var items = value.Split(new[] { ',', ' ' }, 
                    StringSplitOptions.RemoveEmptyEntries);
        if (items.Length != 2)
        {
            throw new ArgumentException(nameof(value));
        }
        return $"lat:{items[0]},lon:{items[1]}";
    }
}

public class ItemController
{
    private readonly ItemService _serializer;

    public ItemController(ItemService serializer)
    {
        _serializer = serializer;
    }

    public string Process(Item item)
    {
        return $"Output is: {_serializer.Serialize(item)}";
    }
}

现在,让我们用测试来覆盖这段代码。

public class ItemsServiceTests
{
    private ItemController CreateSut()
    {
        return new ItemController(new ItemService());
    }

    public static IEnumerable<object[]> SerializeTestData => new List<object[]>
    {
        new object [] { new Item
        {
            Type = ItemType.String,
            Value = "test value"
        }, "Output is: test value" },
        new object [] { new Item
        {
            Type = ItemType.Geo,
            Value = "45,54"
        }, "Output is: lat:45,lon:54" },
        new object [] { new Item
        {
            Type = ItemType.Range,
            Value = "45-54"
        }, "Output is: gte:45,lte:54" },
    };

    [Theory]
    [MemberData(nameof(SerializeTestData))]
    public void Serialize(Item item, string expectedResult)
    {
        //arrange
        var sut = CreateSut();

        //act
        var actualResult = sut.Process(item);

        //assert
        Assert.Equal(actualResult, expectedResult);
    }
}

那么,这个控制器是否以可测试的方式编写的呢?当然,我们已经完全用测试覆盖了它!我们能以某种方式改进它吗?我不这么认为。但让我们来审视一下我在某些代码库中遇到的一些选项。

引入接口

一些追求松耦合的开发者可能会反对将具体实现注入控制器,认为这是对SOLID原则的违反。即,依赖倒置原则。所以,让我们遵循这些原则,引入一个接口并将其注入服务。

public interface IItemService
{
    string Serialize(Item input);
}

public class ItemController
{
    private readonly IItemService _serializer;

    public ItemController(IItemService serializer)
    {
        _serializer = serializer;
    }

    public string Process(Item item)
    {
        return $"Output is: {_serializer.Serialize(item)}";
    }
}

现在,如果我们不更改测试,我们会看到它们仍然通过。这意味着我们已经执行了重构,并且我们的测试套件确保我们没有破坏任何东西。这正是我们编写单元测试的原因!

在这种特定情况下,遵循SOLID是否改善了我们的代码?我不这么认为。它已经简洁且可测试。它让代码变差了吗?我们中的一些人(包括我)认为代码是负债,而不是资产,确实如此。但这重要吗?坦率地说,即使我负责流程,但如果我的团队认为严格遵守SOLID原则有其价值,我宁愿遵循团队的意愿,而不是试图打破它。

题外话:抽象掉易变依赖

挑剔的读者可能会说我在这里是在打稻草人,他们可能是对的。虽然在我的示例中通过接口来抽象依赖关系并没有太大的意义,但这是一个非常有用的技术,可测试性的真正好处来自于抽象掉易变依赖。我所说的“易变依赖”,是指那些会产生可观察到的副作用(例如数据库、电子邮件提供商等)的依赖。在测试套件中直接使用这些依赖可能会导致测试不稳定,因为它们依赖于外部资源。因此,用继承了注入接口的测试替身来替换它们是完全有意义的。

然而,还有其他技术是可行的。其中一种是将这些有副作用的交互提取到单独的模块中,同时对纯逻辑进行单元测试。我不会深入探讨这方面的内容,因为已经有关于这项技术的精彩解释

值得注意的是,一些框架提供了内置选项来抽象掉非基于接口的易变依赖。例如EFCore Inmemory Provider

重构(?)单元测试

既然这两种设计都相当不错,那么重点是什么呢?正如你们中的一些人可能已经从文章标题中猜到的那样,我们将重点放在单元测试套件上。顾名思义,单元测试旨在测试独立的单元,而集成测试则测试多个单元的集成。

我在许多代码库中发现的思维是,代码的自然单元是类,所以示例中的测试重构如下。

public class ItemControllerTests
{
    private ItemController CreateSut()
    {
        var itemServiceMock = new Mock<IItemService>(MockBehavior.Strict);
        itemServiceMock.Setup(e => e.Serialize(It.IsAny<Item>())).Returns("serialized");
        return new ItemController(itemServiceMock.Object);
    }

    [Fact]
    public void ProcessWrapsSerializedOutput()
    {
        //arrange
        var sut = CreateSut();

        //act
        var res = sut.Process(new Item { });

        //assert
        Assert.Equal("Output is: serialized", res);
    }
}

public class ItemsServiceTests
{
    private ItemService CreateSut()
    {
        return new ItemService();
    }

    public static IEnumerable<object[]> SerializeTestData => new List<object[]>
    {
        new object [] { new Item
        {
            Type = ItemType.String,
            Value = "test value"
        }, "test value" },
        new object [] { new Item
        {
            Type = ItemType.Geo,
            Value = "45,54"
        }, "lat:45,lon:54" },
        new object [] { new Item
        {
            Type = ItemType.Range,
            Value = "45-54"
        }, "gte:45,lte:54" },
    };

    [Theory]
    [MemberData(nameof(SerializeTestData))]
    public void Serialize(Item item, string expectedResult)
    {
        //arrange
        var sut = CreateSut();

        //act
        var actualResult = sut.Serialize(item);

        //assert
        Assert.Equal(actualResult, expectedResult);
    }
}

现在我们既测试了ItemService,也检查了ItemController内部是否调用了ItemService的正确方法。这种设计真的更好吗?让我们来看看。

设计的演变

你可能已经注意到,我们的一些业务逻辑(如果你能将这个术语应用于如此过于简化的示例)——即用附加文本包装序列化项——resides inside ItemController。假设我们想遵循瘦控制器原则,并将这段代码提取到服务内部。

public string Process(Item item)
{
    var serializedOutput = _serializer.Serialize(item);
    return _serializer.Wrap(serializedOutput);
}

既然我们已经重构了代码,让我们运行测试套件来检查我们是否没有破坏任何东西。

发生了什么?

Moq.MockException : IItemService.Wrap("serialized") 
                    invocation failed with mock behavior Strict.
All invocations on the mock must have a corresponding setup.

相反,如果我们坚持我们原来的测试策略,即一次测试多个类,我们的测试套件将是绿色的。

事实证明,“重构单元测试”部分中的测试风格是过度指定的软件的一个例子。我们没有专注于验证行为,而是验证了易变的实现细节。这类测试不会为我们的代码提供任何额外的信心,但却很脆弱,导致人们对单元测试普遍感到不满。从这个角度来看:你的利益相关者会在乎你调用ItemService的方法两次还是恰好一次吗?

这使我们得出结论:当我们谈论测试一个单元时,我们应该考虑的是行为的单元,而不是代码的单元!这类测试使我们能够专注于被测系统的关键方面,从而增加我们测试套件的价值。

题外话:尊重SRP

挑剔的读者可能会注意到,在我们向ItemService添加了另一个方法后,它开始违反单一职责原则。在这种情况下,这样做只是为了说明脆弱测试的案例,但总的来说,当你处理名称以ServiceManager结尾的类时,你应该总是小心,因为这是类职责定义不清晰的第一个标志。

结论

本文的主要目标是表明,作为工程师,我们应该理解我们所应用的原则的优点和缺点,而不是盲目地遵循它们。出于这个原因,我们对服务组合和利用测试套件进行了考察。正如我们所见,在不理解其含义的情况下遵循这些原则会导致脆弱的设计。尽管我们的第一反应可能是质疑原则本身,但我们真正需要问自己的主要问题是我们是否正确地应用了它们。

历史

  • 2022年4月18日 - 初始版本
© . All rights reserved.