65.9K
CodeProject 正在变化。 阅读更多。
Home

在 ASP.NET MVC 中创建可单元测试的应用程序 - 初学者教程

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (27投票s)

2013年4月17日

CPOL

6分钟阅读

viewsIcon

122827

downloadIcon

3342

本文将讨论如何使用 ASP.NET MVC 创建可单元测试的应用程序。

介绍 

在本文中,我们将讨论如何使用 ASP.NET MVC 创建可单元测试的应用程序。我们还将简要讨论测试驱动开发,并探讨如何以易于测试的方式设计和开发 ASP.NET MVC 应用程序。

背景

测试驱动开发(TDD)日益受到关注。原因在于,通过 TDD,我们不仅可以构建健壮的应用程序,还可以通过对模块进行成功的单元测试用例来证明应用程序在功能上运行正确。此外,遵循敏捷/Scrum 方法论的项目发现 TDD 非常有用,因为 Scrum 中的主要挑战是定义何时“完成”才是完成。如果遵循 TDD 方法,当所有测试用例都通过时,我们就知道工作已完成。

ASP.NET MVC 应用程序对 TDD 提供了非常好的支持,因为我们可以直接针对控制器编写单元测试,而无需任何 Web 服务器。这样做可以确保控制器在功能上正常运行。

在本文的其余部分,我们将讨论如何设计和开发从单元测试角度易于测试的 MVC 应用程序。

使用代码

我们需要理解的第一件事是,Controller 类可以像普通类一样实例化。这与 WebForms 应用程序不同,在 WebForms 中,页面只能由 Web 服务器调用。能够实例化 Controller 类的这种可能性为编写直接测试控制器逻辑而无需任何 Web 服务器的测试用例打开了大门。

现在我们将创建一个简单的 MVC 应用程序,该应用程序将对表执行 CRUD 操作。我们将以这样一种方式设计这个应用程序:控制器可以通过虚拟数据进行完全测试,甚至无需接触实际数据库。

数据库

让我们创建一个包含一个表的非常简单的数据库,如下所示。我们将从 MVC 应用程序中对该表执行 CRUD 操作。


数据访问

对于数据访问,我们将使用实体框架。使用实体框架的好处是它将为我们生成所有数据访问逻辑,并且还将为我们可以在应用程序中用作 Models 的相应表生成实体。为我们的示例数据库生成的实体将如下所示。


仓储和工作单元

现在我们已经准备好 ObjectContext 和实体。我们可以直接在控制器中使用 ObjectContext 类来执行数据库操作。

public ActionResult Index()
{
    List<Book> books = null;
    using (SampleDatabaseEntities entities = new SampleDatabaseEntities())
    {
        books = entities.Books.ToList();
    }
    return View(books);
}

但是如果这样做,我们的控制器将无法测试。原因是当我们实例化控制器类并调用索引函数时,Context 类将被创建并会访问实际数据库。

现在我们如何解决这个问题呢?这个问题可以通过实现 仓储和工作单元 (Repository and Unit of Work) 模式来解决。如果只有一个包含 ObjectContext 的仓储类,那么我们不需要工作单元;但如果有多个仓储类,那么我们也需要工作单元类。

注意:强烈建议在阅读本文之前阅读以下文章。理解仓储和工作单元模式对于创建可测试的 MVC 应用程序至关重要。请阅读此详细信息:理解和实现 ASP.NET MVC 应用程序中的仓储和工作单元模式[^]

让我们创建一个简单的接口,定义访问书籍数据的契约。然后我们将在我们的仓储类中实现这个接口。这样做的好处是我们可以有一个实现相同接口但使用虚拟数据的另一个类。现在,只要控制器使用接口,我们的测试项目就可以传递虚拟数据类。我们将从测试项目中创建并向控制器传递虚拟类。

public interface IBooksRepository
{
    List<Book> GetAllBooks();
    Book GetBookById(int id);
    void AddBook(Book book);
    void UpdateBook(Book book);
    void DeleteBook(Book book);
    void Save();
}

而执行实际数据库操作的具体仓储类将如下所示

public class BooksRepository : IBooksRepository
{
    SampleDatabaseEntities entities = null;        

    public BooksRepository(SampleDatabaseEntities entities)
    {
        this.entities = entities;
    }

    public List<Book> GetAllBooks()
    {
        return entities.Books.ToList();
    }

    public Book GetBookById(int id)
    {
        return entities.Books.SingleOrDefault(book => book.ID == id);
    }

    public void AddBook(Book book)
    {
        entities.Books.AddObject(book);
    }

    public void UpdateBook(Book book)
    {
        entities.Books.Attach(book);
        entities.ObjectStateManager.ChangeObjectState(book, EntityState.Modified);
    }

    public void DeleteBook(Book book)
    {
        entities.Books.DeleteObject(book);
    }

    public void Save()
    {
        entities.SaveChanges();
    }
}

现在,UnitOfWork 类的职责是创建 ObjectContextRepository 类并将它们传递给控制器。

public class UnitOfWork
{
    private SampleDatabaseEntities entities = null;

    // This will be called from controller default constructor
    public UnitOfWork()           
    {
        entities = new SampleDatabaseEntities();
        BooksRepository = new BooksRepository(entities);
    }

    // This will be created from test project and passed on to the
    // controllers parameterized constructors
    public UnitOfWork(IBooksRepository booksRepo)
    {
        BooksRepository = booksRepo;
    }

    public IBooksRepository BooksRepository
    {
        get;
        private set;
    }
}

然后,我们的控制器可以使用此 UnitofWork 类执行数据库操作,如下所示

public class BooksController : Controller
{
    private UnitOfWork unitOfWork = null;

    public BooksController()
        : this(new UnitOfWork())
    {

    }

    public BooksController(UnitOfWork uow)
    {
        this.unitOfWork = uow;
    }

    public ActionResult Index()
    {
        List<Book> books = unitOfWork.BooksRepository.GetAllBooks();
        return View(books);
    }

    public ActionResult Details(int id)
    {
        Book book = unitOfWork.BooksRepository.GetBookById(id);

        return View(book);
    }

    public ActionResult Create()
    {
        return View();
    }

    [HttpPost]
    public ActionResult Create(Book book)
    {
        if (ModelState.IsValid)
        {
            unitOfWork.BooksRepository.AddBook(book);
            unitOfWork.BooksRepository.Save();
            return RedirectToAction("Index");
        }

        return View();
    }

    public ActionResult Edit(int id)
    {
        Book book = unitOfWork.BooksRepository.GetBookById(id);

        return View(book);
    }

    [HttpPost]
    public ActionResult Edit(Book book)
    {
        if (ModelState.IsValid)
        {
            unitOfWork.BooksRepository.UpdateBook(book);
            unitOfWork.BooksRepository.Save();
            return RedirectToAction("Index");
        }
        
        return View();
    }

    public ActionResult Delete(int id)
    {
        Book book = unitOfWork.BooksRepository.GetBookById(id);
        unitOfWork.BooksRepository.DeleteBook(book);
        return View(book);
    }

    [HttpPost]
    public ActionResult Delete(int id, FormCollection formCollection)
    {
        Book book = unitOfWork.BooksRepository.GetBookById(id);
        unitOfWork.BooksRepository.DeleteBook(book);
        unitOfWork.BooksRepository.Save();
        return View("Deleted");
    }

    public ActionResult Deleted()
    {
        return View();
    }
}

所以从我们应用程序的设计来看,它看起来像


让我们尝试运行应用程序,看看它是否正常工作。


依赖注入 - 理解事物如何运作

在继续之前,让我们详细看看控制器中发生了什么。

  1. UrlRoutingModule 将解析 URL 并调用控制器。
  2. 将调用 Controller 类的默认构造函数。
  3. 此构造函数将使用其默认构造函数创建一个 UnitOfWork 类。
  4. UnitOfWork 的默认构造函数中,BooksRepository 类将被实例化。
  5. UnitOfWork 类将指向真实的 BooksRepository 实现。
  6. 控制器的所有操作将使用真实的 BooksRepository 类执行实际的 CRUD 操作。

我们在控制器中所做的就是让我们的控制器使用 UnitOfWork 类。UnitOfWork 类包含一个接口句柄。因此,我们可以让它使用 BooksRepository 类或任何其他实现相同接口的类。我们正在使用 UnitOfWork 类的构造函数进行依赖注入。

现在,如果我们需要在不接触数据库的情况下测试应用程序,我们可以创建一个也实现了 IBooksRepository 接口的虚拟类,并将这个虚拟类与我们的 UnitOfWork 一起使用。这将有效地让我们的控制器与虚拟类一起工作。

创建测试项目

现在,从我们的测试项目中,我们需要执行以下活动。

  1. 创建一个虚拟类 DummyBooksRepository,它将实现 IBooksRepository
  2. 为控制器所有函数创建测试函数。
  3. 实例化我们的虚拟仓储,即 DummyBooksRepository
  4. 通过调用接受 IBooksRepository 类型参数的构造函数来创建 Unitofwork。我们将在此构造函数中传入我们的虚拟仓储,即 DummyBooksRepository
  5. UnitOfWork 类现在将指向 DummyBooksRepository 实现。
  6. 通过调用接受 UnitOfWork 参数的构造函数并传入我们在上一步中创建的对象来创建控制器。这将有效地使控制器使用 DummyBooksRepository

我们不要尝试遵循上述步骤并尝试创建我们的单元测试项目。首先,让我们创建实现 IBooksRepository 接口的虚拟仓储类。

class DummyBooksRepository : IBooksRepository
{
    // Master list of books that will mimic the persitent database storage
    List<Book> m_books = null;

    public DummyBooksRepository(List<Book> books)
    {
        m_books = books;           
    }

    public List<Book> GetAllBooks()
    {
        return m_books;
    }

    public Book GetBookById(int id)
    {
        return m_books.SingleOrDefault(book => book.ID == id);
    }

    public void AddBook(Book book)
    {
        m_books.Add(book);
    }

    public void UpdateBook(Book book)
    {
        int id = book.ID;
        Book bookToUpdate = m_books.SingleOrDefault(b => b.ID == id);
        DeleteBook(bookToUpdate);
        m_books.Add(book);
    }

    public void DeleteBook(Book book)
    {
        m_books.Remove(book);
    }

    public void Save()
    {
        // Nothing to do here
    }
}

最终的设计将如下所示: 


现在,从我们的测试类中,我们将实例化这个虚拟仓储类,并创建一个将使用这个类的工作单元。然后,我们将实例化我们需要测试的控制器,将这个 UnitOfWork 类对象传入其中。

[TestClass]
public class BooksControllerTest
{
    Book book1 = null;
    Book book2 = null;
    Book book3 = null;
    Book book4 = null;
    Book book5 = null;

    List<Book> books = null;
    DummyBooksRepository booksRepo = null;
    UnitOfWork uow = null;
    BooksController controller = null;

    public BooksControllerTest()
    {
        // Lets create some sample books
        book1 = new Book { ID = 1, BookName = "test1", AuthorName = "test1", ISBN = "NA" };
        book2 = new Book { ID = 2, BookName = "test2", AuthorName = "test2", ISBN = "NA" };
        book3 = new Book { ID = 3, BookName = "test3", AuthorName = "test3", ISBN = "NA" };
        book4 = new Book { ID = 4, BookName = "test4", AuthorName = "test4", ISBN = "NA" };
        book5 = new Book { ID = 5, BookName = "test5", AuthorName = "test5", ISBN = "NA" };

        books = new List<Book>
        {
            book1,
            book2,
            book3,
            book4
        };


        // Lets create our dummy repository
        booksRepo = new DummyBooksRepository(books);

        // Let us now create the Unit of work with our dummy repository
        uow = new UnitOfWork(booksRepo);

        // Now lets create the BooksController object to test and pass our unit of work
        controller = new BooksController(uow);
    }
}

现在下一步是为控制器类的所有动作编写单元测试。由于控制器正在使用虚拟存储库,因此从功能角度来看,控制器类将表现相同,但将操作我们的虚拟存储库类而不是实际存储库类。

为了测试控制器的检索方法,让我们向类中添加以下函数。

[TestMethod]
public void Index()
{
    // Lets call the action method now
    ViewResult result = controller.Index() as ViewResult;

    // Now lets evrify whether the result contains our book entries or not
    var model = (List<Book>)result.ViewData.Model;

    CollectionAssert.Contains(model, book1);
    CollectionAssert.Contains(model, book2);
    CollectionAssert.Contains(model, book3);
    CollectionAssert.Contains(model, book4);

    // Uncomment the below line and the test will start failing
    // CollectionAssert.Contains(model, book5);
}

[TestMethod]
public void Details()
{
    // Lets call the action method now
    ViewResult result = controller.Details(1) as ViewResult;

    // Now lets evrify whether the result contains our book
    Assert.AreEqual(result.Model, book1);
}

现在让我们添加函数来测试创建、更新和删除功能。

[TestMethod]
public void Create()
{   
    // Lets create a valid book objct to add into
    Book newBook = new Book { ID = 7, BookName = "new", AuthorName = "new", ISBN = "NA" };

    // Lets call the action method now
    controller.Create(newBook);

    // get the list of books
    List<Book> books = booksRepo.GetAllBooks();

    CollectionAssert.Contains(books, newBook);
}

[TestMethod]
public void Edit()
{
    // Lets create a valid book objct to add into
    Book editedBook = new Book { ID = 1, BookName = "new", AuthorName = "new", ISBN = "NA" };

    // Lets call the action method now
    controller.Edit(editedBook);

    // get the list of books
    List<Book> books = booksRepo.GetAllBooks();

    CollectionAssert.Contains(books, editedBook);
}

[TestMethod]
public void Delete()
{
    // Lets call the action method now
    controller.Delete(1);

    // get the list of books
    List<Book> books = booksRepo.GetAllBooks();

    CollectionAssert.DoesNotContain(books, book1);
}

现在让我们运行单元测试并查看结果。


注意:以上代码片段仅显示了有效操作。我们还应该包含执行无效操作的测试用例。尝试在控制器类中引入一些问题,看看测试将如何失败。

关注点

在本文中,我们探讨了如何利用存储库和工作单元模式的强大功能来创建可单元测试的 ASP.NET MVC 应用程序。我们还看到,测试 MVC 应用程序不需要任何 Web 服务器,它们只需通过从测试项目中实例化控制器类即可进行测试。本文是从初学者角度撰写的。希望对您有所帮助。

历史

  • 2013 年 4 月 17 日:第一个版本。
© . All rights reserved.