ASP.NET MVC Tip #21 – 如何伪造 Data Context
ASP.NET MVC Tip #21 – Fake the Data Context
美语原文:http://weblogs.asp.net/stephenwalther/archive/2008/07/19/asp-net-mvc-tip-21-fake-the-data-context.aspx
国语翻译:http://www.cnblogs.com/mike108mvp
译者注:在下水平有限,翻译中若有错误或不妥之处,欢迎大家批评指正。谢谢。
译者注:ASP.NET MVC QQ交流群 1215279 欢迎对 ASP.NET MVC 感兴趣的朋友加入
在这篇帖子中,我将演示如何创建一个在内存中的data context类,当你在对ASP.NET MVC applications 的数据访问进行单元测试时,你可以使用这个data context类。
在这篇帖子中,我将解释如何为ASP.NET MVC application的数据访问代码编写单元测试。我将演示如何对LINQ to SQL DataContext 进行单元测试,而不必使用“模拟对象框架”(Mock Object Framework)。首先,我会展示如何创建一个泛型DataContextRepository,你可以使用它来获取、修改数据库中的记录。接着,我将展示如何创建一个 FakeDataContextRepository,你可以使用它来单元测试你的数据访问代码。
动机
如果你在实践测试驱动开发,那么你应该不写任何程序代码,直到你已经为这些程序代码写出测试代码之后。这个测试代码表达了你想让你的程序代码如何工作的意图。你应该始终针对测试来写程序代码。测试代码也为你安全得修改程序代码提供了一张安全网。
在实践测试驱动开发时,数据访问代码有一个特殊的问题。这个问题就是访问数据库几乎始终是一个很慢的操作。既然你每次修改程序代码都要执行单元测试,那么运行单元测试的速度很慢是一个坏主意。很慢的测试是不好的,因为它会引诱你放弃测试驱动开发,因为它太消耗时间了。
如何针对数据访问代码测试的这个问题作出反应?一种方法是在应用程序中避免测试任何能够接触数据库的代码。这个看起来是个很坏的选项。如果说测试驱动开发对于应用程序设计是很好的,而且数据访问代码是你的应用程序中如此重要的一部分,那么你最好找到某种方式来让这二者协同工作。
第二种方法是接受这种缓慢的速度,来对针对数据库的数据访问代码进行单元测试。这个主意是在你执行单元测试前生成一个新的测试数据库。这是我在MVC Tip #20的帖子中探讨的:http://www.cnblogs.com/mike108mvp/archive/2008/07/20/1247158.html
这种方法在Ruby on Rails开发人员中是很流行的。并且,它工作地很好,只要你的应用程序保持简单。然而,在某些方面,它花费太长时间来运行单元测试,并且探索第三种方法或终极方法是很有意义的。
第三种方法或终极方法是伪造数据库。在你的单元测试中,你访问一个真实数据库的替身,而不是去访问真实数据库。这就是今天的帖子要探讨的内容。我将演示如何用一个简单的在内存中的数据库来伪造LINQ to SQL DataContext。
单元测试数据访问代码
这篇帖子最后下载的代码包含了两个类DataContextRepository 和 FakeDataContextRepository。让我通过创建一个简单的数据库驱动的MVC应用程序来演示一下如何使用这两个类。
假设你决定要创建一个Movie 数据库应用程序。该应用程序需要支持对movies进行增、删、查、改(CRUD)。你如何开始创建该应用程序?
Figure 1 -- The Movie Database Application
如果你遵守测试驱动开发的实践,那么任何东西都将开始于一个测试。让我们开始写一个测试来验证我们的应用程序的Index()方法是否返回了一个movies 列表。代码清单1中的测试代码使用了FakeDataRepository 类来测试Index()方法。
Listing 1 – HomeControllerTests.cs (C#)
using
System.Collections.Specialized;
using
System.Web.Mvc;
using
Microsoft.VisualStudio.TestTools.UnitTesting;
using
MvcData;
using
Tip21.Controllers;
using
Tip21.Models;
using
System.Collections.Generic;
namespace
Tip21Tests.Controllers
{
[TestClass]
public class HomeControllerTest
{
private Movie CreateTestMovie(string title, string director)
{
Movie newMovie = new Movie();
newMovie.Title = title;
newMovie.Director = director;
return newMovie;
}
[TestMethod]
public void Index()
{
// Setup
var fakeRepository = new FakeDataContextRepository<Movie>();
fakeRepository.Insert(CreateTestMovie("Star Wars", "Lucas"));
fakeRepository.Insert(CreateTestMovie("Batman", "Burton"));
HomeController controller = new HomeController(fakeRepository);
// Execute
ViewResult result = controller.Index() as ViewResult;
// Verify
var movies = (IList<Movie>)result.ViewData.Model;
Assert.AreEqual(2, movies.Count );
Assert.AreEqual("Star Wars", movies[0].Title);
Assert.AreEqual("Batman", movies[1].Title);
}
}
}
代码清单1中的测试代码验证Index() 方法是否从数据库中正确地取回了数据记录。
代码清单1中的测试类包含两个方法。第一个方法是CreateTestMovie()的工具方法,它能够快速创建一个测试数据库。第二个方法是Index(),它是测试HomeController.Index() action用的。
Index()方法中,一个FakeDataContextRepository 类的实例被创建。注意,这是一个泛型类。当实例化这个类时,你必须提供FakeDataContextRepository 代表的实体类型。在这个例子中,FakeDataContextRepository 用来代表Movie 实体集合。(Movie 实体是一个LINQ to SQL 实体,它是由Visual Studio 2008的对象关系设计器创建的。)
其次,两条Movie 记录被添加进FakeDataContext中。在HomeController.Index()方法被调用后,测试代码检查这些记录是否被Index()方法返回。测试代码检查返回的记录数量,以及第一条记录的标题是“Star Wars”,第二条记录的标题是“Batman”。
既然我们有了一个合适的单元测试,我们就可以程序代码来满足这个测试。HomeController 类包含在代码清单2中。
Listing 2 – HomeController.cs (C#)
using
System;
using
System.Collections.Generic;
using
System.Linq;
using
System.Web;
using
System.Web.Mvc;
using
MvcData;
using
Tip21.Models;
using
System.Collections.Specialized;
namespace
Tip21.Controllers
{
[HandleError]
public class HomeController : Controller
{
private IDataContextRepository<Movie> _repository;
public HomeController() : this(new DataContextRepository<Movie>(new MovieDataContext())) { }
public HomeController(IDataContextRepository<Movie> repository)
{
_repository = repository;
}
public ActionResult Index()
{
IList<Movie> movies = _repository.ListAll();
return View(movies);
}
}
}
代码清单2中的HomeController 类包含了两个构造函数。第一个构造函数是一个无参构造函数,它创建了一个DataContextRepository 类的新实例,并且将它传递给第二个构造函数。第二个构造函数将DataContextRepository 赋值给一个类的域(field)。第一个构造函数是在Movie 应用程序实际运行时被调用的。第二个构造函数是被单元测试调用的。(这也是依赖注入Dependency Injection的一个例子)。
注意,HomeController 类除了在第一个无参构造函数中之外,从不引用DataContextRepository 类。取而代之的是,该类使用IDataContextRepository 接口。使用接口来代替具体类是很重要的,因为它能够让我们将真实的DataContext替换为伪造的DataContext。
Index()方法传递DataContextRepository 作为它的模型。Repository 代表了Movie 数据记录。代码清单3中的Index view简单地循环Repository 并且呈现movie 标题在一个无序列表中。
Listing 3 – Index.aspx (C#)
<%
@ Page Language
=
"
C#
"
AutoEventWireup
=
"
true
"
CodeBehind
=
"
Index.aspx.cs
"
Inherits
=
"
Tip21.Views.Home.Index
"
%>
<%
@ Import Namespace
=
"
Tip21.Models
"
%>
<!
DOCTYPE html PUBLIC
"
-//W3C//DTD XHTML 1.0 Transitional//EN
"
"
http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd
"
>
<
html xmlns
=
"
http://www.w3.org/1999/xhtml
"
>
<
head runat
=
"
server
"
>
<
title
></
title
>
</
head
>
<
body
>
<
div
>
<
ul
>
<%
foreach
(Movie movie
in
ViewData.Model)
{ %>
<li>
<%= Html.ActionLink("Edit", "Edit", new {id=movie.Id}) %>
<a href="/Home/Delete/<%= movie.Id %>" onclick="return confirm('Delete <%=movie.Title %>?')">Delete</a>
<%= movie.Title %>
</li>
<% }
%>
</
ul
>
<%=
Html.ActionLink(
"
Add Movie
"
,
"
Create
"
)
%>
</
div
>
</
body
>
</
html
>
这是一个非常简单的过程。我们在FakeDataContextRepository的帮助下为Index()方法创建单元测试。接着,我们用真实的DataContextRepository 满足单元测试。在这里我们做什么非法的事情了吗?TDD警察会把我们抓进监狱吗?
只要FakeDataContextRepository 的行为与DataContextRepository 一样,我们就没事。FakeDataContextRepository 给我们提供了一个快速对数据访问代码进行单元测试的方法。
让我们看几个单元测试数据访问代码的案例。代码清单4中,我修改了HomeControllerTest 类,以便它包含对Index(), Insert(), Update(),和 Delete()方法的测试。
Listing 4 – HomeControllerTest.cs (C#)
using
System.Collections.Specialized;
using
System.Web.Mvc;
using
Microsoft.VisualStudio.TestTools.UnitTesting;
using
MvcData;
using
Tip21.Controllers;
using
Tip21.Models;
using
System.Collections.Generic;
namespace
Tip21Tests.Controllers
{
[TestClass]
public class HomeControllerTest
{
private Movie CreateTestMovie(string title, string director)
{
Movie newMovie = new Movie();
newMovie.Title = title;
newMovie.Director = director;
return newMovie;
}
[TestMethod]
public void Index()
{
// Setup
var fakeRepository = new FakeDataContextRepository<Movie>();
fakeRepository.Insert(CreateTestMovie("Star Wars", "Lucas"));
fakeRepository.Insert(CreateTestMovie("Batman", "Burton"));
HomeController controller = new HomeController(fakeRepository);
// Execute
ViewResult result = controller.Index() as ViewResult;
// Verify
var movies = (IList<Movie>)result.ViewData.Model;
Assert.AreEqual(2, movies.Count );
Assert.AreEqual("Star Wars", movies[0].Title);
Assert.AreEqual("Batman", movies[1].Title);
}
[TestMethod]
public void Insert()
{
// Setup
var fakeRepository = new FakeDataContextRepository<Movie>();
HomeController controller = new HomeController(fakeRepository);
NameValueCollection formParams = new NameValueCollection();
formParams.Add("title", "Star Wars");
formParams.Add("Director", "Lucas");
// Execute
controller.Insert(formParams);
// Verify
var movies = fakeRepository.ListAll();
Assert.AreEqual(1, movies.Count);
Assert.AreEqual("Star Wars", movies[0].Title);
}
[TestMethod]
public void Update()
{
// Setup
var fakeRepository = new FakeDataContextRepository<Movie>();
Movie newMovie = fakeRepository.Insert(CreateTestMovie("Star Wars", "Lucas"));
HomeController controller = new HomeController(fakeRepository);
NameValueCollection formParams = new NameValueCollection();
formParams.Add("id", newMovie.Id.ToString());
formParams.Add("title", "Star Wars Changed");
formParams.Add("Director", "Lucas Changed");
// Execute
controller.Update(formParams);
// Verify
var movies = fakeRepository.ListAll();
Assert.AreEqual(1, movies.Count);
Assert.AreEqual("Star Wars Changed", movies[0].Title);
}
[TestMethod]
public void Delete()
{
// Setup
var fakeRepository = new FakeDataContextRepository<Movie>();
Movie movieToDelete = fakeRepository.Insert(CreateTestMovie("Star Wars", "Lucas"));
HomeController controller = new HomeController(fakeRepository);
NameValueCollection formParams = new NameValueCollection();
formParams.Add("id", movieToDelete.Id.ToString());
// Execute
controller.Delete(movieToDelete.Id);
// Verify
var movies = fakeRepository.ListAll();
Assert.AreEqual(0, movies.Count);
}
}
}
这里说明了Insert()测试是如何工作的。首先,FakeDataContextRepository 被传递给HomeController。其次,从HTML 表单中传递过来的表单域(form fields) 用一个键值对集合(NameValueCollection )来伪造,并且被传递给HomeController.Insert()方法。最后,FakeDataContextRepository 被用来验证新记录是否被成功地插入数据库中。
HomeController 的完整代码包含在代码清单5中。
Listing 5 – HomeController.cs (C#)
using
System;
using
System.Collections.Generic;
using
System.Linq;
using
System.Web;
using
System.Web.Mvc;
using
MvcData;
using
Tip21.Models;
using
System.Collections.Specialized;
namespace
Tip21.Controllers
{
[HandleError]
public class HomeController : Controller
{
private IDataContextRepository<Movie> _repository;
public HomeController() : this(new DataContextRepository<Movie>(new MovieDataContext())) { }
public HomeController(IDataContextRepository<Movie> repository)
{
_repository = repository;
}
public ActionResult Index()
{
IList<Movie> movies = _repository.ListAll();
return View(movies);
}
public ActionResult Create()
{
return View();
}
public ActionResult Insert(NameValueCollection formParams)
{
_repository.Insert(formParams);
return RedirectToAction("Index");
}
public ActionResult Edit(int id)
{
return View(_repository.Get(id));
}
public ActionResult Update(NameValueCollection formParams)
{
_repository.Update(formParams);
return RedirectToAction("Index");
}
public ActionResult Delete(int id)
{
_repository.Delete(id);
return RedirectToAction("Index");
}
}
}
有一个特殊的事情我要警告你。注意,Insert() 和 Update() actions 接收一个formParams 键值对集合,它代表了所有被提交给actions的HTML 表单域。为了让formParams 魔力参数工作,我利用了自定义的Action Invoker。请看我的ASP.NET MVC Tip #18这篇帖子:
http://www.cnblogs.com/mike108mvp/archive/2008/07/19/1246698.html
扩展 DataContext Repository
在某些情况下,你需要修改DataContextRepository 类,以便它支持更多的数据访问方法。例如,假设你想要创建一个GetFirstMovie()方法,它返回添加到数据库中的最后一条movie 记录。如果你发现你需要创建新的数据访问方法,那么我推荐你同时从DataContextRepository 和 FakeDataContextRepository 中继承,并且创建一个新的接口。
代码清单6中包含了三个新类。
Listing 6 – MovieRepository.cs (C#)
using
System.Data.Linq;
using
System.Linq;
using
MvcData;
namespace
Tip21.Models
{
public interface IMovieRepository : IDataContextRepository<Movie>
{
Movie GetFirstMovie();
}
public class MovieRepository : DataContextRepository<Movie>, IMovieRepository
{
public Movie GetFirstMovie()
{
return this.Table.First();
}
public MovieRepository(string connectionString ) : base(connectionString) {}
public MovieRepository(DataContext dataContext ) : base(dataContext) {}
}
public class FakeMovieRepository : FakeDataContextRepository<Movie>, IMovieRepository
{
public Movie GetFirstMovie()
{
return this.FakeTable.Values.First();
}
}
}
代码清单7中的代码演示了如何用一个新的MovieController来使用MovieRepository。
Listing 7 – MovieController.cs (C#)
using
System;
using
System.Collections.Generic;
using
System.Linq;
using
System.Web;
using
System.Web.Mvc;
using
Tip21.Models;
namespace
Tip21.Controllers
{
public class MovieController : Controller
{
private IMovieRepository _repository;
public MovieController() : this(new MovieRepository(new MovieDataContext())) { }
public MovieController(IMovieRepository repository)
{
_repository = repository;
}
public ActionResult Index()
{
var firstMovie = _repository.GetFirstMovie();
return View(firstMovie);
}
}
}
最后,代码清单8中的MovieControllerTest 演示了如何使用FakeMovieRepository 来测试MovieController 类。
Listing 8 – MovieControllerTest.cs (C#)
using
System.Collections.Specialized;
using
System.Web.Mvc;
using
Microsoft.VisualStudio.TestTools.UnitTesting;
using
MvcData;
using
Tip21.Controllers;
using
Tip21.Models;
namespace
Tip21Tests.Controllers
{
[TestClass]
public class MovieControllerTest
{
private Movie CreateTestMovie(string title, string director)
{
Movie newMovie = new Movie();
newMovie.Title = title;
newMovie.Director = director;
return newMovie;
}
[TestMethod]
public void Index()
{
// Setup
var fakeRepository = new FakeMovieRepository();
fakeRepository.Insert(CreateTestMovie("Star Wars", "Lucas"));
fakeRepository.Insert(CreateTestMovie("Batman", "Burton"));
MovieController controller = new MovieController(fakeRepository);
// Execute
ViewResult result = controller.Index() as ViewResult;
// Verify
var model = (Movie)result.ViewData.Model;
Assert.AreEqual("Star Wars", model.Title);
}
}
}
代码清单8中的单元测试使用FakeMovieRepository 类来伪造一个有两条记录的repository。接着,MovieController.Index()方法被调用。如果Index()方法返回的movie 记录与fake repository中的第一条记录相一致,则测试成功。否则,你就知道MovieController.Index()方法有错误。
反思时间
你可能会问为一个伪造对象而不是一个实际对象创建单元测试是否真的有用。我们是否只是测试了伪造对象是否可用?我们是否避免测试真实对象?
这个问题的答案是“既是又不是”(yes and no)。MovieRepository.GetFirstMovie()方法确实没有被测试到。取而代之的是,只有FakeMovieRepository.GetFirstMovie()方法被测试到了,并且我们并不关心它是否正常工作。因此,伪造是一种避免测试的方式。
然而,目标是尽可能少地伪造。当你从单元测试中调用MovieController.Index() 方法时,我们测试了围绕FakeMovieRepository 类的所有逻辑。在我们的玩具MovieController 类中,确实没有任何业务逻辑要测试。但是,在一个真实的应用程序中,与models 交互的controllers常常包含异常复杂的业务逻辑。
如果你仔细地不将任何业务逻辑放在你的repository中,那么伪造一个repository是很有用的。因为你能通过伪造repository来测试所有与repository交互的程序逻辑。
这一点值得更加强调:不要把你的业务逻辑放在你的repository中。如果你这么做,那么你将会对单元测试隐藏这些业务逻辑。
如果你真的想要测试MovieRepository类,而不是伪造的版本,那么你应该在一个整合测试中测试MovieRepository类。你不用每次修改都进行整合测试。取而代之的是,你可能只需要每天运行一到两次整合测试。
总结
在这篇帖子中,我探讨了在测试驱动开发中对数据访问代码进行单元测试的方式。我演示了如何创建一个可以在你的单元测试中使用的FakeDataContextRepository 类。我也展示了如何扩展基类的DataContextRepository 和 FakeDataContextRepository 来创建一个新的数据访问方法。
在这篇帖子以及以前的帖子中,我提供了两种可选方法来单元测试数据访问代码。在ASP.NET MVC Tip #20的帖子中,我演示了如何自动生成一个测试数据库。在这篇帖子中,我演示了如何伪造一个数据库。
下载代码:http://weblogs.asp.net/blogs/stephenwalther/Downloads/Tip21/Tip21.zip