单元测试被高估了(1)

2022-05-05   出处: tyrrrz.me  作/译者:Oleksii Holub/lukeaxu

测试在现代软件开发中的重要性怎么强调都不为过。交付产品不是一项一次性的任务,而是一个持续的过程。每一行代码的更改,都必须保证先前的功能不被破坏,这意味着软件需要进行严格的测试。

随着软件行业的发展,测试实践也日趋成熟。逐渐走向自动化,测试方法影响了软件设计本身,催生了诸如测试驱动开发,依赖倒置等。

如今,自动化测试已深深嵌入我们对软件开发的认知,很难想象没有自动化测试的软件开发。这最终使我们能够在不牺牲质量的情况下快速开发软件,从这个角度来讲,不能说测试是一件坏事。

现在很多“最佳实践”强调推动开发人员进行单元测试。而在 Mike Cohn 测试金字塔中更高层次的测试,要么是被除开发人员以外的更宽泛的人来书写,要么就是完全被忽略。

这种方法的好处通常得到以下论点的支持:单元测试在开发过程中提供了极大的价值,因为它们能够快速捕获错误并帮助实施促进模块化的设计模式。这个观点被广泛接受,以至于“单元测试”一词现在在某种程度上与一般的自动化测试混为一谈,而失去了其本身的部分含义。

当我还是一个经验不足的开发人员时,我相信这些“最佳实践”,因为我认为这会使我的代码变得更好。由于涉及Mock和其他一些技术,实际我并不是特别喜欢编写单元测试,但毕竟这是推荐的方法,所以我只能笃信不疑。

直到后来,随着我积累了更多的经验并构建了更多的项目,我才开始意识到有更好的方法来进行测试,而专注于单元测试在大多数情况下完全是浪费时间。

积极推广“最佳实践”往往还有一种倾向,即表现出对其的崇拜,这促使开发人员使用现有的设计模式或既有的特定方法,而不是引发开发人员的积极思考。在自动化测试的背景下,对单元测试的崇拜更是如此。

在本文中,我将分享我对测试技术的观察,并讨论为什么我认为它效率低下。我还将说明我目前在开源项目和日常工作中使用哪些方法来测试我的代码。

注1:虽然本文包含用 C# 编写的代码示例,但语言本身对我所表达的观点并不重要。

注2:我意识到编程术语在传达含义方面完全没有用,因为每个人似乎对它们的理解不同。在本文中,使用了“标准”定义,其中单元测试针指对代码的最小可分离部分进行的测试,端到端测试针对软件的最外层入口,而集成测试是介于两者之间的所有内容。

注3:如果你不想阅读整篇文章,可以跳到最后总结部分。

单元测试的谬误

单元测试,顾名思义,围绕“单元”的概念展开,它表示较大系统中非常小的孤立部分。对于一个单元是什么或它应该有多小没有正式的定义,但大多数人认为它对应于模块的单个功能(或对象的方法)。

通常,如果编写代码时没有考虑到单元测试,那么可能无法完全隔离地测试某些功能,因为它们可能具有某些外部依赖关系。为了解决这个问题,我们可以应用依赖倒置原则,用抽象代替具体。然后,这些抽象的内容可以用真实或模拟的实现代替,具体取决于代码是正常执行还是测试执行。

除此之外,单元测试应该专注于被测点。例如,如果一个函数包含将数据写入文件系统的代码,则该部分需要抽象出来,否则验证这种行为的测试将被视为集成测试,因为它的覆盖范围扩展到了文件系统。

考虑到上述因素,我们可以推断单元测试仅对验证给定函数内部的纯业务逻辑有用。单元测试不应该有副作用或扩展其覆盖范围,因为那属于集成测试领域。

为了说明这些细微差别如何影响设计,让我们看一个简单示例。想象一下,我们正在开发一个计算当地日出和日落时间的应用程序,它通过以下两个类的帮助来完成:

public class LocationProvider : IDisposable
{
    private readonly HttpClient _httpClient = new HttpClient();

    // Gets location by query
    public async Task<Location> GetLocationAsync(string locationQuery) { /* ... */ }

    // Gets current location by IP
    public async Task<Location> GetLocationAsync() { /* ... */ }

    public void Dispose() => _httpClient.Dispose();
}

public class SolarCalculator : IDisposable
{
    private readonly LocationProvider _locationProvider = new LocationProvider();

    // Gets solar times for current location and specified date
    public async Task<SolarTimes> GetSolarTimesAsync(DateTimeOffset date) { /* ... */ }

    public void Dispose() => _locationProvider.Dispose();
}

尽管上面的设计在 OOP 方面是完全有效的,但这些类实际上都不是单元可测的。因为LocationProvider依赖于它自己的实例HttpClient,而SolarCalculator依赖于LocationProvider,所以不可能隔离这些类方法中可能包含的业务逻辑。

让我们修改代码:

public interface ILocationProvider
{
    Task<Location> GetLocationAsync(string locationQuery);

    Task<Location> GetLocationAsync();
}

public class LocationProvider : ILocationProvider
{
    private readonly HttpClient _httpClient;

    public LocationProvider(HttpClient httpClient) =>
        _httpClient = httpClient;

    public async Task<Location> GetLocationAsync(string locationQuery) { /* ... */ }

    public async Task<Location> GetLocationAsync() { /* ... */ }
}

public interface ISolarCalculator
{
    Task<SolarTimes> GetSolarTimesAsync(DateTimeOffset date);
}

public class SolarCalculator : ISolarCalculator
{
    private readonly ILocationProvider _locationProvider;

    public SolarCalculator(ILocationProvider locationProvider) =>
        _locationProvider = locationProvider;

    public async Task<SolarTimes> GetSolarTimesAsync(DateTimeOffset date) { /* ... */ }
}

通过添加抽象层,我们能够将LocationProviderSolarCalculator中解耦,但作为代价,代码长度几乎翻了一番。另请注意,我们将IDisposable从这两个类中删除了,因为它们不再完全拥有它们的依赖项,因此不会对其生命周期负责。

虽然这些更改对某些人来说似乎是一种改进,但需要指出的是,我们定义的接口除了使单元测试成为可能之外没有任何实际用途。在我们的设计中不需要实际的多态性,因此,就我们的代码而言,这些抽象完全是为抽象而抽象。

以上的工作总归有好处,下面为SolarCalculator.GetSolarTimesAsync编写单元测试:

public class SolarCalculatorTests
{
    [Fact]
    public async Task GetSolarTimesAsync_ForKyiv_ReturnsCorrectSolarTimes()
    {
        // Arrange
        var location = new Location(50.45, 30.52);
        var date = new DateTimeOffset(2019, 11, 04, 00, 00, 00, TimeSpan.FromHours(+2));

        var expectedSolarTimes = new SolarTimes(
            new TimeSpan(06, 55, 00),
            new TimeSpan(16, 29, 00)
        );

        var locationProvider = Mock.Of<ILocationProvider>(lp =>
            lp.GetLocationAsync() == Task.FromResult(location)
        );

        var solarCalculator = new SolarCalculator(locationProvider);

        // Act
        var solarTimes = await solarCalculator.GetSolarTimesAsync(date);

        // Assert
        solarTimes.Should().BeEquivalentTo(expectedSolarTimes);
    }
}

在这里,我们有一个基本测试来验证SolarCalculator是否适用于已知位置。由于单元测试和它们的单元是紧密耦合的,我们遵循推荐的命名约定,测试类以被测类命名,测试方法的名称遵循Method_Precondition_Result模式。

为了模拟所需的前提条件,我们必须将相应的行为注入到单元的依赖项ILocationProvider中。 在这个例子中,我们将GetLocationAsync()的返回值替换为提前已知正确太阳时的位置。

请注意,ILocationProvider暴露了两种不同的方法,我们无法知道实际调用的是哪一种。这意味着通过 Mock 这些方法中的一个特定方法,我们对被测方法的底层实现做出了假设。

总而言之,测试确实正确地验证了内部的业务逻辑否按预期工作。但是,让我们对以上的过程做进一步的观察。

1.单元测试的目的有限

任何单元测试的目的都非常简单:在隔离范围内验证业务逻辑。单元测试是否是合适的工具,取决于实际的被测范围。

例如,对使用复杂的数学算法计算太阳时的方法进行单元测试是否有意义?答案很可能是肯定的。

而对向 REST API 发送请求以获取地理坐标的方法进行单元测试是否有意义?答案很可能是否定的。

如果您将单元测试本身视为一个目标,您很快就会发现,尽管付出了很多努力,但大多数测试仍无法为您提供所需的编程信心,这是因为它们对错误的内容做了测试。在许多情况下,针对更广泛交互的集成测试比单元测试更有益。

有趣的是,一些开发人员编写了集成测试,但仍然将它们称为单元测试,主要是由于概念的混淆。尽管可以对单元大小进行考究与争辩,但这使得术语的定义更加模糊。

2.单元测试导致更复杂的设计

支持单元测试的最流行的论点之一是它促使您以高度模块化的方式设计软件。这是建立在这样一个假设之上:当代码被分成许多较小的组件而不是几个较大的组件时,更容易理解。

但是,通常适得其反,功能最终可能会变得分散。这使得评估代码变得更加困难,因为开发人员需要浏览构成单个内聚模块的多个组件。

此外,为实现组件隔离,创建了大量抽象层。尽管抽象本身是一种非常强大和有用的技术,但抽象不可避免地会增加认知复杂性,从而使理解代码变得更加困难。

通过这种间接方式,我们最终也会失去某种程度的封装,否则我们可以保持这种封装。例如,管理单个依赖项生命周期的责任从包含它们的组件转移到了其他一些不相关的服务(通常是依赖项容器)。

一些基础组件也可以委托给依赖注入框架,从而更容易配置、管理和激活依赖项。但是,这会降低可移植性,这在某些情况下可能是不可取的,例如在编写库时。

归根结底,单元测试会影响软件设计,但这是否是一件好事还存在很大争议。

3.单元测试成本高昂

从逻辑上讲,由于单测很小且孤立,单元测试应该非常容易和快速地编写。不幸的是,这只是另一个似乎相当流行的谬论。

尽管前面基于模块化的考虑让各个组件彼此分开,但单元测试实际上并没有从中受益。事实上,单元测试的复杂性仅与单元具有的外部交互的数量成正比,因为您必须为每个外部的交互做出相应的工作以实现隔离,同时保证能够执行所需的行为。

本文前面展示的示例非常简单,在实际项目中,为单个测试准备测试条件的代码可能会更长。而且在某些情况下,被模拟的行为可能非常复杂,几乎不可能弄清楚它到底应该做什么。

除此之外,单测与被测代码非常紧密地耦合,这意味着任何对代码的更改都是双倍的,因为测试也需要更新。更糟糕的是,很少有开发人员觉得它做是一项诱人的任务,通常把它交给团队中更年轻的成员。

4.单元测试依赖实现细节

基于Mock的单元测试的含义是,使用这种方法编写的任何测试本质上都是对实现敏感的。通过Mock特定的依赖项,您的测试将依赖于被测代码如何使用该依赖项,而该依赖项不受公共接口的约束。

这种额外的耦合通常会导致意想不到的问题,其中看似非破坏性的更改可能会导致测试失败,例如Mock失效。这非常令人沮丧,并最终阻止开发人员重构代码,因为永远不清楚测试中的错误是来自实际的回归还是由于依赖于某些实现细节。

测试有状态的代码可能更加棘手,因为可能无法通过公开的接口观察变化。为了解决这个问题,您通常会注入一种能够记录函数何时被调用的模拟行为,以帮助您确保被测单元正确使用其依赖项。

当然,当测试不仅依赖于调用特定函数,还依赖于它发生了多少次调用或调用传递了哪些参数时,测试与实现之间的耦合度就更高了。以这种方式编写的测试只有在内部细节不发生变化时才有用,这非常不合理。

过多依赖实现细节也会使测试本身变得非常复杂,尤其是与其他许多模块交互或存在大量依赖项时。测试变得如此复杂,那么谁来编写测试用例来测试测试用例呢?

5.单元测试不关心用户行为

无论您正在开发什么类型的软件,其目标都是为最终用户提供价值。事实上,我们编写自动化测试用例的主要原因是确保没有引入损害软件价值的缺陷。

在大多数情况下,用户通过一些顶级界面(如 UI、CLI 或 API)使用软件。虽然代码本身可能涉及许多抽象层,但对用户而言唯一重要的是他们实际看到的并与之交互的那一层。

系统的某些部分是否存在错误甚至没有关系,只要它永远不会出现在用户面前并且不会影响所提供的功能。相反,如果用户界面存在缺陷导致系统实际上不可用,那么即使我们对所有较低级别的代码进行全面覆盖,也并没有什么用处。

当然,如果你想确保某些东西正常工作,你必须检查那个确切的东西,看看它是否有效。在我们的案例中,获得软件开发信心的最好方法是模拟真实用户如何与顶级界面交互,看看它是否按照预期正常工作。

单元测试的问题在于它们正好相反:我们总是在处理用户不直接与之交互的一小段孤立的代码,从不测试实际的用户行为。

进行基于Mock的测试带来一个更值得思考的问题:由于系统中本来可以使用的部分被Mock替换,从而进一步远离实际情况,通过测试与实际体验不相似的东西,是否能够确信用户将获得流畅的体验?

{测试窝原创译文,译者lukeaxu}


声明:本文为本站编辑转载,文章版权归原作者所有。文章内容为作者个人观点,本站只提供转载参考(依行业惯例严格标明出处和作译者),目的在于传递更多专业信息,普惠测试相关从业者,开源分享,推动行业交流和进步。 如涉及作品内容、版权和其它问题,请原作者及时与本站联系(QQ:1017718740),我们将第一时间进行处理。本站拥有对此声明的最终解释权!欢迎大家通过新浪微博(@测试窝)或微信公众号(测试窝)关注我们,与我们的编辑和其他窝友交流。
161° /1618 人阅读/0 条评论 发表评论

登录 后发表评论