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





5.00/5 (5投票s)
简谈脆弱的单元测试及其避免方法
引言
关于单元测试的价值已经有很多论述,但仍有许多开发者可能已经见证过代码库中的单元测试过于脆弱,或者很少能发现软件中的实际缺陷。此外,有些人还质疑了旨在使代码可测试的默认架构风格。这些原因导致许多开发者公开质疑单元测试,而另一些人则只是默默地破坏编写单元测试的过程。
在本文中,我将分享我对上述问题的看法。
你可以在这个仓库中查看提供的代码。在文章的讨论过程中,我将展示两种不同的设计,它们位于两个不同的分支中,所以请随意探索它们。
安装
让我们来看一个注入了服务的简单控制器。虽然我使用了“控制器”这个术语,但为了示例的简洁性,我没有使用任何框架。尽管如此,你可能认为我们正在谈论的是一个流行的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
添加了另一个方法后,它开始违反单一职责原则。在这种情况下,这样做只是为了说明脆弱测试的案例,但总的来说,当你处理名称以Service
或Manager
结尾的类时,你应该总是小心,因为这是类职责定义不清晰的第一个标志。
结论
本文的主要目标是表明,作为工程师,我们应该理解我们所应用的原则的优点和缺点,而不是盲目地遵循它们。出于这个原因,我们对服务组合和利用测试套件进行了考察。正如我们所见,在不理解其含义的情况下遵循这些原则会导致脆弱的设计。尽管我们的第一反应可能是质疑原则本身,但我们真正需要问自己的主要问题是我们是否正确地应用了它们。
历史
- 2022年4月18日 - 初始版本