进行HTTP调用以获取资源或调用API是软件开发的主要内容。但如果你没有正确地从你使用的HTTP库中抽象出请求构造和响应处理,那么测试起来就会非常困难。
下面是一个我们可能在C#中发现的与HTTP有关的代码的例子。
public User GetThatUserData(string userId)
{
var httpClient = new HttpClient();
var url = $"https://example.com/api/users/{userId}";
var response = httpClient.GetAsync(url).Result;
if (response.StatusCode != HttpStatusCode.OK)
{
throw new Exception($"Unexpected status code: {response.StatusCode}");
}
var body = response.Content.ReadAsStringAsync().Result;
return JsonConvert.DeserializeObject<User>(body);
}
还有一个JavaScript中的类似例子。
async function getThatUserData(userId) {
const url = 'https://example.com/api/users/' + userId
const response = await axios.get(url)
if (response.status !== 200) {
throw new Error('Unexpected status code: ' + response.statusCode)
}
return response.data
}
乍一看,这些似乎是很好的抽象概念。所有与HTTP相关的处理都包含在函数中;输入和输出都没有暴露任何关于URL、头文件、状态码或正文的信息。但是它们很难测试。
有时我们选择依靠集成测试来测试这类函数。不幸的是,这导致我们的测试依赖于一个外部依赖。如果该服务器停机或因某种未知原因失效,我们的测试就会失败,即使我们的代码是正常的。此外,你可能无法测试你的错误路径,因为你无法控制响应。而当我们有了为测试目的而运行自己的服务器的想法时,事情就变得更加复杂了。
根据我们使用的语言和库,也许可以对这些功能进行单元测试。但即使有可能,也往往很复杂,使我们的测试更难理解和维护。
所以,在测试困难的时候,我们可能根本就不测试这些函数。毕竟,我们不需要测试底层的HTTP库,对吗?不幸的是,这在我们的请求和响应处理中留下了一个未测试的空白。
在C语言中抽象化我们自己
为了使代码更容易测试,我们需要将HTTP库分离出来。让我们先在C#中做到这一点。
在上面的代码中,HTTP请求和响应都是通过调用带有url的GetAsync 方法建立的。为了抽象出这个库,我们可以先为实际执行HTTP调用的那部分引入一个新的接口。
public interface IHttpExecutor
{
Task<HttpResponseMessage> GetAsync(string url);
}
public User GetThatUserData(string userId, IHttpExecutor executor)
{
var url = $"https://example.com/api/users/{userId}";
var response = executor.GetAsync(url).Result;
if (response.StatusCode != HttpStatusCode.OK)
{
throw new Exception($"Unexpected status code: {response.StatusCode}");
}
var body = response.Content.ReadAsStringAsync().Result;
return JsonConvert.DeserializeObject<User>(body);
}
在这个例子中,我决定使用基于方法的依赖注入来将新的IHttpExecutor ,因为它很容易写在博客文章中。但如果你愿意,你也可以使用类级注入。重要的是,现在我们有一个接口,将我们的代码与HTTP库分开。
现在我们可以开始编写测试,利用该接口的测试双实现。然后,我们可以验证我们是否调用了正确的url(通过询问测试double),我们可以让它返回我们想要测试的任何HttpResponseMessage 。我们甚至可以根据需要抛出异常,以表示超时或连接失败的情况。
但是处理HttpResponseMessage 是一件很麻烦的事情。我们如何设置响应体?如何设置我们自己的响应对象以便response.Content.ReadAsStringAsync() ,这并不明显。我们还没有从库中抽象出足够多的内容。
public class HttpResponse
{
public int StatusCode { get; set; }
public string Body { get; set; }
}
public interface IHttpExecutor
{
Task<HttpResponse> GetAsync(string url);
}
public User GetThatUserData(string userId, IHttpExecutor executor)
{
var url = $"https://example.com/api/users/{userId}";
var response = executor.GetAsync(url).Result;
if (response.StatusCode != 200)
{
throw new Exception($"Unexpected status code: {response.StatusCode}");
}
return JsonConvert.DeserializeObject<User>(response.Body);
}
现在我们可以轻松地通过测试替身来测试我们的代码。新的HttpResponse 对象很简单,可以实例化我们需要的一切。当然,如果我们要在测试之外运行我们的代码,我们将需要一个真正的接口实现。
public class HttpExecutor : IHttpExecutor
{
private HttpClient httpClient = new HttpClient();
public async Task<HttpResponse> GetAsync(string url)
{
var response = await httpClient.GetAsync(url);
var body = await response.Content.ReadAsStringAsync();
return new HttpResponse {
StatusCode = (int)response.StatusCode,
Body = body
};
}
}
这个GetAsync 方法很难进行单元测试,但它不包括任何针对我们用例的请求构建和响应处理。而且因为它是一个更通用的接口(没有指定一个特定的url),所以为它写集成测试变得更容易。
随着时间的推移,我们可以在我们的HttpResponse ,以考虑到像响应头这样的事情。我们可能还会发现,我们想要创建一个HttpRequest 类,这样我们就可以指定HTTP请求的概念,如方法、头信息和正文。然后我们可以有一个单一的ExecuteAsync(HttpRequest request) 方法来处理所有种类的请求。
当然,有些HTTP库比其他库要好。也许我们应该直接选择内置的HttpClient 以外的东西?如果我们选择了一个具有良好接口的东西,我们就可以避免创建所有这些额外的类。但使用这些类的好处是,现在我们可以根据需要切换底层的实现,而不需要改变任何其他代码。
在JavaScript中实现功能
在C#的例子中,我使用了注入和测试替身,因为当我写C#时,我倾向于使用模拟主义风格的TDD。这种语言还要求我们定义一堆类型来模拟HTTP。让我们用一种更实用的风格来修改我们的JavaScript例子代码。
我们的测试问题从根本上归结为需要测试我们是否正确创建了请求和处理了响应。但这些与执行HTTP调用没有任何关系,所以它们可以是纯函数(没有I/O)。这些都是很容易测试的!
function buildRequest(userId) {
return {
url: 'https://example.com/api/users/' + userId
}
}
function handleResponse(response) {
if (response.status !== 200) {
throw new Error('Unexpected status code: ' + response.statusCode)
}
return response.data
}
现在我们只需要将这些函数与我们的HTTP库进行组合,以便进行端到端的调用。
async function getThatUserData(userId) {
return handleResponse(await axios.get(buildRequest(userId).url))
}
在我们的新方法中,唯一没有测试的是axios.get 的调用。但这并不是我们的代码,所以我们真的需要测试它吗?我们如何调用它是未经测试的,但如果不多走一步,把axios 调用移到另一个函数后面,就很难做到这一点。
async function execute(request) {
return await axios.get(request.url)
}
async function getThatUserData(userId) {
return handleResponse(await execute(buildRequest(userId)))
}
和C#的例子一样,这个execute 方法足够通用,比原来的函数更容易测试,因为它是专门针对我们要调用的API的。现在我们可以考虑支持其他类型的请求(如POST),或者根据需要用另一个库替换axios 。
结论
有了正确的抽象,你可以轻松地对你的HTTP相关代码进行单元测试。这使你能够确保你正确地构建你的请求,验证诸如URL构造、请求头等事情。
更重要的是,你现在可以测试你的代码将如何处理所有类型的响应。我们可以建立对成功调用时如何处理数据的信心。以前难以测试的失败案例,如特定的400和500级别的状态代码、请求超时,甚至是畸形的响应,现在都很容易测试。
这种抽象化难以测试的调用的方法也可用于其他类似情况,如数据库调用。抽象库和其他依赖关系可以为你的代码增加很多灵活性,同时也使它更容易测试。