Тестирование и подход 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 с юнит-тестами

  1. В Visual Studio в меню File нажмите New Project.
  2. В New Project под Installed Templates откройте Visual C# или Visual Basic и нажмите Web
  3. Выберите шаблон ASP.NET MVC Web Application
  4. Назовите решение MvcContacts
  5. Нажмите OK
  6. В окне Create Unit Test Project убедитесь, что Yes, create a unit test project выбрано и нажмите OK.
  7. Visual Studio создаст решение с двумя проектами - MvcContacts и MvcContacts.Tests.
  8. В меню Test нажмите Run и All Tests in Solution.
  9. Результаты будут отображаться в окне TestResults . Тесты должны пройти.
  10. В проекте 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)

Создание модели базы данных
  1. В Solution Explorer нажмите правой кнопкой мыши на папке App_Data в проекте MvcContacts, затем Add и Existing Item. Появится диалог Add Existing Item.
  2. Перейдите в папку с Contact.mdf, выделите этот файл и нажмите Add.
  3. В Solution Explorer нажмите правой кнопкой мыши на проекте MvcContacts, нажмите Add и New Item. Появится диалог Add New Item.
  4. В Installed Templates раскройте вкладку Visual C#, выберите Data и ADO.NET Entity Data Model template.
  5. В Name введите ContactModel и нажмите Add. Появится диалог Entity Data Model Wizard.
  6. В What should the model contain выберите Generate from database и Next.
  7. В пункте Which data connection should your application use to connect to the database? Выберите Contact.mdf
  8. Убедитесь в том, что пункт Save entity connection settings in Web.config as отмечен. Для строки подключения можно оставить значение по умолчанию.
  9. Next.
  10. Появится страница, где вы можете указать объекты базы данных, которые будут включены в модель.
  11. Выберите Tables и таблицу Contacts. Model namespace можно оставить со значением по умолчанию.
  12. 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. Благодарим за помощь в переводе Александра Белоцерковского.