Тестирование и подход Test-Driven Development в ASP.NET MVC 3
В данном руководстве показано, как разработать ASP.NET MVC приложения в Visual Studio используя подход test-driven development (TDD). MVC было создано для включения возможности тестирования без дополнительных зависимостей в лице веб-сервера (IIS), базы данных или внешних классов (в отличие от юнит-тестов для Web Forms, которым требовался веб-сервер).
Мы создадим тесты для контроллера MVC перед реализацией его функциональности. Акцент поставлен на том, как проектировать «внутренности» контроллера с помощью юнит-тестов перед непосредственно реализацией, что является важным аспектом философии TDD (хорошее описание философии MVC TDD см. It’s Not TDD, It’s Design By Example в блоге Brad Wilson)
Вы создадите проект и юнит-тесты для приложения, которое можно использовать для отображения и редактирования контактов. Контакт содержит имя, фамилию, телефонный номер и e-mail.
Готовый проект лежит здесь: Download и включает в себя финальные версии проектов на C# и VB. В первой части учебника показано, как создавать MVC-проект в Visual Studio, как добавлять модель данных и метаданные к данной модели.
Создание нового приложения MVC с юнит-тестами
Для создания приложения MVC с юнит-тестами
- В Visual Studio в меню File нажмите New Project.
- В New Project под Installed Templates откройте Visual C# или Visual Basic и нажмите Web
- Выберите шаблон ASP.NET MVC Web Application
- Назовите решение MvcContacts
- Нажмите OK
- В окне Create Unit Test Project убедитесь, что Yes, create a unit test project выбрано и нажмите OK.
- Visual Studio создаст решение с двумя проектами - MvcContacts и MvcContacts.Tests.
- В меню Test нажмите Run и All Tests in Solution.
- Результаты будут отображаться в окне TestResults . Тесты должны пройти.
- В проекте MvcContacts.Tests откройте файлы контроллера аккаунта (MvcContacts\MvcContacts.Tests\Controllers\AccountControllerTest) и (MvcContacts\Models\AccountModels).
Эти классы являются хорошим образцом создания интерфейсов-болванок и TDD. Создание болванок является процессом создания простых объектов для зависимостей класса. Таким образом, вы можете протестировать работу класса без зависимостей. Для тестирования интерфейсов обычно создаётся класс-болванка, реализующий этот интерфейс, допустим MockMembershipService в контроллере аккаунта реализует интерфейс ImembershipService , имея свойства-«болванки», являющиеся частями классов-составляющих, такие как theValidateUser, CreateUser, ChangePassword.
Класс MockMembershipService позволяет вам тестировать методы, с помощью которых создаются пользовательские аккаунты, происходит валидация регистрационных данных пользователя и изменяется пользовательский пароль. И всё это без создания экземпляра класса типа Membership.
Создание модели базы данных
Ниже используется Entity Data Model (EDM), которая создаётся с помощью базы данных Contact, которую можно скачать по ссылке. (вы должны скачать проект для того, чтобы получить файл Contact.mdf, для подробной информации см. раздел Prerequisites)
Создание модели базы данных
- В Solution Explorer нажмите правой кнопкой мыши на папке App_Data в проекте MvcContacts, затем Add и Existing Item. Появится диалог Add Existing Item.
- Перейдите в папку с Contact.mdf, выделите этот файл и нажмите Add.
- В Solution Explorer нажмите правой кнопкой мыши на проекте MvcContacts, нажмите Add и New Item. Появится диалог Add New Item.
- В Installed Templates раскройте вкладку Visual C#, выберите Data и ADO.NET Entity Data Model template.
- В Name введите ContactModel и нажмите Add. Появится диалог Entity Data Model Wizard.
- В What should the model contain выберите Generate from database и Next.
- В пункте Which data connection should your application use to connect to the database? Выберите Contact.mdf
- Убедитесь в том, что пункт Save entity connection settings in Web.config as отмечен. Для строки подключения можно оставить значение по умолчанию.
- Next.
- Появится страница, где вы можете указать объекты базы данных, которые будут включены в модель.
- Выберите Tables и таблицу Contacts. Model namespace можно оставить со значением по умолчанию.
- Finish.
Закройте появившийся ADO.NET Entity Data Model Designer.
Добавление метаданных к модели
В этом разделе вы добавите метаданные контактов. Класс метаданных контактов не будет использован в юнит-тестах, однако для полноты примера мы реализуем его, потому что он предоставляет автоматическую валидацию данных на клиентской и серверной стороне.
Добавление метаданных к модели
В папке MvcContacts\Models создайте файл класса с именем ContactMD. В этот файл вы добавите класс ContactMD с метаданными для сущности Contact, которая является частью модели данных, используемой в этом руководстве.
Замените код в файле на:
using System.ComponentModel.DataAnnotations;
namespace MvcContacts.Models {
[MetadataType(typeof(ContactMD))]
public partial class Contact {
public class ContactMD {
[ScaffoldColumn(false)]
public object Id { get; set; }
[Required()]
public object FirstName { get; set; }
[Required()]
public object LastName { get; set; }
[RegularExpression(@"^\d{3}-?\d{3}-?\d{4}$")]
public object Phone { get; set; }
[Required()]
[DataType(DataType.EmailAddress)]
[RegularExpression(@"^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*@([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9})$")]
public object Email { get; set; }
}
}
}
Добавление репозитория
Хорошей практикой в MVC является невключение кода доступа к данным или EDM в код контроллера. Вместо этого можно использовать паттерн «репозиторий». Репозиторий располагается между приложением и хранилищем данных и отделяет бизнес-логику от взаимодействия с базой данных и концентрирует всю работу с данными в одном месте.
Репозиторий возвращает объекты из модели данных. Для простых моделей возвращаемые из EDM, LIQN to SQL определяются как доменные объекты.
Для более сложных приложений возможно появление необходимости в слое маппинга. Слой маппинга необязательно является неэффективным решением – провайдеры LINQ могут создавать эффективные запросы к хранилищу данных (обращаясь к базе данных с использованием минимального количества промежуточных объектов).
Репозиторий не подразумевает знания EDM, LINQ to SQL или любых других моделей данных, с которыми вы работаете (тема LINQ не затрагивается в данном руководстве, использование LINQ как слоя абстракции доступа к данным означает, что вы скрываете механизм хранилища данных. Так, это позволяет использовать SQL Server для production и LINQ to Objects для тестирования коллекций, находящихся в памяти.
Тестирование методов в контроллере, которые напрямую используют EDM, подразумевает подключение к базе данных, так как методы зависят в этом случае от EDM (который зависит от базы данных). Следующий код демонстрирует MVC-контроллер, использующий сущность Contact из EDM, и простой пример смешивания обращений к базе данных в методах, что делает тестирование данных методов более сложным. Например, юнит-тесты, редактирующие или удаляющие данные, изменяют состояние базы данных. Для этого необходимо при каждом тестировании создавать новую конфигурацию базы данных и, кроме этого, обращения к базе данных весьма тяжеловесны, тогда как юнит-тесты должны занимать минимум ресурсов, чтобы в процессе разработки их можно было часто запускать.
public class NotTDDController : Controller {
ContactEntities _db = new ContactEntities();
public ActionResult Index() {
var dn = _db.Contacts;
return View(dn);
}
public ActionResult Edit(int id) {
Contact prd = _db.Contacts.FirstOrDefault(d => d.Id == id);
return View(prd);
}
[HttpPost]
public ActionResult Edit(int id, FormCollection collection) {
Contact prd = _db.Contacts.FirstOrDefault(d => d.Id == id);
UpdateModel(prd);
_db.SaveChanges();
return RedirectToAction("Index");
}
}
Паттерн «репозиторий» имеет следующие преимущества:
- Он предоставляет точку работы для юнит-тестов, вы можете легко тестировать бизнес-логику без базы данных и других зависимостей.
- Дублирующиеся запросы могут быть перенесены в репозиторий.
- Методы контроллера могут иметь строго-типизированные параметры, что означает, что компилятор находит ошибки с типизацией на стадии компиляции вместо того, чтобы находить соответствующие ошибки во время тестирования приложения.
- Доступ к данным централизован, что обеспечивает следующие преимущества:
- Более совершенное Separation Of Concerns (SoC), another tenet of MVC, which increases maintainability and readability.
- Упрощенную реализацию централизованного кэширования данных.
- Более гибкую и менее связанную архитектуру, которую можно распространять на весь дизайн приложения при его развитии.
- Поведение может быть ассоциировано с соответствующими данными, например, вы можете вычислять поля или создавать сложные связи или бизнес-логику между элементами внутри сущности
- Доменная модель может быть применена для упрощения сложной бизнес-логики.
Используя данный паттерн с MVC и TDD обычно вынуждает вас создавать интерфейс для класса доступа к данным. Паттерн «репозиторий» упрощает этот процесс до «вставки» репозитория-болванки при юнит-тестировании методов контроллера.
В этом разделе вы создадите репозиторий контакта, являющийся классом, который будет использоваться для сохранения контактов в базу данных, и интерфейс для этого репозитория.
Как добавить репозиторий
В папке MvcContacts\Models создайте файл класса и добавьте класс с именем IcontactRepository, который будет содержать интерфейс для объекта репозитория.
Замените код в классе на:
using System;
using System.Collections.Generic;
namespace MvcContacts.Models {
public interface IContactRepository {
void CreateNewContact(Contact contactToCreate);
void DeleteContact(int id);
Contact GetContactByID(int id);
IEnumerable<Contact> GetAllContacts();
int SaveChanges();
}
}
В папке MvcContacts\Models создайте новый класс EntityContactManagerRepository, который будет реализовывать интерфейс IcontactRepository.
Замените код в классе на:
using System.Collections.Generic;
using System.Linq;
namespace MvcContacts.Models {
public class EF_ContactRepository : MvcContacts.Models.IContactRepository {
private ContactEntities _db = new ContactEntities();
public Contact GetContactByID(int id) {
return _db.Contacts.FirstOrDefault(d => d.Id == id);
}
public IEnumerable<Contact> GetAllContacts() {
return _db.Contacts.ToList();
}
public void CreateNewContact(Contact contactToCreate) {
_db.AddToContacts(contactToCreate);
_db.SaveChanges();
// return contactToCreate;
}
public int SaveChanges() {
return _db.SaveChanges();
}
public void DeleteContact(int id) {
var conToDel = GetContactByID(id);
_db.Contacts.DeleteObject(conToDel);
_db.SaveChanges();
}
}
}
Создание тестов для TDD
В этом разделе вы создадите реализацию-болванку репозитория, добавите юнит-тесты и реализуете функциональность приложения в юнит-тестах.
Реализация репозитория-«болванки»
В проекте MvcContacts.Tests создайте папку Models. В папке MvcContacts.Tests\Models создайте новый класс MocContactRepository, который будет реализовывать интерфейс IcontactRepository и будет иметь простой репозиторий для приложения.
Замените код класса на:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using MvcContacts.Models;
namespace MvcContacts.Tests.Models {
class InMemoryContactRepository : MvcContacts.Models.IContactRepository {
private List<Contact> _db = new List<Contact>();
public Exception ExceptionToThrow { get; set; }
//public List<Contact> Items { get; set; }
public void SaveChanges(Contact contactToUpdate) {
foreach (Contact contact in _db) {
if (contact.Id == contactToUpdate.Id) {
_db.Remove(contact);
_db.Add(contactToUpdate);
break;
}
}
}
public void Add(Contact contactToAdd) {
_db.Add(contactToAdd);
}
public Contact GetContactByID(int id) {
return _db.FirstOrDefault(d => d.Id == id);
}
public void CreateNewContact(Contact contactToCreate) {
if (ExceptionToThrow != null)
throw ExceptionToThrow;
_db.Add(contactToCreate);
// return contactToCreate;
}
public int SaveChanges() {
return 1;
}
public IEnumerable<Contact> GetAllContacts() {
return _db.ToList();
}
public void DeleteContact(int id) {
_db.Remove(GetContactByID(id));
}
}
}
Для добавления поддержки тестов
В проекте MvcContacts откройте файле Controllers\HomeController.cs и змените код на:
using System;
using System.Web.Mvc;
using MvcContacts.Models;
namespace MvcContacts.Controllers {
[HandleError]
public class HomeController : Controller {
IContactRepository _repository;
public HomeController() : this(new EF_ContactRepository()) { }
public HomeController(IContactRepository repository) {
_repository = repository;
}
public ViewResult Index() {
throw new NotImplementedException();
}
}
}
Класс содержит два конструктора, один без параметров, второй принимает параметр типа IcontactRepository, и данный конструктор будет использован юнит-тестами для передачи в репозиторий-болванку. Конструктор без параметров просто создаёт экземпляр EF_ContactRepository и вызывается MVC при вызове метода контроллера.
В проекте MvcContacts.Test откройте Controllers\HomeControllerTest и замените код на:
using System.Web.Mvc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MvcContacts.Controllers;
using MvcContacts.Models;
using MvcContacts.Tests.Models;
using System.Web;
using System.Web.Routing;
using System.Security.Principal;
namespace MvcContacts.Tests.Controllers {
[TestClass]
public class HomeControllerTest {
Contact GetContact() {
return GetContact(1, "Janet", "Gates");
}
Contact GetContact(int id, string fName, string lName) {
return new Contact
{
Id = id,
FirstName = fName,
LastName = lName,
Phone = "710-555-0173",
Email = "janet1@adventure-works.com"
};
}
private static HomeController GetHomeController(IContactRepository repository) {
HomeController controller = new HomeController(repository);
controller.ControllerContext = new ControllerContext()
{
Controller = controller,
RequestContext = new RequestContext(new MockHttpContext(), new RouteData())
};
return controller;
}
private class MockHttpContext : HttpContextBase {
private readonly IPrincipal _user = new GenericPrincipal(
new GenericIdentity("someUser"), null /* roles */);
public override IPrincipal User {
get {
return _user;
}
set {
base.User = value;
}
}
}
}
}
Код содержит два перегруженных метода, которые принимают контакт (GetContact) и getHomeController, и класс-“болванку» для HttpContextObject.
Добавление тестов
Каждый тест в TDD должен иметь определенное указание в методе. Тест не должен определять работоспособность базы данных или других компонентов (которые должны тестироваться юнит-тестами для слоя доступа к данным и в интеграционных тестах). Имена тестов должны быть понятными – короткие имена в стиле Creat_Post_Test1 затруднят понимание сути тестов в случае, если их будут сотни.
В этой части руководства будут реализованы вызовы в контроллере Home, возвращающие список контактов. Метод контроллера, создающийся по умолчанию, называется Index, поэтому первый тест будет проверять работоспособность именно этого метода, как он возвращает соответствующее представление. Ранее мы вносили изменения в этот метод, изменив возвращаемый объект на ViewResult вместо привычного ActionResult. Когда вы знаете, что метод всегда возвращает ViewResult, вы можете упростить юнит-тесты, возвратив объект ViewResult из метода контроллера. Когда вы возвращаете объект ViewResult, юнит-тест не приводит привычный объект ActionResult к типу ViewResult.
Добавление первого теста
В классе HomeControllerTest добавьте юнит-тест Index_Get_AsksForIndexView, который будет проверять, возвращает ли метод Index представление Index.
[TestMethod]
public void Index_Get_AsksForIndexView() {
// Arrange
var controller = GetHomeController(new InMemoryContactRepository());
// Act
ViewResult result = controller.Index();
// Assert
Assert.AreEqual("Index", result.ViewName);
}
В меню Test нажмите Run и All Tests In Solution. Результаты отобразятся в окне Test Results, и, как и ожидалось, юнит-тест Index_Get_AsksForIndexView возвратит ошибку.
Измените код метода Index в HomeController для возвращения списка контактов:
public ViewResult Index() {
return View("Index", _repository.ListContacts());
}
В духе парадигмы TDD, вы пишите ровно столько кода, сколько требуется для теста.
Запустите тесты. В этот раз тест Index_Get_AsksForIndexView будет пройден.
Создание теста для возвращения контактов
В этом разделе вы проверите, что возвращаются все контакты. При этом желания создавать юнит-тесты для доступа к данным нет. Проверка приложения на доступ к базе данных и возвращения контактов важна, но это интеграционный тест, а не TDD.
Добавление теста для возвращения контактов
Создайте тест, добавляющий два контакт-«болванки» в репозиторий-«болванку» в классе HomeControllerTest и удостоверяющийся, что они помещены в объект ViewData.Model представления Index.
[TestMethod]
public void Index_Get_RetrievesAllContactsFromRepository() {
// Arrange
Contact contact1 = GetContactNamed(1, "Orlando", "Gee");
Contact contact2 = GetContactNamed(2, "Keith", "Harris");
InMemoryContactRepository repository = new InMemoryContactRepository();
repository.Add(contact1);
repository.Add(contact2);
var controller = GetHomeController(repository);
// Act
var result = controller.Index();
// Assert
var model = (IEnumerable<Contact>)result.ViewData.Model;
CollectionAssert.Contains(model.ToList(), contact1);
CollectionAssert.Contains(model.ToList(), contact1);
}
Создание теста для создания контакта
Теперь можно тестировать процесс создания нового контакта. Первый тест удостоверяет, что операция HTTP POST прошла успешно и был вызван метод Create. Новый контакт не будет добавлен, вместо этого возвратится представление HTTP GET Create, которое содержит введенную информацию и ошибки в модели. Запуск юнит-теста контроллера не вызывает реальные операции использования модели.
Добавление теста для создания контакта
Добавьте тест в проект:
[TestMethod]
public void Create_Post_ReturnsViewIfModelStateIsNotValid() {
// Arrange
HomeController controller = GetHomeController(new InMemoryContactRepository());
// Simply executing a method during a unit test does just that - executes a method, and no more.
// The MVC pipeline doesn't run, so binding and validation don't run.
controller.ModelState.AddModelError("", "mock error message");
Contact model = GetContactNamed(1, "", "");
// Act
var result = (ViewResult)controller.Create(model);
// Assert
Assert.AreEqual("Create", result.ViewName);
}
Добавьте тест:
[TestMethod]
public void Create_Post_PutsValidContactIntoRepository() {
// Arrange
InMemoryContactRepository repository = new InMemoryContactRepository();
HomeController controller = GetHomeController(repository);
Contact contact = GetContactID_1();
// Act
controller.Create(contact);
// Assert
IEnumerable<Contact> contacts = repository.GetAllContacts();
Assert.IsTrue(contacts.Contains(contact));
}
Код содержит проверку правильности добавления контакта в репозиторий с помощью метода HTTP POST Create.
Класс MocContactRepository позволяет установить исключение-«болванку», которое симулирует исключение, выбрасываемое базой данных при какой-либо ошибке. Много из исключений, связанных с базой данных, нельзя обработать в модели, поэтому важно, чтобы код обработки исключений работал корректно. Следующий код показывает, как это сделать правильно.
[TestMethod]
public void Create_Post_ReturnsViewIfRepositoryThrowsException() {
// Arrange
InMemoryContactRepository repository = new InMemoryContactRepository();
Exception exception = new Exception();
repository.ExceptionToThrow = exception;
HomeController controller = GetHomeController(repository);
Contact model = GetContactID_1();
// Act
var result = (ViewResult)controller.Create(model);
// Assert
Assert.AreEqual("Create", result.ViewName);
ModelState modelState = result.ViewData.ModelState[""];
Assert.IsNotNull(modelState);
Assert.IsTrue(modelState.Errors.Any());
Assert.AreEqual(exception, modelState.Errors[0].Exception);
}
Далее
Проект содержит больше тестов, нежели покрытых в данном руководстве. Чтобы узнать больше о том, как использовать объекты-«болванки» и методологию TDD с проектами MVC, обратите внимание на остальные тесты и напишите соответствующие тесты для методов Delete и Edit.
Благодарности
Это перевод оригинальной статьи Walkthrough: Using TDD with ASP.NET MVC. Благодарим за помощь в переводе Александра Белоцерковского.