和 ASP.NET MVC 一样,ASP.NET Web API 的编写也考虑了可测试性。这意味着我们可以使用测试驱动开发来开发 REST 服务,或者只是在我们现有的应用程序中添加单元测试或集成测试。如果我们看一下 ASP.NET 源代码,主要的解决方案包含 1000 多个测试——微软对测试是认真的。
可测试性的支柱之一是被测试元素的解耦。在我们的例子中,这意味着控制器应该与其他组件去耦合。我们只能编写带有解耦控制器的测试单元;如果我们不能得到完全的解耦,我们只能编写集成测试。
考虑以下只有三个动作的控制器:
public class PostsController : ApiController
{
private readonly PostRepository _repository;
public PostsController()
{
_repository = new PostRepository();
}
public IQueryable<Post> Get()
{
return _repository.GetAll();
}
public Post Get(int
id)
{
return _repository.Get(id);
}
public HttpResponseMessage Post(Post post)
{
_repository.Create(post);
var response = Request.CreateResponse(HttpStatusCode.Created);
string uri = Url.Link("DefaultApi", new { id = post.Id });
response.Headers.Location = new Uri(uri);
return response;
}
}
我们如何对这个控制器进行单元测试?
这是不可能的,因为它对存储库类有很强的依赖性,并且控制器在构造函数中创建存储库的实例。新的操作才是真正把PostController
和PostRepository
结合起来的。因此,如果我们想要测试控制器,我们必须处理完整的堆栈并运行集成测试,而不是单元测试。
单元和集成只是两种不同类型的测试,目的不同。单元测试是为了测试一个独立于应用程序其余部分的方法而构建的。这意味着测试和被测系统(SUT)不得使用外部资源(数据库、文件系统、网络等)。)并且被测试的代码不能依赖于其他对象。在集成测试中,我们针对应用程序的完整堆栈编写测试,这意味着被测试的类连接到可以使用数据库或其他外部资源的其他真实类。
图 21:单元测试和集成测试
这实际上意味着什么?
单元测试通常更快,因为它们完全在内存中执行,而集成测试往往更慢,因为它们可能依赖于外部资源。如果一个单元测试失败了,那么在我们正在测试的方法中肯定会出现错误,但是如果一个集成测试失败了,那么错误可能在应用程序的其他部分,有时可能不是“真正的”错误(例如,如果连接字符串不正确,或者如果数据库服务器不可访问)。
那么单元测试比集成测试好吗?绝对不是——每个测试都很重要。单元测试是为微功能设计的,而集成测试验证应用程序的完整性。
回到我们的PostsController
,让我们看看如何重构它来支持更好的可测试性。我们已经说过,问题出在创建存储库实例的构造函数上。如果我们可以应用依赖注入模式,并将存储库的抽象传递给控制器,我们就可以编写单元测试。
所以我们可以重构控制器,让它接收接口的一个实例IPostRepository
:
public class PostsController : ApiController
{
private readonly IPostRepository _repository;
public PostsController(IPostRepository repository)
{
_repository =
repository;
}
//...
}
这种重构允许我们对PostsController
进行单元测试,因为我们消除了强依赖性。在我们的测试中,我们可以通过一个模拟的存储库:
[Fact]
public void
GetById_should_return_the_post()
{
Mock<IPostRepository> repo = new Mock<IPostRepository>();
PostsController controller = new PostsController(repo.Object);
controller.Get(42);
repo.Verify(r =>
r.Get(42));
}
我们已经创建了一个存储库的模拟实例,并创建了控制器。我们可以验证当我们在控制器上调用Get
时,用正确的参数调用了存储库。由于存储库是一个模拟对象,并且控制器没有使用真正的存储库,所以这是一个单元测试。
状态测试与交互测试
使用模拟对象,我们可以编写两种不同风格的单元测试。基于状态的测试检查 SUT 的正确性,在测试方法执行后验证 SUT 的状态。
基于交互的测试检查实现的正确性,验证 SUT 对协作者进行了正确的调用,因此不需要验证 SUT 的状态。
但是这种重构破坏了我们的应用程序;即使测试是绿色的,如果我们运行应用程序并从客户端调用Get
,我们会得到以下错误:
Type
'HelloWebApi.Controllers.PostsController' does not have a default constructor"
这里发生的情况是,由于网络应用编程接口不能构建具有外部依赖关系的控制器,所以它搜索默认构造函数。因为它找不到它,所以抛出一个异常。
为了解决这个问题,我们必须实现一个定制的控制器解析器,它能够解析依赖关系并构造控制器。
实际上,这意味着我们实现接口IDependencyResolver
并将默认解析器更改为新的解析器。接口IDependencyResolver
(实现IDependencyScope
和IDisposable
)提供两种方式:
GetService
:用于获取指定类型的单个组件的实例。GetServices
:用于获取指定类型的对象集合。
所以对于控制器,运行时调用GetService
。如果我们的自定义控制器能够创建所需的类型,它就会构建并返回它。如果没有,它只返回 null,以便运行时继续使用默认的解析器。
自定义控制器还可以管理它创建的对象的生存期;为此使用了两种方法BeginScope
和Dispose
。当框架创建一个控制器的新实例时,它调用BeginScope
,返回一个IDependencyScope
的实例。然后,运行时调用IDependencyScope
实例上的GetService
来获取控制器实例。当请求完成时,运行时在子作用域上调用Dispose
,所以我们可以使用Dispose
来处理控制器的依赖关系。
因此,实际上,一个简单的解析器可能是这样的:
public class SimpleResolver : IDependencyResolver
{
public object GetService(Type serviceType)
{
if (serviceType == typeof(PostsController))
{
return new PostsController(new PostRepository());
}
return null;
}
public IEnumerable<object> GetServices(Type
serviceType)
{
return new List<object>();
}
public void Dispose()
{
}
public IDependencyScope BeginScope()
{
return this;
}
}
最重要的部分是在GetService
方法中,该方法负责基于serviceType
构建请求的对象。在之前的实现中,我们只是使用新的操作符创建了PostsController
。在现实场景中,该代码可能应该被替换为控件容器的反转,它将为我们解析对象。
现在我们已经正确实现了解析器,我们需要将其添加到配置中,以确保它能够工作。将此行添加到WebApiConfig.Register
方法中:
config.DependencyResolver = new
SimpleResolver();
回到测试主题,让我们看看如何测试Post
动作:
public HttpResponseMessage Post(Post
post)
{
_repository.Create(post);
var response = Request.CreateResponse(HttpStatusCode.Created);
string uri = Url.Link("DefaultApi", new { id = post.Id });
response.Headers.Location = new URI(uri);
return response;
}
该动作使用控制器创建一个新的Post
对象,然后创建一个带有状态代码的响应,并将位置头设置为新创建的帖子。我们希望测试响应消息是否包含正确的状态代码,以及报头是否存在。
测试这段代码很容易,但是需要一些设置来准备请求和响应的上下文。
测试的完整代码如下:
[Fact]
public void
Post_Status_is_Created_and_header_contains_the_location()
{
Mock<IPostRepository> repository = new Mock<IPostRepository>();
var controller = new PostsController(repository.Object);
var config = new HttpConfiguration();
var request = new HttpRequestMessage(HttpMethod.Post,
"http://localhost/");
IHttpRoute route = config.Routes.MapHttpRoute("DefaultApi",
"api/{controller}/{id}");
HttpRouteData routeData = new HttpRouteData(route,
new HttpRouteValueDictionary
{
{ "controller", "posts" }
});
controller.ControllerContext =
new HttpControllerContext(config, routeData, request);
request.Properties[HttpPropertyKeys.HttpConfigurationKey] = config;
request.Properties[HttpPropertyKeys.HttpRouteDataKey] = routeData;
controller.Request =
request;
HttpResponseMessage response = controller.Post(new Post() {
Title = "test",
Date = DateTime.Today,
Body = "blablabla"
});
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
}
测试从创建模拟存储库开始,这是测试通过所需要的,即使测试中没有使用它。创建控制器后,所有的代码都需要用来设置 Web API 需要运行的上下文,因此它可以使用像Request.CreateResponse
和Url.Link
这样的辅助方法。基本上我们创建了路由表、请求对象、配置和ControllerContext
。
| | 注:样本测试使用 xunit.net(http://xunit.codeplex.com/)作为测试。 |
接下来,我们通过传递一个假的Post
对象来调用Post()
方法。我们在响应消息上Assert
以确保状态代码是正确的,并且位置头是存在的。
POST
动作是最难测试的动作。其他像GET
和DELETE
这样的就简单多了,因为它们不需要处理请求和响应机制。因此,这个测试并不像它应该的那样漂亮,因为设置代码很难写和读。我们当然可以重构它,创建辅助方法或抽象类来继承,但是我们也可以使用一种特殊的宿主技术来测试控制器与完整的 ASP.NET 网络应用编程接口堆栈的集成。
在前一章中,我们已经讨论了内存托管,并且已经展示了它的工作原理。在这里,我们将看到如何使用它来测试我们的控制器。通过内存托管,我们可以编写集成测试,就像 ASP.NET 网络应用编程接口在生产环境中一样。因此,这个场景中的测试通过了整个堆栈,从请求到模型绑定器、控制器、存储库,再到响应。这是针对 API 行为编写验收测试的完美技术。
[Fact]
public void
Get_with_in_memory_hosting()
{
HttpConfiguration config = new HttpConfiguration();
WebApiConfig.Register(config);
HttpServer server = new HttpServer(config);
HttpClient client = new HttpClient(server);
var response = client.GetAsync("http://localhost/api/posts").Result;
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
这个测试比上一个简单;它创建了一个内存服务器,该服务器“监听”使用HttpClient
类发出的请求,并对响应进行断言。这是一个集成测试,因为它使用整个应用程序堆栈。正如您在测试开始时所看到的,我们必须使用WebApiConfig
类配置应用编程接口,以便测试配置与生产中的配置相同。需要注意的是,即使我们在测试中测试PostsController
,它也不会被直接使用。相反,我们构建了一个到/api/posts
URI 的请求,路由系统将该请求路由到PostsController
(在生产场景中正是这样发生的)。
Post()
动作的测试是这样的:
[Fact]
public void
Get_with_in_memory_hosting()
{
HttpConfiguration config = new HttpConfiguration();
WebApiConfig.Register(config);
HttpServer server = new HttpServer(config);
HttpClient client = new HttpClient(server);
HashSet<KeyValuePair<string, string>> values = new
HashSet<KeyValuePair<string, string>>{
new KeyValuePair<string, string>("Title", "test"),
new KeyValuePair<string, string>("Date", "2010-04-11"),
new KeyValuePair<string, string>("Body", "blablabla")
};
HttpResponseMessage response = client.PostAsync("http://localhost/api/posts", new
FormUrlEncodedContent(values)).Result;
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
}
在这种情况下,我们不需要设置整个上下文,因为它是由内存服务器自动配置的。我们要做的是建立请求。既然这是一个POST
,请求必须有一个体。我们使用的是一个FormUrlEncodedContent
,它是一个HashSet
,键值对代表要创建的Post
的信息。
测试通常被认为是实际应用程序的附属品,也许是因为在过去,测试 web 应用程序非常困难。ASP.NET 网络应用编程接口使测试像编写应用程序的其他部分一样容易。由于测试驱动开发是适用的,我们的应用编程接口在一次又一次的测试中成长。
在这一章中,我们看到了如何测试网络应用编程接口,以及如何应用控制反转来注入依赖关系,以便创建的测试是真正的单元测试。