单元测试被高估了

2025-06-10

单元测试被高估了

测试在现代软件开发中的重要性无论怎样强调都不为过。交付一款成功的产品并非一次性完成,而是一个持续不断的循环过程。每一行代码的修改都必须确保软件保持正常运行,这意味着需要进行严格的测试。

随着软件行业的发展,测试实践也日趋成熟。测试方法逐渐走向自动化,也影响了软件设计本身,催生了诸如测试驱动开发之类的理念,强调依赖倒置等模式,并推广了围绕依赖倒置构建的高级架构。

如今,自动化测试已深深植根于我们对软件开发的认知之中,很难想象两者缺一不可。而且,由于自动化测试最终使我们能够在不牺牲质量的情况下快速开发软件,因此很难说它不是一件好事。

然而,尽管存在许多不同的方法,现代的“最佳实践”主要推动开发人员进行单元测试。在Mike Cohn 的金字塔中,单元测试的范围位于更高的位置,它们要么作为更广泛的测试套件的一部分编写(通常由完全不同的人编写),要么甚至被完全忽略。

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

当我还是一名经验尚浅的开发人员时,我坚信要严格遵循这些“最佳实践”,因为我认为这能让我的代码变得更好。我并不特别喜欢编写单元测试,因为它涉及抽象和模拟等繁琐的程序,但毕竟这是推荐的方法,所以我也没资格知道得更清楚。

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

大力推广的“最佳实践”往往会引发人们对其的狂热崇拜,诱使开发人员不假思索地套用设计模式或使用特定方法。在自动化测试领域,我发现这种情况在我们行业对单元测试的不健康痴迷中尤为普遍。

在本文中,我将分享我对这种测试技术的观察,并解释为什么我认为它效率低下。我还会解释我目前在开源项目和日常工作中所使用的代码测试方法。

注意:本文包含用 C# 编写的代码示例,但语言本身对于我提出的观点来说并不(太)重要。

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

单元测试的谬误

顾名思义,单元测试围绕“单元”的概念展开。“单元”指的是大型系统中非常小的独立部分。对于单元是什么以及它应该有多小,并没有正式的定义,但普遍认为它对应于模块中的一个独立函数(或一个对象的方法)。

通常,如果代码编写时没有考虑单元测试,那么某些函数可能无法完全独立地进行测试,因为它们可能具有外部依赖关系。为了解决这个问题,我们可以应用依赖倒置原则,用抽象替代具体的依赖关系。然后,可以根据代码是正常执行还是作为测试的一部分执行,用真实或虚假的实现来替换这些抽象。

除此之外,单元测试也应是纯粹的。例如,如果某个函数包含将数据写入文件系统的代码,则该部分也需要抽象出来,否则,验证此类行为的测试将被视为集成测试,因为它的覆盖范围延伸到了该单元与文件系统的集成。

考虑到上述因素,我们可以推断,单元测试仅适用于验证给定函数内部的纯业务逻辑。其范围不扩展到测试副作用或其他集成,因为这些属于集成测试的范畴。

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

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 : IDiposable
{
    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();
}
Enter fullscreen mode Exit fullscreen mode

虽然上述设计从面向对象编程 (OOP) 的角度来看完全有效,但这两个类实际上都无法进行单元测试。由于LocationProvider依赖于 自身的 实例,HttpClientSolarCalculator又依赖于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) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

通过这样做,我们得以LocationProvider与解耦SolarCalculator,但代价是代码大小几乎翻倍。另请注意,我们必须删除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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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

为了在编排阶段模拟所需的前提条件,我们必须将相应的行为注入到单元的依赖项中,即ILocationProvider。在这种情况下,我们通过将 的返回值替换GetLocationAsync()为一个已经提前知道正确太阳时的位置来实现。

请注意,尽管ILocationProvider公开了两种不同的方法,但从契约的角度来看,我们无法知道实际调用的是哪一种方法。这意味着,通过选择模拟其中一种特定方法,我们对正在测试的方法的底层实现做出了假设(这在前面的代码片段中被故意隐藏了)。

总而言之,测试确实正确地验证了内部的业务逻辑GetSolarTimesAsync是否按预期工作。不过,让我们进一步阐述一下我们在此过程中的一些观察。


  1. 单元测试的用途有限

重要的是要理解,任何单元测试的目的都非常简单:在隔离的范围内验证业务逻辑。根据你打算测试的交互,单元测试可能适合也可能不适合这项工作。

例如,对一个使用冗长而复杂的数学算法来计算太阳时的方法进行单元测试是否有意义?很有可能

对一个向 REST API 发送请求以获取地理坐标的方法进行单元测试有意义吗?很可能没有

如果你把单元测试本身作为目标,你很快就会发现,尽管付出了很多努力,但大多数测试都无法给你所需的信心,仅仅是因为它们测试错了地方。很多情况下,用集成测试来测试更广泛的交互比专注于单元测试更有益。

有趣的是,有些开发人员最终确实会在这种情况下编写集成测试,但仍然将其称为单元测试,这主要是因为围绕这个概念的混淆。虽然有人认为单元大小可以任意选择,并且可以跨越多个组件,但这却使得定义非常模糊,最终导致该术语的整体使用完全无用。

  1. 单元测试导致设计更加复杂

支持单元测试的最流行论点之一是,它强制你以高度模块化的方式设计软件。这建立在一个假设之上:将代码拆分成许多较小的组件比拆分成几个较大的组件更容易推理。

然而,这往往会导致相反的问题,即功能最终变得不必要地碎片化。这使得评估代码变得更加困难,因为开发人员需要浏览构成一个单一内聚元素的多个组件。

此外,为了实现组件隔离而大量使用抽象,也会产生大量不必要的间接调用。虽然抽象本身就是一种非常强大且实用的技术,但它不可避免地会增加认知复杂性,使得代码推理更加困难。

通过这种间接访问,我们最终也失去了一些原本可以维护的封装性。例如,管理各个依赖项生命周期的责任从包含它们的组件转移到了其他不相关的服务(通常是依赖项容器)。

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

归根结底,虽然单元测试确实会影响软件设计,但这是否真的是一件好事仍存在很大争议。

  1. 单元测试成本高昂

从逻辑上讲,单元测试既然小而独立,编写起来应该很容易、很快,这种想法很有道理。不幸的是,这只是又一个似乎相当流行的谬论,尤其是在管理者群体中。

尽管前面提到的模块化架构让我们误以为各个组件可以彼此独立地考虑,但单元测试实际上并没有从中受益。事实上,单元测试的复杂性只会与单元的外部交互数量成正比增长,这是因为在执行所需行为的同时,你必须完成所有工作才能实现隔离。

本文前面的示例非常简单,但在实际项目中,安排阶段跨越多行代码的情况并不罕见,仅仅是为了设置单个测试的先决条件。在某些情况下,模拟的行为可能非常复杂,几乎不可能将其解开来弄清楚它应该做什么。

除此之外,单元测试在设计上与被测代码紧密耦合,这意味着任何修改代码的工作量都会翻倍,因为测试套件也需要更新。更糟糕的是,很少有开发人员觉得这项任务很有吸引力,他们通常会把这项工作推卸给团队中资历较浅的成员。

  1. 单元测试依赖于实现细节

基于 Mock 的单元测试有一个不好的隐含含义:任何用这种方法编写的测试本质上都具有实现感知能力。通过 Mock 特定的依赖项,你的测试将依赖于被测代码如何使用该依赖项,而这不受公共接口的约束。

这种额外的耦合常常会导致意想不到的问题,看似不具破坏性的变更,却可能随着模拟代码的过时而导致测试失败。这会非常令人沮丧,最终导致开发人员不愿重构代码,因为他们永远无法确定测试中的错误是源于实际的回归,还是由于对某些实现细节的依赖。

对有状态代码进行单元测试可能更加棘手,因为可能无法通过公开的接口观察到代码的变更。为了解决这个问题,你通常会注入间谍(spy),这是一种模拟行为,它会记录函数的调用时间,帮助你确保单元正确使用其依赖项。

当然,当你不仅依赖于某个被调用的特定函数,还依赖于它被调用的次数或传递了哪些参数时,测试就会与实现更加耦合。以这种方式编写的测试只有在内部细节预计不会改变的情况下才有用,而这种预期是非常不合理的。

过度依赖实现细节也会使测试本身变得非常复杂,考虑到为了模拟特定行为需要进行大量的设置,尤其是在交互不那么简单或存在大量依赖关系的情况下。当测试变得非常复杂,以至于其自身行为难以推断时,谁来编写测试来测试这些测试呢?

  1. 单元测试不测试用户行为

无论你开发的是哪种类型的软件,其目标都是为最终用户提供价值。事实上,我们编写自动化测试的首要原因就是确保不会出现任何意外的缺陷,从而降低最终的价值。

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

即使系统某个部分在几层深处存在错误,只要它不会暴露给用户,也不会影响所提供的功能,也没关系。相反,如果用户界面存在缺陷,导致系统实际上无法使用,那么即使我们全面覆盖了所有底层组件,也没有任何区别。

当然,如果你想确保某个功能正常工作,你必须亲自检查它是否正常工作。在我们的案例中,获得系统信心的最佳方法是模拟真实用户如何与顶层界面交互,并查看它是否按照预期正常工作。

单元测试的问题在于,它恰恰相反。由于我们总是处理用户不直接交互的、孤立的小代码片段,所以我们从不测试实际的用户行为。

进行基于模拟的测试,使其价值面临更大的质疑,因为系统中原本会用到的部分被模拟环境所取代,进一步拉大了模拟环境与现实的距离。通过测试与实际体验不符的内容,我们不可能确保用户获得流畅的体验。

“单元测试是确保模拟工作的好方法”(@rkoutnik 的推文)

金字塔驱动测试

那么,考虑到单元测试存在的种种缺陷,我们作为一个行业,为什么会决定将单元测试作为软件测试的主要方法呢?很大程度上是因为更高层次的测试一直被认为太难、太慢、太不可靠。

如果你参考传统的测试金字塔,你会发现它建议测试最重要的部分应该在单元级别执行。其理念是,由于粗粒度测试被认为速度更慢、更复杂,因此你需要将精力集中在集成频谱的底层,以最终获得一个高效且可维护的测试套件:

测试金字塔。底部是单元测试,顶部是集成测试,顶部是端到端测试。

金字塔提供的隐喻模型旨在传达一个好的测试方法应该涵盖多个不同的层次,因为过于关注极端情况可能会导致测试要么过于缓慢和笨重,要么根本无法提供任何信心。话虽如此,我们仍然强调较低的层次,因为人们认为这些层次的开发测试投资回报率最高。

顶级测试虽然能提供最大的信心,但最终往往速度缓慢、难以维护,或者内容过于宽泛,无法融入通常快节奏的开发流程。因此,在大多数情况下,这类测试通常由专门的 QA 专家单独维护,因为通常认为编写这些测试并非开发人员的职责。

集成测试是介于单元测试和完整的端到端测试之间的一个抽象部分,它常常被完全忽视。由于不清楚集成测试的具体级别、如何构建和组织此类测试,或者担心它们可能失控,许多开发人员倾向于避免集成测试,转而选择更明确的极端方法——单元测试。

出于这些原因,开发过程中进行的所有测试通常都位于金字塔的最底层。事实上,随着时间的推移,这种情况已经变得如此普遍,以至于开发测试和单元测试几乎成了同义词,这导致了混淆,而会议演讲、博客文章、书籍,甚至一些 IDE(就 JetBrains Rider 而言,所有测试都是单元测试)只会进一步加剧这种混淆。

在大多数开发人员眼中,测试金字塔看起来有点像这样:

开发人员视角的测试金字塔。底部显示单元测试,其余部分标记为其他人的问题。

虽然金字塔是将软件测试转化为可解决问题的高尚尝试,但该模型显然存在诸多问题。特别是,它所依赖的假设可能并非在所有情况下都成立,尤其是在高度集成的测试套件运行缓慢或困难的情况下。

作为人类,我们天生倾向于依赖那些经验更丰富的人传授的信息,这样我们就能从前几代人的知识中受益,并将我们的第二思维系统应用于更有用的事情上。这是一种重要的进化特征,使我们物种极其适应生存。

然而,每当我们将经验推演成指导方针时,我们往往会认为它们本身就很好,而忘记了与其相关性息息相关的具体情况。现实情况是,情况瞬息万变,曾经完全合理的结论(或最佳实践)可能不再适用。

回顾过去,2000 年的高水平测试显然非常艰难,2009 年可能依然如此,但现在已经是 2020 年了,我们实际上生活在未来。技术和软件设计的进步已经使这个问题不再像以前那么重要。

如今,大多数现代应用程序框架都提供了某种用于测试的独立 API 层,您可以在模拟的内存环境中运行应用程序,该环境与真实环境非常接近。像 Docker 这样的虚拟化工具还可以执行依赖于实际基础设施依赖关系的测试,同时仍然保持确定性和快速性。

我们拥有MountebankWireMockGreenMailAppiumSeleniumCypress等众多解决方案,它们简化了高级测试中曾经被认为难以实现的各个方面。除非您正在开发 Windows 桌面应用程序并且受困于UIAutomation 框架,否则您可能会有很多选择。

在我之前的一个项目中,我们有一个 Web 服务,它在系统边界上进行了测试,使用了近百个行为测试,并行运行时间不到 10 秒。当然,单元测试可以获得比这更快的执行时间,但考虑到单元测试提供的可靠性,这完全是明智之举。

然而,“慢测试谬误”并非金字塔所基于的唯一错误假设。将大部分测试集中在单元级别的想法,只有当这些测试真正发挥作用时才有效,而这当然取决于被测代码中包含了多少业务逻辑。

有些应用程序可能包含大量业务逻辑(例如工资单系统),有些则几乎没有(例如 CRUD 应用程序),大多数则介于两者之间。我个人参与过的大多数项目都没有足够的业务逻辑来保证单元测试的全面覆盖,但另一方面,它们又具有大量的基础设施复杂性,而集成测试则能为这些项目带来益处。

当然,理想情况下,开发人员会评估项目的具体情况,并找到最适合当前问题的测试方法。然而,现实情况是,大多数开发人员根本没有考虑过这个问题,只是盲目地按照最佳实践的建议,堆积成山的单元测试。

最后,我认为公平地说,测试金字塔提供的模型总体上过于简单。纵轴以线性比例表示测试范围,其中任何随着测试增加而获得的信心,显然都会被可维护性和速度的等量损失所抵消。如果比较极端情况,这可能是正确的,但对于介于两者之间的其他点,情况并非如此。

它也没有考虑到隔离本身是有成本的,而且隔离并非仅仅通过“避免”外部交互就能免费获得。考虑到编写和维护模拟所需的大量精力,隔离程度较低的测试完全有可能成本更低,最终提供更高的可信度,尽管运行速度会略慢一些。

如果你考虑这些方面,那么这个规模似乎根本不是线性的,而且投资回报率最高的点位于中间位置,而不是单位级别:

该图表显示成本和速度的规模可能与集成度不成线性关系。

总而言之,当你尝试为项目建立一套高效的测试套件时,测试金字塔并非最佳的指导原则。与其依赖所谓的“最佳实践”,不如专注于与你的具体情况相关的内容。

现实驱动测试

从最基本的层面来说,如果测试能够确保软件正常运行,那么它就具有价值。我们越有信心,在引入代码变更时,就越不需要依靠自己去发现潜在的错误和回归问题,因为我们相信测试能够帮我们做到这一点。

这种信任反过来又取决于测试对实际用户行为的准确程度。在系统边界运行且不了解任何内部细节的测试场景,必然会比在较低层级进行的测试更能给我们带来更大的信心(从而带来更大的价值)。

本质上,我们从测试中获得的置信度是衡量其价值的主要指标。尽可能提高置信度也是我们的首要目标。

当然,正如我们所知,还有其他因素在起作用,例如成本、速度、并行能力等等,这些都很重要。测试金字塔对这些因素如何相互关联做出了强有力的假设,但这些假设并非普遍适用。

此外,这些因素对于获得信心这一首要目标来说也是次要的。一项昂贵、耗时很长但能提供很大信心的测试,仍然比一项极其快速、简单却毫无效果的测试有用得多。

因此,我发现最好编写尽可能高度集成的测试,同时保持其速度和复杂性合理

这是否意味着我们编写的每个测试都应该是端到端测试?不,但我们应该尽可能地朝着这个方向努力,同时将缺点保持在可接受的水平。

可接受与否是主观的,取决于具体情况。归根结底,重要的是这些测试是由开发人员编写并在开发过程中使用的,这意味着它们不应该成为维护的负担,并且应该能够在本地构建和持续集成 (CI) 中运行它们。

这样做也意味着你最终可能会得到分散在集成规模不同层次的测试,看起来缺乏清晰的结构感。单元测试中不会出现这个问题,因为每个测试都与特定的方法或函数耦合,因此结构通常与代码本身的结构一致。

幸运的是,这并不重要,因为按单个类或模块组织测试本身并不重要,而是单元测试的副作用。相反,测试应该根据它们要验证的实际面向用户的功能进行划分。

这类测试通常被称为功能测试,因为它们基于软件的功能需求,描述了软件的功能及其工作原理。功能测试并非金字塔上的又一层,而是一个完全正交的概念。

与普遍的看法相反,编写功能测试并不需要使用Gherkin或 BDD 框架,只需使用通常用于单元测试的工具即可。例如,考虑如何重写文章开头的示例,使测试围绕支持的用户行为而不是代码单元进行构建:

public class SolarTimesSpecs
{
    [Fact]
    public async Task User_can_get_solar_times_automatically_for_their_location() { /* ... */ }

    [Fact]
    public async Task User_can_get_solar_times_during_periods_of_midnight_sun() { /* ... */ }

    [Fact]
    public async Task User_can_get_solar_times_if_their_location_cannot_be_determined() { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

请注意,测试的实际实现是隐藏的,因为这与测试是否能正常工作无关。重要的是,测试及其结构是由软件需求驱动的,而其范围理论上可以从端到端到单元级别。

根据规范而不是类来命名测试还有一个额外的好处,那就是可以消除不必要的耦合。现在,如果我们决定将SolarCalculator其重命名或移动到其他目录,则无需更新测试名称来反映这一点。

通过遵循这种结构,我们的测试套件将有效地形成一份动态文档。例如,CliWrap中的测试套件组织方式如下( xUnit将下划线替换为空格):

用于 CliWrap 的功能测试

只要某个软件能完成哪怕一点点有用的功能,它就总会有功能需求。这些需求可以是正式的(规范文档、用户故事等),也可以是非正式的(口头约定、假设、JIRA 工单、写在厕纸上等)。

将非正式规范转化为功能测试通常很困难,因为这需要我们抛开代码,挑战自己从用户的角度思考。对我的开源项目有帮助的是,我首先创建一个 readme 文件,列出一系列相关的使用示例,然后将它们编码成测试。

总而言之,我们可以得出结论,最好根据行为线程而不是代码的内部结构来划分测试

如果我们将所有这些想法结合起来,我们就能得到一个思维框架,它能为编写测试提供清晰的目标和良好的组织感,同时又不依赖任何假设。我们可以用它为我们的项目建立一个以价值为重点的测试套件,然后根据与当前情况相关的优先级和限制对其进行扩展。

Web 服务的功能测试(通过 ASP.NET Core)

您可能仍然对功能测试的构成或具体应该如何进行感到困惑,尤其是在您从未进行过功能测试的情况下。因此,展示一个简单但完整的示例很有意义。为此,我们将把之前的太阳能计算器转换为 Web 服务,并根据本文上一部分概述的规则对其进行测试。此应用将基于 ASP.NET Core,这是我最熟悉的 Web 框架,但同样的思路也适用于任何其他平台。

我们的 Web 服务将公开端点,根据用户的 IP 或提供的位置计算日出和日落时间。为了更有趣,我们还将添加一个 Redis 缓存层来存储之前的计算结果,从而提高响应速度。

测试将通过在模拟环境中启动应用来进行,该环境可以接收 HTTP 请求、处理路由、执行验证,并表现出与生产环境中运行的应用几乎相同的行为。同时,我们还将使用 Docker 来确保测试依赖与真实应用相同的基础架构依赖关系。

我们先来回顾一下这个 Web 应用的实现,了解一下我们正在处理的内容。注意,为了简洁起见,以下代码片段中省略了部分内容,但您也可以在GitHub上查看完整的项目

首先,我们需要一种通过 IP 获取用户位置的方法,这可以通过LocationProvider前面示例中看到的类来实现。它只需包装一个名为IP-API的外部 GeoIP 查找服务即可:

public class LocationProvider
{
    private readonly HttpClient _httpClient;

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

    public async Task<Location> GetLocationAsync(IPAddress ip)
    {
        // If IP is local, just don't pass anything (useful when running on localhost)
        var ipFormatted = !ip.IsLocal() ? ip.MapToIPv4().ToString() : "";

        var json = await _httpClient.GetJsonAsync($"http://ip-api.com/json/{ipFormatted}");

        var latitude = json.GetProperty("lat").GetDouble();
        var longitude = json.GetProperty("lon").GetDouble();

        return new Location
        {
            Latitude = latitude,
            Longitude = longitude
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

为了将位置转换为太阳时,我们将依赖美国海军天文台发布的日出/日落算法。该算法本身太长,无法在此处列出,但其余实现SolarCalculator如下:

public class SolarCalculator
{
    private readonly LocationProvider _locationProvider;

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

    private static TimeSpan CalculateSolarTimeOffset(Location location, DateTimeOffset instant,
        double zenith, bool isSunrise)
    {
        /* ... */

        // Algorithm omitted for brevity

        /* ... */
    }

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

    public async Task<SolarTimes> GetSolarTimesAsync(IPAddress ip, DateTimeOffset date)
    {
        var location = await _locationProvider.GetLocationAsync(ip);

        var sunriseOffset = CalculateSolarTimeOffset(location, date, 90.83, true);
        var sunsetOffset = CalculateSolarTimeOffset(location, date, 90.83, false);

        var sunrise = date.ResetTimeOfDay().Add(sunriseOffset);
        var sunset = date.ResetTimeOfDay().Add(sunsetOffset);

        return new SolarTimes
        {
            Sunrise = sunrise,
            Sunset = sunset
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

由于它是一个 MVC Web 应用程序,我们还将有一个控制器,它提供端点来公开应用程序的功能:

[ApiController]
[Route("solartimes")]
public class SolarTimeController : ControllerBase
{
    private readonly SolarCalculator _solarCalculator;
    private readonly CachingLayer _cachingLayer;

    public SolarTimeController(SolarCalculator solarCalculator, CachingLayer cachingLayer)
    {
        _solarCalculator = solarCalculator;
        _cachingLayer = cachingLayer;
    }

    [HttpGet("by_ip")]
    public async Task<IActionResult> GetByIp(DateTimeOffset? date)
    {
        var ip = HttpContext.Connection.RemoteIpAddress;
        var cacheKey = $"{ip},{date}";

        var cachedSolarTimes = await _cachingLayer.TryGetAsync<SolarTimes>(cacheKey);
        if (cachedSolarTimes != null)
            return Ok(cachedSolarTimes);

        var solarTimes = await _solarCalculator.GetSolarTimesAsync(ip, date ?? DateTimeOffset.Now);
        await _cachingLayer.SetAsync(cacheKey, solarTimes);

        return Ok(solarTimes);
    }

    [HttpGet("by_location")]
    public async Task<IActionResult> GetByLocation(double lat, double lon, DateTimeOffset? date)
    {
        /* ... */
    }
}
Enter fullscreen mode Exit fullscreen mode

如上所示,该/solartimes/by_ip端点主要只是将执行委托给SolarCalculator,但也具有非常简单的缓存逻辑,以避免向第三方服务发出冗余请求。缓存由CachingLayer封装用于存储和检索 JSON 内容的 Redis 客户端的类完成:

public class CachingLayer
{
    private readonly IConnectionMultiplexer _redis;

    public CachingLayer(IConnectionMultiplexer connectionMultiplexer) =>
        _redis = connectionMultiplexer;

    public async Task<T> TryGetAsync<T>(string key) where T : class
    {
        var result = await _redis.GetDatabase().StringGetAsync(key);

        if (result.HasValue)
            return JsonSerializer.Deserialize<T>(result.ToString());

        return null;
    }

    public async Task SetAsync<T>(string key, T obj) where T : class =>
        await _redis.GetDatabase().StringSetAsync(key, JsonSerializer.Serialize(obj));
}
Enter fullscreen mode Exit fullscreen mode

Startup最后,通过配置请求管道和注册所需的服务,将上述所有部分在类中连接在一起:

public class Startup
{
    private readonly IConfiguration _configuration;

    public Startup(IConfiguration configuration) =>
        _configuration = configuration;

    private string GetRedisConnectionString() =>
        _configuration.GetConnectionString("Redis");

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc(o => o.EnableEndpointRouting = false);

        services.AddSingleton<IConnectionMultiplexer>(
            ConnectionMultiplexer.Connect(GetRedisConnectionString()));

        services.AddSingleton<CachingLayer>();

        services.AddHttpClient<LocationProvider>();
        services.AddTransient<SolarCalculator>();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
            app.UseDeveloperExceptionPage();

        app.UseMvcWithDefaultRoute();
    }
}
Enter fullscreen mode Exit fullscreen mode

请注意,我们没有让类实现任何自成目的的接口,因为我们不打算使用模拟。我们可能需要在测试中替换某个服务,但现在还不清楚,所以我们在确定需要替换之前,尽量避免不必要的工作(以及设计上的损害)。

虽然这是一个相当简单的项目,但该应用已经通过依赖第三方 Web 服务(GeoIP 提供商)以及持久层(Redis)而引入了相当多的基础设施复杂性。这是一种相当常见的设置,很多实际项目都涉及此类设置。

采用传统的单元测试方法,我们会发现自己需要针对应用的服务层,甚至可能是控​​制器层,编写独立的测试来确保每个代码分支都能正确执行。这样做在一定程度上是有用的,但却无法确保实际的端点(包括所有中间件和外围组件)能够按预期工作。

相反,我们将编写直接针对端点的测试。为此,我们需要创建一个单独的测试项目,并添加一些支持测试的基础组件。其中一个组件FakeApp将用于封装我们应用程序的虚拟实例:

public class FakeApp : IDisposable
{
    private readonly WebApplicationFactory<Startup> _appFactory;

    public HttpClient Client { get; }

    public FakeApp()
    {
        _appFactory = new WebApplicationFactory<Startup>();
        Client = _appFactory.CreateClient();
    }

    public void Dispose()
    {
        Client.Dispose();
        _appFactory.Dispose();
    }
}
Enter fullscreen mode Exit fullscreen mode

这里的大部分工作已经由 完成WebApplicationFactory,它是框架提供的一个实用程序,允许我们在内存中引导应用程序以进行测试。它还提供了 API,以便在需要时覆盖配置、服务注册和请求管道。

我们可以在测试中使用此对象的实例来运行应用程序,使用提供的 发送请求HttpClient,然后检查响应是否符合我们的预期。此实例可以在多个测试之间共享,也可以为每个测试单独创建。

由于我们也依赖 Redis,我们希望能够启动一个新的服务器供我们的应用使用。有很多方法可以做到这一点,但为了举一个简单的例子,我决定使用 xUnit 的 Fixture API 来实现:

public class RedisFixture : IAsyncLifetime
{
    private string _containerId;

    public async Task InitializeAsync()
    {
        // Simplified, but ideally should bind to a random port
        var result = await Cli.Wrap("docker")
            .WithArguments("run -d -p 6379:6379 redis")
            .ExecuteBufferedAsync();

        _containerId = result.StandardOutput.Trim();
    }

    public async Task ResetAsync() =>
        await Cli.Wrap("docker")
            .WithArguments($"exec {_containerId} redis-cli FLUSHALL")
            .ExecuteAsync();

    public async Task DisposeAsync() =>
        await Cli.Wrap("docker")
            .WithArguments($"container kill {_containerId}")
            .ExecuteAsync();
}
Enter fullscreen mode Exit fullscreen mode

上述代码通过实现IAsyncLifetime接口来实现,该接口允许我们定义在测试运行之前和之后执行的方法。我们使用这些方法在 Docker 中启动一个 Redis 容器,并在测试完成后将其终止。

除此之外,RedisFixture该类还公开了ResetAsync一个方法,可用于执行FLUSHALL从数据库中删除所有键的命令。我们将在每次测试之前调用此方法将 Redis 重置为干净状态。或者,我们也可以重新启动容器,虽然这会花费一些时间,但可能更可靠。

现在基础设施已经建立,我们可以继续编写第一个测试:

public class SolarTimeSpecs : IClassFixture<RedisFixture>, IAsyncLifetime
{
    private readonly RedisFixture _redisFixture;

    public SolarTimeSpecs(RedisFixture redisFixture)
    {
        _redisFixture = redisFixture;
    }

    // Reset Redis before each test
    public async Task InitializeAsync() => await _redisFixture.ResetAsync();

    [Fact]
    public async Task User_can_get_solar_times_for_their_location_by_ip()
    {
        // Arrange
        using var app = new FakeApp();

        // Act
        var response = await app.Client.GetStringAsync("/solartimes/by_ip");
        var solarTimes = JsonSerializer.Deserialize<SolarTimes>(response);

        // Assert
        solarTimes.Sunset.Should().BeWithin(TimeSpan.FromDays(1)).After(solarTimes.Sunrise);
        solarTimes.Sunrise.Should().BeCloseTo(DateTimeOffset.Now, TimeSpan.FromDays(1));
        solarTimes.Sunset.Should().BeCloseTo(DateTimeOffset.Now, TimeSpan.FromDays(1));
    }
}
Enter fullscreen mode Exit fullscreen mode

如你所见,设置非常简单。我们需要做的就是创建一个实例,FakeApp并使用提供的方法HttpClient向其中一个端点发送请求,就像在真实的 Web 应用中一样。

此特定测试通过查询/solartimes/by_ip路由来实现,路由会根据用户的 IP 地址确定当前日期的日出和日落时间。由于我们依赖于实际的 GeoIP 提供商,并且不知道最终结果如何,因此我们执行基于属性的断言来确保太阳时间的有效性。

虽然这些断言能够捕获大量潜在的错误,但并不能让我们完全确信结果完全正确。不过,我们可以通过几种不同的方式改进这一点。

一个显而易见的选择是,用一个始终返回相同位置的虚假实例替换真正的 GeoIP 提供商,这样我们就可以硬编码预期太阳时间。这样做的缺点是,我们将有效地缩小集成范围,这意味着我们将无法验证我们的应用是否正确地与第三方服务通信。

另一种方法是,我们可以用测试服务器从客户端接收的 IP 地址来代替。这样,我们可以使测试更加严格,同时保持相同的集成范围。

为了实现这一点,我们需要创建一个启动过滤器,以便我们使用中间件将自定义 IP 地址注入请求上下文:

public class FakeIpStartupFilter : IStartupFilter
{
    public IPAddress Ip { get; set; } = IPAddress.Parse("::1");

    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> nextFilter)
    {
        return app =>
        {
            app.Use(async (ctx, next) =>
            {
                ctx.Connection.RemoteIpAddress = Ip;
                await next();
            });

            nextFilter(app);
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

FakeApp然后我们可以通过将其注册为服务来将其连接起来:

public class FakeApp : IDisposable
{
    private readonly WebApplicationFactory<Startup> _appFactory;
    private readonly FakeIpStartupFilter _fakeIpStartupFilter = new FakeIpStartupFilter();

    public HttpClient Client { get; }

    public IPAddress ClientIp
    {
        get => _fakeIpStartupFilter.Ip;
        set => _fakeIpStartupFilter.Ip = value;
    }

    public FakeApp()
    {
        _appFactory = new WebApplicationFactory<Startup>().WithWebHostBuilder(o =>
        {
            o.ConfigureServices(s =>
            {
                s.AddSingleton<IStartupFilter>(_fakeIpStartupFilter);
            });
        });

        Client = _appFactory.CreateClient();
    }

    /* ... */
}
Enter fullscreen mode Exit fullscreen mode

现在我们可以更新测试以依赖具体的数据:

[Fact]
public async Task User_can_get_solar_times_for_their_location_by_ip()
{
    // Arrange
    using var app = new FakeApp
    {
        ClientIp = IPAddress.Parse("20.112.101.1")
    };

    var date = new DateTimeOffset(2020, 07, 03, 0, 0, 0, TimeSpan.FromHours(-5));
    var expectedSunrise = new DateTimeOffset(2020, 07, 03, 05, 20, 37, TimeSpan.FromHours(-5));
    var expectedSunset = new DateTimeOffset(2020, 07, 03, 20, 28, 54, TimeSpan.FromHours(-5));

    // Act
    var query = new QueryBuilder
    {
        {"date", date.ToString("O", CultureInfo.InvariantCulture)}
    };

    var response = await app.Client.GetStringAsync($"/solartimes/by_ip{query}");
    var solarTimes = JsonSerializer.Deserialize<SolarTimes>(response);

    // Assert
    solarTimes.Sunrise.Should().BeCloseTo(expectedSunrise, TimeSpan.FromSeconds(1));
    solarTimes.Sunset.Should().BeCloseTo(expectedSunset, TimeSpan.FromSeconds(1));
}
Enter fullscreen mode Exit fullscreen mode

有些开发者可能仍然对在测试中依赖真正的第三方 Web 服务感到不安,因为这可能会导致不确定的结果。相反,有人可能会说,我们确实希望测试能够包含这种依赖关系,因为我们希望能够意识到它是否以意外的方式中断或更改,因为这可能会导致我们自己的软件中出现错误。

当然,使用真实的依赖并不总是可行的,例如,如果服务有使用配额、需要付费,或者速度很慢或不可靠。在这种情况下,我们可能会想用一个伪造的(最好不是模拟的)实现来替换它,以便在测试中使用。然而,这并不是其中一种情况。

与第一个类似,我们也可以编写一个覆盖第二个端点的测试。这个更简单,因为所有输入参数都直接作为 URL 查询的一部分传递:

[Fact]
public async Task User_can_get_solar_times_for_a_specific_location_and_date()
{
    // Arrange
    using var app = new FakeApp();

    var date = new DateTimeOffset(2020, 07, 03, 0, 0, 0, TimeSpan.FromHours(+3));
    var expectedSunrise = new DateTimeOffset(2020, 07, 03, 04, 52, 23, TimeSpan.FromHours(+3));
    var expectedSunset = new DateTimeOffset(2020, 07, 03, 21, 11, 45, TimeSpan.FromHours(+3));

    // Act
    var query = new QueryBuilder
    {
        {"lat", "50.45"},
        {"lon", "30.52"},
        {"date", date.ToString("O", CultureInfo.InvariantCulture)}
    };

    var response = await app.Client.GetStringAsync($"/solartimes/by_location{query}");
    var solarTimes = JsonSerializer.Deserialize<SolarTimes>(response);

    // Assert
    solarTimes.Sunrise.Should().BeCloseTo(expectedSunrise, TimeSpan.FromSeconds(1));
    solarTimes.Sunset.Should().BeCloseTo(expectedSunset, TimeSpan.FromSeconds(1));
}
Enter fullscreen mode Exit fullscreen mode

我们可以继续添加类似的测试,以确保应用程序支持所有可能的地点、日期,并处理潜在的极端情况,例如午夜太阳现象。但是,您可能不想每次都重新测试整个管道,而只需关注计算太阳时间的业务逻辑。

这样做意味着我们需要SolarCalculatorLocationProvider某种方式隔离,而这又意味着我们想要避免的嘲笑。幸运的是,有一个更巧妙的方法来实现这一点。

SolarCalculator我们可以通过将代码的纯部分和不纯部分彼此分离来改变实现:

public class SolarCalculator
{
    private static TimeSpan CalculateSolarTimeOffset(Location location, DateTimeOffset instant,
        double zenith, bool isSunrise)
    {
        /* ... */
    }

    public SolarTimes GetSolarTimes(Location location, DateTimeOffset date)
    {
        var sunriseOffset = CalculateSolarTimeOffset(location, date, 90.83, true);
        var sunsetOffset = CalculateSolarTimeOffset(location, date, 90.83, false);

        var sunrise = date.ResetTimeOfDay().Add(sunriseOffset);
        var sunset = date.ResetTimeOfDay().Add(sunsetOffset);

        return new SolarTimes
        {
            Sunrise = sunrise,
            Sunset = sunset
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

现在,LocationProvider该方法不再依赖于提供位置信息,而是GetSolarTimes将位置作为显式参数。这样做意味着我们不再需要依赖反转,因为没有需要反转的依赖项。

为了将所有东西重新连接在一起,我们需要做的就是更新控制器:

[ApiController]
[Route("solartimes")]
public class SolarTimeController : ControllerBase
{
    private readonly SolarCalculator _solarCalculator;
    private readonly LocationProvider _locationProvider;
    private readonly CachingLayer _cachingLayer;

    public SolarTimeController(
        SolarCalculator solarCalculator,
        LocationProvider locationProvider,
        CachingLayer cachingLayer)
    {
        _solarCalculator = solarCalculator;
        _locationProvider = locationProvider;
        _cachingLayer = cachingLayer;
    }

    [HttpGet("by_ip")]
    public async Task<IActionResult> GetByIp(DateTimeOffset? date)
    {
        var ip = HttpContext.Connection.RemoteIpAddress;
        var cacheKey = ip.ToString();

        var cachedSolarTimes = await _cachingLayer.TryGetAsync<SolarTimes>(cacheKey);
        if (cachedSolarTimes != null)
            return Ok(cachedSolarTimes);

        // Composition instead of dependency injection
        var location = await _locationProvider.GetLocationAsync(ip);
        var solarTimes = _solarCalculator.GetSolarTimes(location, date ?? DateTimeOffset.Now);

        await _cachingLayer.SetAsync(cacheKey, solarTimes);

        return Ok(solarTimes);
    }

    /* ... */
}
Enter fullscreen mode Exit fullscreen mode

由于我们现有的测试不了解实现细节,因此这种简单的重构不会对它们造成任何破坏(单元测试则不会出现这种情况)。完成这些之后,我们可以编写一些额外的轻量级测试,以更广泛地覆盖业务逻辑,同时仍然不会模拟任何东西:

[Fact]
public void User_can_get_solar_times_for_New_York_in_November()
{
    // Arrange
    var location = new Location
    {
        Latitude = 40.71,
        Longitude = -74.00
    };

    var date = new DateTimeOffset(2019, 11, 04, 00, 00, 00, TimeSpan.FromHours(-5));
    var expectedSunrise = new DateTimeOffset(2019, 11, 04, 06, 29, 34, TimeSpan.FromHours(-5));
    var expectedSunset = new DateTimeOffset(2019, 11, 04, 16, 49, 04, TimeSpan.FromHours(-5));

    // Act
    var solarTimes = new SolarCalculator().GetSolarTimes(location, date);

    // Assert
    solarTimes.Sunrise.Should().BeCloseTo(expectedSunrise, TimeSpan.FromSeconds(1));
    solarTimes.Sunset.Should().BeCloseTo(expectedSunset, TimeSpan.FromSeconds(1));
}

[Fact]
public void User_can_get_solar_times_for_Tromso_in_January()
{
    // Arrange
    var location = new Location
    {
        Latitude = 69.65,
        Longitude = 18.96
    };

    var date = new DateTimeOffset(2020, 01, 03, 00, 00, 00, TimeSpan.FromHours(+1));
    var expectedSunrise = new DateTimeOffset(2020, 01, 03, 11, 48, 31, TimeSpan.FromHours(+1));
    var expectedSunset = new DateTimeOffset(2020, 01, 03, 11, 48, 45, TimeSpan.FromHours(+1));

    // Act
    var solarTimes = new SolarCalculator().GetSolarTimes(location, date);

    // Assert
    solarTimes.Sunrise.Should().BeCloseTo(expectedSunrise, TimeSpan.FromSeconds(1));
    solarTimes.Sunset.Should().BeCloseTo(expectedSunset, TimeSpan.FromSeconds(1));
}
Enter fullscreen mode Exit fullscreen mode

虽然这些测试不再涵盖完整的集成范围,但它们仍然由应用程序的功能需求驱动。由于我们已经有另一个覆盖整个端点的高级测试,因此我们可以在不牺牲整体信心的情况下将这些测试的范围缩小。如果我们试图提高执行速度,这种权衡是合理的,但我建议尽可能坚持使用高级测试,至少在它成为问题之前是这样。

最后,我们可能还想做一些事情来确保我们的 Redis 缓存层也能正常工作。即使我们在测试中使用它,它实际上也不会返回缓存的响应,因为数据库在测试之间会被重置。

测试缓存之类的问题在于,它们无法通过功能需求来定义。用户不了解应用的内部事务,无法知道响应是否来自缓存。

但是,如果我们的目标只是测试应用程序和 Redis 之间的集成,我们不需要编写实现感知测试,而是可以执行以下操作:

[Fact]
public async Task User_can_get_solar_times_for_their_location_by_ip_multiple_times()
{
    // Arrange
    using var app = new FakeApp();

    // Act
    var collectedSolarTimes = new List<SolarTimes>();

    for (var i = 0; i < 3; i++)
    {
        var response = await app.Client.GetStringAsync("/solartimes/by_ip");
        var solarTimes = JsonSerializer.Deserialize<SolarTimes>(response);

        collectedSolarTimes.Add(solarTimes);
    }

    // Assert
    collectedSolarTimes.Select(t => t.Sunrise).Distinct().Should().ContainSingle();
    collectedSolarTimes.Select(t => t.Sunset).Distinct().Should().ContainSingle();
}
Enter fullscreen mode Exit fullscreen mode

该测试将多次查询同一个端点,并断言结果始终保持不变。这足以确保响应被正确缓存,并以与正常响应相同的方式返回。

最终,我们得到了一个简单的测试套件,如下所示:

测试套件

值得注意的是,测试的时长相当不错,最快的集成测试仅用了 55 毫秒,最慢的测试则不到一秒(因为冷启动)。考虑到这些测试涵盖了整个生命周期,包含了所有依赖项和基础架构,而且完全没有依赖任何模拟,我认为这个时间是可以接受的。

如果您想自己修改示例项目,可以在 GitHub 上找到它。

缺点和注意事项

不幸的是,没有灵丹妙药,本文描述的方法也存在一些潜在的缺陷。为了公平起见,有必要提及这些缺陷。

我发现进行高级功能测试的最大挑战之一是如何在实用性和可用性之间找到一个良好的平衡点。与专注于单元测试的方法相比,需要付出更多努力才能确保此类测试具有足够的确定性、耗时不长、能够彼此独立运行,并且在开发过程中普遍可用。

广泛的测试范围也意味着需要更深入地了解项目的依赖项和技术。重要的是了解它们的使用方式、是否可以轻松容器化、有哪些可用选项以及利弊权衡。

在集成测试中,“可测试性”并非由代码隔离程度来定义,而是由实际基础设施对测试的适应和支持程度来决定。这对负责人和整个团队的技术专长提出了一定的先决条件。

设置和配置测试环境可能也需要一些时间,因为它包括创建 Fixture、连接伪实现、添加自定义初始化和清理行为等等。随着项目规模的扩大和复杂程度的提升,所有这些工作都需要持续维护。

编写功能测试本身也需要更多的规划,因为它不再仅仅涵盖每个类的每个方法,而是需要概述软件需求并将其转化为代码。理解这些需求是什么以及哪些是功能性的有时也很棘手,因为这需要从用户的角度思考的能力。

另一个常见的问题是,高级测试通常缺乏局部性。如果测试失败,无论是由于未满足预期还是由于未处理的异常,通常都不清楚错误的具体原因。

虽然有一些方法可以缓解这个问题,但最终还是需要权衡:独立测试更擅长指出错误的原因,而集成测试更擅长凸显错误的影响。两者同样有用,所以最终取决于你认为哪个更重要。

归根结底,尽管存在这些缺点,我仍然认为功能测试是值得的,因为我发现它总体上能带来更好的开发者体验。我已经有一段时间没做过经典的单元测试了,而且我不太想再重新开始。

概括

单元测试是一种流行的软件测试方法,但大多时候出于错误的考虑。它经常被吹捧为开发人员测试代码并执行最佳设计实践的有效方法,然而许多人却觉得它繁琐且肤浅。

重要的是要理解,开发测试并不等同于单元测试。开发测试的主要目标并非编写尽可能独立的测试,而是确保代码能够按照其功能需求运行。当然,还有更好的方法来实现这一点。

编写由用户行为驱动的高级测试,从长远来看将为您带来更高的投资回报,而且它并没有看起来那么难。找到最适合您项目的方法并坚持下去。

以下是主要内容:

  1. 批判性思考并挑战最佳实践
  2. 不要依赖测试金字塔
  3. 按功能而不是按类、模块或范围分离测试
  4. 力求达到最高集成度,同时保持合理的速度和成本
  5. 避免为了可测试性而牺牲软件设计
  6. 仅将 mocking 作为最后的手段

还有其他一些关于现代软件开发中替代测试方法的优秀文章。以下是我个人觉得非常有趣的文章:


在 Twitter 上关注我,当我发布新文章时会收到通知✨

鏂囩珷鏉ユ簮锛�https://dev.to/tyrrrz/unit-testing-is-overlated-150e
PREV
新的 Web 代码编辑器
NEXT
面试题:堆 vs 栈 (C#) 前言 堆 vs 栈