前篇:单元测试被高估了(2)
Web 服务的功能测试
对于功能测试由什么构成,可能仍然存在一些混淆,因此展示一个简单但完整的示例是有意义的。为此,我们将把之前的太阳时计算器变成一个 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
};
}
}
为了将位置转换为太阳时,我们参考了美国海军天文台发布的日出/日落算法。算法本身太长,这里就不展开了,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
};
}
}
我们还需要有一个控制器来暴露接口以提供服务:
[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)
{
/* ... */
}
}
如上所示,/solartimes/by_ip
只是将执行委托给SolarCalculator
,并且有非常简单的缓存逻辑以避免对第三方服务的多余请求。缓存由CachingLayer
封装用于向 Redis 存储和检索 JSON 内容:
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));
}
最后是启动类,通过配置请求和注册服务,将上述所有的类中联系在一起:
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();
}
}
注意,到目前为止,所有的类都没有实现任何接口,因为我们不打算使用 Mock。在测试用例中可能会需要替换某项服务,但现在还不甚清楚,所以我们先避免不必要的工作,直到确定需要它时再实现。
虽然这是一个相当简单的项目,但这个应用程序已经依赖了第三方 Web 服务(GeoIP)以及持久层(Redis),从某种意义上说,同时也具有某种程度的复杂性。这是一个典型的 Web 服务,许多现实生活中的项目都与之类似。
如果专注于单元测试,我们会针对应用程序的服务层甚至控制器层,编写测试以确保每个代码分支都正确执行。这样做在一定程度上是有用的,但远不能让我们相信实际情况也会如此顺利,也不能让我们确保所有的中间件和其他组件,都能按预期工作。
相反,我们将编写直接针对接口的测试。为此,我们需要创建一个单独的测试项目并添加一些支持测试的基础组件。其中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();
}
}
大部分工作由WebApplicationFactory
完成,这是框架提供的一个实用程序,允许我们在内存中启动应用程序以进行测试。它还为我们提供了 API 来覆盖配置、服务注册和请求。
在测试中可以使用该对象的实例来运行应用程序,使用提供的HttpClient
发送请求,然后检查响应是否符合预期。此实例可以在多个测试用例之间共享,也可以为每个测试用例单独创建。
由于项目也依赖 Redis,我们希望有一种方法每次启动一个新的 Redis 服务器以供我们的应用程序使用。有很多方法可以做到,对于简单的示例,我决定使用 xUnit 提供的特性来实现:
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();
}
上面的代码通过实现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));
}
}
用例的准备工作非常简单,我们需要做的就是创建一个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);
};
}
}
然后通过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();
}
/* ... */
}
现在可以更新测试以依赖具体数据:
[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));
}
一些开发人员可能仍然对在测试中依赖真正的第三方服务感到不安,因为它可能会增加测试的不稳定性。相反,有人可能会争辩说我们确实希望测试中包含这种依赖关系,因为我们想知道它是否会以意想不到的方式中断,因为它可能会让我们捕获到软件出现的错误。
当然,使用真正的依赖并不总是可行的,例如,如果服务有使用限额、需要花钱,或者速度很慢或不稳定。在这种情况下,我们希望将其替换虚拟服务(最好不是 Mock 的),以便在测试中使用。
与第一个测试用例类似,我们将编写对第二个接口的测试。这个更简单,因为所有输入参数都直接作为 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));
}
我们可以继续添加这样的测试,以确保应用程序支持所有可能的位置、日期,并处理潜在的边缘情况,例如极昼现象。但是,这样做的扩展性可能会很差,因为我们不想每次都执行整个端到端的测试来验证计算太阳时的业务逻辑是否正常工作。
需要注意的是,尽管我们希望尽可能避免缩小集成范围,但如果有真正的原因,我们仍然需要这么做。例如,在这种情况下,我们选择用单元测试覆盖其他情况。
通常,这意味着我们需要以某种方式将SolarCalculator
与LocationProvider
隔离,这也意味着要使用 Mock. 幸运的是,有一种巧妙的方法可以避免使用 Mock.
现在我们将简化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
};
}
}
SolarCalculator
不再依赖LocationProvider
,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);
}
/* ... */
}
由于现有的测试不了解实现细节,这个简单的重构并没有破坏已有的测试。完成后,我们可以编写一些额外的轻量级测试来更广泛地覆盖业务逻辑,同时仍然不需要 Mock 任何东西:
[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));
}
尽管这些测试不再涉及完整的端到端的集成范围,但它们仍然由应用程序的功能需求驱动。因为我们已经有另一个覆盖完整端到端的高级测试,所以我们可以在不牺牲整体信心的情况下使用更小范围的集成测试。
如果我们试图提高执行速度,这种权衡是有意义的,但我仍然建议在遇到实际阻碍前尽可能坚持高级测试。
最后,我们可能还想做一些事情来确保 Redis 缓存层也能正常工作。即使我们在以上的测试中使用了它,但它实际上没有返回任何缓存的响应,因为 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();
}
测试发起多次查询并断言结果始终保持不变。这足以确保响应被正确缓存,然后以与正常响应相同的方式返回。
最终,所有这些测试组成一个简单的测试集,如下所示:
可以看到,测试的执行速度非常好,最快的集成测试在 55 毫秒内完成,最慢的也不到一秒(由于冷启动)。考虑到这些测试涉及应用程序的完整流程,包括所有依赖项和基础设施,并且没有使用 Mock,执行速度是完全可以接受的。
如果您想修改示例,可以从 GitHub 上找到该项目。
缺点
当然,没有包治百病的灵丹妙药,本文中描述的方法也存在一些缺点。
在进行高级功能测试时,我发现的最大挑战之一是在测试的有用性和可用性之间找到良好的平衡。与单元测试的方法相比,在保证测试的确定性、彼此独立运行以及在开发过程中始终可用需要付出更多努力。
更广的测试范围意味着需要更深入地了解项目的依赖关系和它所依赖的技术。更重要的是要了解它们的使用方式、它们是否可以容器化、可用的选项以及权衡取舍。
在集成测试的上下文中,“可测试性”不是由代码的隔离程度来定义的,而是由集成测试对基础设施的适应性和其对测试的促进程度来定义。这对项目负责人和整个团队提出了更高的要求。
设置和配置测试环境也可能需要更多时间,因为它包括创建虚拟实现、添加初始化行为和清理动作等等。随着项目变得更加复杂,所有这些事情都需要维护。
编写功能测试本身也需要更多的计划性,因为它不再只是涵盖类的方法,而是针对软件需求并将其转化为代码。有时了解这些需求是什么,以及判断其中哪些部分应该进行功能测试也很棘手,因为它需要从用户的角度思考。
另一个常见的问题是,高级测试经常会受到缺少细节的影响。如果测试失败,无论是由于未达到预期还是由于未处理的异常,通常都不清楚究竟是什么导致了错误。
尽管有一些方法可以缓解这个问题,但最终总是落在对两者的权衡取舍上:单元测试更善于指出错误的原因,而集成测试更善于突出影响。
尽管有这些缺点,我仍然认为功能测试是值得的,因为它可以带来更好的开发体验。
总结
单元测试是一种流行的软件测试方法,但其流行主要是出于某种错误的原因。它通常被吹捧为开发人员测试代码和改善设计的最佳实践,但也有许多人认为它是累赘。
测试并不等同于单元测试。测试的主要目标不是编写尽可能独立的单元测试,而是获得对代码的信心,而且有比单元测试更好的方法来实现这一目标。
长远来看,编写由用户行为驱动的高级测试有更高的投资回报。重要的是,找到一种对您的项目最有意义的方法并坚持下去。
最后总结本文的主要内容:
- 不要依赖测试金字塔
- 按功能划分测试,而不是按类、模块或范围
- 以最高水平的集成为目标,同时保持合理的速度和成本
- 避免为了代码可测试性而牺牲软件设计
- 将 Mock 方法作为最后不得已的测试技术
{测试窝原创译文,译者lukeaxu}