Цель урока. Научиться создавать тесты для кода. NUnit. Принцип применения TDD. Mock. Юнит-тесты. Интегрированное тестирование. Генерация данных.
Тестирование, принцип TDD, юнит-тестирование и прочее.
Тестирование для меня лично – это тема многих размышлений. Нужны или не нужны тесты? Но никто не будет спорить, что для написания тестов нужны ресурсы.
Рассмотрим два случая:
Мы делаем сайт, показываем заказчику, он высылает список неточностей и дополнительных пожеланий, мы их бодро правим и сайт отдаем заказчику, т.е. выкладываем на его сервер. На его сервер никто не ходит, заказчик понимает, что чуда не произошло и перестает платить за хостинг/домен. Сайт умирает. Нужны ли там тесты?
Мы делаем сайт, показываем заказчику, он высылает список правок, мы их бодро правим, запускаем сайт. Через полгода на сайте 300 уников в день и эта цифра растет изо дня в день. Заказчик постоянно просит новые фичи, старый код начинает разрастаться, и со временем его всё сложнее поддерживать.
Видите ли, тут дилемма такова, что результат запуска сайта непредсказуемый. У меня было и так, что сделанный на коленке сайт запустился весьма бодро, а бывало, что крутую работу заказчик оплатил, но даже не принял. Итак, тактика поведения может быть такой:
Писать тесты всегда. Мы крутая компания, мы покрываем 90% кода всяческими тестами, и нам реально всё равно, что мы тратим на это в 100500 раз больше времени/денег, ведь результат полностью предсказуем и мы вообще красавцы.
Не писать тесты никогда. Мы крутая компания, мы настолько идеально работаем, что можем в уме пересобрать весь проект, и если наш код компилируется, это значит, что он полностью рабочий. Если что-то не работает, то вероятно это хостинг, или ошибка в браузере. или фича такая.
Писать тесты, но не всегда. Тут мы должны понять, что каким бы ни был сайт или проект, то он состоит из функционала. А это значит, что пользователям должны быть предоставлены всяческие возможности, и возможности важные, я бы даже сказал — критические, как-то зарегистрироваться на сайте, сделать заказ, добавить новость или комментарий. Неприятно, когда хочешь, а не можешь зарегистрироваться, ведь сайт-то нужный.
Для чего используются тесты? Это как принцип ведения двойной записи в бухгалтерии. Каждое действие, каждый функционал проверяется не только работоспособностью сайта, но и еще как минимум одним тестом. При изменении кода юнит-тесты указывают, что имнно пошло не так и красным подсвечивают места, где произошло нарушение. Но так ли это?
Рассмотрим принцип TDD:
Прочитать задание и написать тест, который заваливается
Написать любой код, который позволяет проходить данный тест и остальные тесты
Сделать рефакторинг, т.е. убрать повторяющийся код, если надо, но чтобы все тесты проходили
Например, было дано следующее исправление:
Мы решили добавить в блог поле тегов. Так как у нас уже существует много записей в блоге, то это поле решили сделать необязательным. Так как уже есть существующий код, то скаффолдингом не пользовались. Вручную проверили создание записи – всё ок. Прогнали тесты – всё ок. Но забыли добавить изменение поля в UpdatePost (cache.Tags = instance.Tags;). При изменении старой записи мы добавляем теги, которые собственно не сохраняются. При этом тесты прошли на ура. Жизнь — боль!
Что ж, как видно, мы нарушили основной принцип TDD – вначале пиши тест, который заваливается, а уже потом пиши код, который его обрабатывает. Но(!) тут есть и вторая хитрость — мы написали тест, который проверяет создание записи блога с тегом. Конечно, сразу же у нас это не скомпилировалось (т.е. тест не прошел), но мы добавили в ModelView что-то типа throw new NotImplementedException(). Всё скомпилировалось, тест горит красным, мы добавляем это поле с тегом, убирая исключение, тест проходит. Все остальные тесты тоже проходят. Принципы соблюдены, а ошибка осталась.
Что я могу сказать, на каждый принцип найдется ситуация, где он не сработает. Т.е. нет такого – отключили мозги и погнали. Одно можно сказать точно, и это главный вывод из этих рассуждений:
тесты должны писаться быстро
Так какие же задачи мы решаем в основном на сайте:
Добавление информации
Проверка информации
Изменение информации
Удаление информации
Проверка прав на действие
Выдача информации
Это основные действия. Как, например, проходит регистрация:
Показываем поля для заполнения
При нажатии на «Зарегистрироваться» проверяем данные
Если всё удачно, то выдаем страничку «Молодец», если же не всё хорошо, то выдаем предупреждение и позволяем исправить оплошность
Если всё хорошо, то в БД у нас появляется запись
А еще мы письмо с активацией отправляем
Создадим для всего этого юнит-тесты:
Что мы показываем ему поля для заполнения (т.е. передаем пустой объект класса RegisterUserView)
Что у нас стоят атрибуты и всё такое, проверяем, что действительно ли мы проверяем, что можно записать в БД
Что выдаем именно «Молодец» страницу
Что появляется запись, что было две записи, а стало три записи
Что пытаемся что-то отправить, находим шаблон и вызвываем MailNotify.
Приступим, пожалуй.
Установить NUnit
Идем по ссылке http://sourceforge.net/projects/nunit/ и устанавливаем NUnit. Так же в VS устанавливаем NUnit Test Adapter (ну чтобы запускать тесты прямо в VS).
Создадим папочку типа Solution Folder Test и в нее добавим проект LessonProject.UnitTest и установим там NUnit:
Install-Package NUnit
Создадим класс UserControllerTest в (/Test/Default/UserContoller.cs):
[TestFixture]
public class UserControllerTest
{
}
Итак, принцип написания наименования методов тестов Method_Scenario_ExpectedBehavior:
Method – метод [или свойство], который тестируем
Scenario – сценарий, который мы тестируем
ExpectedBehavior – ожидаемое поведение
Например, проверяем первое, что возвращаем View c классом UserView для регистрации:
public void Register_GetView_ItsOkViewModelIsUserView()
{
Console.WriteLine("=====INIT======");
var controller = new UserController();
Console.WriteLine("======ACT======");
var result = controller.Register();
Console.WriteLine("====ASSERT=====");
Assert.IsInstanceOf<ViewResult>(result);
Assert.IsInstanceOf<UserView>(((ViewResult)result).Model);
}
Итак, все тесты делятся на 3 части Init->Act->Assert:
Init – инициализация, мы получаем наш UserController
Act – действие, мы запускаем наш controller.Register
Assert – проверка, что всё действительно так.
Откроем вкладку Test Explorer:
Если адаптер NUnit правильно был установлен, то мы увидим наш тест-метод.
Запускаем. Тест пройден, можно идти открывать шампанское. Стоооп. Это лишь самая легкая часть, а как быть с той частью, где мы что-то сохраняем. В данном случае мы не имеем БД, наш Repositary – null, ноль, ничего.
Изучим теперь класс и методы для инициализации (документация). SetUpFixture – класс, помеченный этим атрибутом, означает, что в нем есть методы, которые проводят инициализацию перед тестами и зачистку после тестов. Это относится к одному и тому же пространству имен.
Setup – метод, помеченный этим атрибутом, вызывается до выполнения всех тестовых методов. Если находится в классе с атрибутом TestFixture, то вызывается перед выполнением методов только этого класса.
TearDown – метод, помеченный этим атрибутом, вызывается после выполнения всех тестов. Если находится в классе с атрибутом TestFixture, то вызывается после выполнения всех методов.
Создадим класс UnitTestSetupFixture.cs (/Setup/UnitTestSetupFixture.cs):
[SetUpFixture]
public class UnitTestSetupFixture
{
[SetUp]
public void Setup()
{
Console.WriteLine("===============");
Console.WriteLine("=====START=====");
Console.WriteLine("===============");
}
[TearDown]
public void TearDown()
{
Console.WriteLine("===============");
Console.WriteLine("=====BYE!======");
Console.WriteLine("===============");
}
}
Запустим и получим:
===============
=====START=====
===============
=====INIT======
======ACT======
====ASSERT=====
===============
=====BYE!======
===============
Mock
Итак, Mock – это объект-пародия. Т.е. например, не база данных, а что-то похожее на базу данных. Мираж, в общем-то. Есть еще Stub – это заглушка. Пример метода заглушки:
public int GetRandom()
{
return 4;
}
Но мы будем использовать Mock:
Install-Package Moq
Определим, какое окружение есть у нас, чтобы мы проинициализировали для него Mock-объекты. В принципе, это всё, что мы некогда вынесли в Ninject Kernel:
IRepository
IConfig
IMapper
IAuthentication
И тут я сделаю небольшое замечание. Мы не можем вынести Config в объекты-миражи. Не в плане, что это совсем невозможно, а в плане – что это плохая затея. Например, мы изменили шаблон письма так, что string.Format() выдает ошибку FormatException. А в тесте всё хорошо, тест отлично проходит. И за что он после этого отвечает? Ни за что. Так что файл конфигурации надо использовать оригинальный. Оставим это на потом.
По поводу, IMapper – в этом нет необходимости, мы совершенно спокойно можем использовать и CommonMapper.
Но для начала проинициазируем IKernel для работы в тестовом режиме. В App_Start/NinjectWebCommon.cs мы в методе RegisterServices указываем, как должны быть реализованы интерфейсы, и вызываем это в bootstrapper.Initialize(CreateKernel). В дальнейшем мы обращаемся по поводу получения сервиса через DependencyResolver.GetService(). Так что создадим NinjectDependencyResolver (/Tools/NinjectDependencyResolver.cs):
public class NinjectDependencyResolver : IDependencyResolver
{
private readonly IKernel _kernel;
public NinjectDependencyResolver(IKernel kernel)
{
_kernel = kernel;
}
public object GetService(Type serviceType)
{
return _kernel.TryGet(serviceType);
}
public IEnumerable<object> GetServices(Type serviceType)
{
try
{
return _kernel.GetAll(serviceType);
}
catch (Exception)
{
return new List<object>();
}
}
}
Добавим в SetUp метод (/Setup/UnitTestSetupFixture.cs):
[SetUp]
public virtual void Setup()
{
InitKernel();
}
protected virtual IKernel InitKernel()
{
var kernel = new StandardKernel();
DependencyResolver.SetResolver(new NinjectDependencyResolver(kernel));
InitRepository(kernel); //потом сделаем
return kernel;
}
public partial class MockRepository : Mock<IRepository>
{
public MockRepository(MockBehavior mockBehavior = MockBehavior.Strict)
: base(mockBehavior)
{
GenerateRoles();
GenerateLanguages();
GenerateUsers();
}
}
(/Mock/Repository/Entity/Language.cs)
namespace LessonProject.UnitTest.Mock
{
public partial class MockRepository
{
public List<Language> Languages { get; set; }
public void GenerateLanguages()
{
Languages = new List<Language>();
Languages.Add(new Language()
{
ID = 1,
Code = "en",
Name = "English"
});
Languages.Add(new Language()
{
ID = 2,
Code = "ru",
Name = "Русский"
});
this.Setup(p => p.Languages).Returns(Languages.AsQueryable());
}
}
}
(/Mock/Repository/Entity/Role.cs)
public partial class MockRepository
{
public List<Role> Roles { get; set; }
public void GenerateRoles()
{
Roles = new List<Role>();
Roles.Add(new Role()
{
ID = 1,
Code = "admin",
Name = "Administrator"
});
this.Setup(p => p.Roles).Returns(Roles.AsQueryable());
}
}
(/Mock/Repository/Entity/User.cs)
public partial class MockRepository
{
public List<User> Users { get; set; }
public void GenerateUsers()
{
Users = new List<User>();
var admin = new User()
{
ID = 1,
ActivatedDate = DateTime.Now,
ActivatedLink = "",
Email = "admin",
FirstName = "",
LastName = "",
Password = "password",
LastVisitDate = DateTime.Now,
};
var role = Roles.First(p => p.Code == "admin");
var userRole = new UserRole()
{
User = admin,
UserID = admin.ID,
Role = role,
RoleID = role.ID
};
admin.UserRoles =
new EntitySet<UserRole>() {
userRole
};
Users.Add(admin);
Users.Add(new User()
{
ID = 2,
ActivatedDate = DateTime.Now,
ActivatedLink = "",
Email = "chernikov@gmail.com",
FirstName = "Andrey",
LastName = "Chernikov",
Password = "password2",
LastVisitDate = DateTime.Now
});
this.Setup(p => p.Users).Returns(Users.AsQueryable());
this.Setup(p => p.GetUser(It.IsAny<string>())).Returns((string email) =>
Users.FirstOrDefault(p => string.Compare(p.Email, email, 0) == 0));
this.Setup(p => p.Login(It.IsAny<string>(), It.IsAny<string>())).Returns((string email, string password) =>
Users.FirstOrDefault(p => string.Compare(p.Email, email, 0) == 0));
}
}
Рассмотрим, как работает Mock. У него есть такой хороший метод, как Setup (опять?! сплошной сетап!), который работает таким образом: this.Setup(что у нас запрашивают).Returns(что мы отвечаем на это);
Проверим какой-то наш вывод, например класс /Default/Controllers/UserController:cs:
[Test]
public void Index_GetPageableDataOfUsers_CountOfUsersIsTwo()
{
//init
var controller = DependencyResolver.Current.GetService<Areas.Default.Controllers.UserController>();
//act
var result = controller.Index();
Assert.IsInstanceOf<ViewResult>(result);
Assert.IsInstanceOf<PageableData<User>>(((ViewResult)result).Model);
var count = ((PageableData<User>)((ViewResult)result).Model).List.Count();
Assert.AreEqual(2, count);
}
В BaseController.cs (/LessonProject/Controllers/BaseController.cs) уберем атрибуты Inject у свойств Auth и Config (иначе выделенная строка не сможет проинициализовать контроллер и вернет null). Кстати о выделенной строке. Мы делаем именно такую инициализацию, чтобы все Inject-атрибутованные свойства были проинициализированы. Запускаем, и, правда, count == 2. Отлично, MockRepository работает. Вернем назад атрибуты Inject.
Кстати, тесты не запускаются обычно в дебаг-режиме, чтобы запустить Debug надо сделать так:
Теперь поработаем с Config. Это будет круто!
TestConfig
Что нам нужно сделать. Нам нужно:
Взять Web.Config c проекта LessonProject (каким-то хитрым образом)
И на его базе создать некий класс, который будет реализовывать IConfig интерфейс
Ну и поцепить на Ninject Kernel
И можно использовать.
Начнем. Для того чтобы взять Web.Config – нам нужно скопировать его в свою папку. Назовем её Sandbox. Теперь скопируем, поставим на pre-build Event в Project Properties:
При каждом запуске билда мы копируем Web.config (и, если надо, то перезаписываем) к себе в Sandbox.
Создадим TestConfig.cs и в конструктор будем передавать наш файл (/Tools/TestConfig.cs):
public class TestConfig : IConfig
{
private Configuration configuration;
public TestConfig(string configPath)
{
var configFileMap = new ExeConfigurationFileMap();
configFileMap.ExeConfigFilename = configPath;
configuration = ConfigurationManager.OpenMappedExeConfiguration(configFileMap, ConfigurationUserLevel.None);
}
public string ConnectionStrings(string connectionString)
{
return configuration.ConnectionStrings.ConnectionStrings[connectionString].ConnectionString;
}
public string Lang
{
get
{
return configuration.AppSettings.Settings["Lang"].Value;
}
}
public bool EnableMail
{
get
{
return bool.Parse(configuration.AppSettings.Settings["EnableMail"].Value);
}
}
public IQueryable<IconSize> IconSizes
{
get
{
IconSizesConfigSection configInfo = (IconSizesConfigSection)configuration.GetSection("iconConfig");
if (configInfo != null)
{
return configInfo.IconSizes.OfType<IconSize>().AsQueryable<IconSize>();
}
return null;
}
}
public IQueryable<MimeType> MimeTypes
{
get
{
MimeTypesConfigSection configInfo = (MimeTypesConfigSection)configuration.GetSection("mimeConfig");
return configInfo.MimeTypes.OfType<MimeType>().AsQueryable<MimeType>();
}
}
public IQueryable<MailTemplate> MailTemplates
{
get {
MailTemplateConfigSection configInfo = (MailTemplateConfigSection)configuration.GetSection("mailTemplatesConfig");
return configInfo.MailTemplates.OfType<MailTemplate>().AsQueryable<MailTemplate>();
}
}
public MailSetting MailSetting
{
get
{
return (MailSetting)configuration.GetSection("mailConfig");
}
}
public SmsSetting SmsSetting
{
get
{
return (SmsSetting)configuration.GetSection("smsConfig");
}
}
}
И инициализируем в UnitTestSetupFixture.cs (/Setup/UnitTestSetupFixture.cs):
protected virtual void InitConfig(StandardKernel kernel)
{
var fullPath = new FileInfo(Sandbox + "/Web.config").FullName;
kernel.Bind<IConfig>().ToMethod(c => new TestConfig(fullPath));
}
Создадим простой тест на проверку данных в конфиге:
[TestFixture]
public class MailTemplateTest
{
[Test]
public void MailTemplates_ExistRegisterTemplate_Exist()
{
var config = DependencyResolver.Current.GetService<IConfig>();
var template = config.MailTemplates.FirstOrDefault(p => p.Name.StartsWith("Register"));
Assert.IsNotNull(template);
}
}
Запускаем, проверяем, вуаля! Переходим к реализации IAuthentication.
Authentication
В веб-приложении, когда мы уже исполняем код в контроллере, мы уже имеем какой-то заданный контекст, окружение, сформированное http-запросом. Т.е. это и параметры, и кукисы, и данные о версии браузера, и каково разрешение экрана, и какая операционная система. В общем, это всё – HttpContext. Следует понимать, что мы при авторизации помещаем в кукисы какие-то данные, а потом достаем их и всё. Собственно, для этого мы создадим специальный интерфейс IAuthCookieProvider, который будет типа записывать кукисы
IAuthCookieProvider.cs (LessonProject/Global/Auth/IAuthCookieProvider):
И реализуем его для HttpAuthCookieProvider.cs (/Global/Auth/HttpAuthCookieProvider.cs):
public class HttpContextCookieProvider : IAuthCookieProvider
{
public HttpContextCookieProvider(HttpContext HttpContext)
{
this.HttpContext = HttpContext;
}
protected HttpContext HttpContext { get; set; }
public HttpCookie GetCookie(string cookieName)
{
return HttpContext.Request.Cookies.Get(cookieName);
}
public void SetCookie(HttpCookie cookie)
{
HttpContext.Response.Cookies.Set(cookie);
}
}
И теперь используем эту реализацию для работы с Cookies в CustomAuthentication (/Global/Auth/CustomAuthentication.cs):
public IAuthCookieProvider AuthCookieProvider { get; set; }
и вместо HttpContext.Request.Cookies.Get – используем GetCookie() и
HttpContext.Response.Cookies.Set – соответственно SetCookie().
Изменяем и в IAuthencation.cs (/Global/Auth/IAuthencation.cs):
public interface IAuthentication
{
/// <summary>
/// Конекст (тут мы получаем доступ к запросу и кукисам)
/// </summary>
IAuthCookieProvider AuthCookieProvider { get; set; }
И в AuthHttpModule.cs (/Global/Auth/AuthHttpModule.cs):
var auth = DependencyResolver.Current.GetService<IAuthentication>();
auth.AuthCookieProvider = new HttpContextCookieProvider(context);
MockHttpContext
Теперь создадим Mock-объекты для HttpContext в LessonProject.UnitTest:
MockHttpContext.cs в (/Mock/HttpContext.cs):
public class MockHttpContext : Mock<HttpContextBase>
{
[Inject]
public HttpCookieCollection Cookies { get; set; }
public MockHttpCachePolicy Cache { get; set; }
public MockHttpBrowserCapabilities Browser { get; set; }
public MockHttpSessionState SessionState { get; set; }
public MockHttpServerUtility ServerUtility { get; set; }
public MockHttpResponse Response { get; set; }
public MockHttpRequest Request { get; set; }
public MockHttpContext(MockBehavior mockBehavior = MockBehavior.Strict)
: this(null, mockBehavior)
{
}
public MockHttpContext(IAuthentication auth, MockBehavior mockBehavior = MockBehavior.Strict)
: base(mockBehavior)
{
//request
Browser = new MockHttpBrowserCapabilities(mockBehavior);
Browser.Setup(b => b.IsMobileDevice).Returns(false);
Request = new MockHttpRequest(mockBehavior);
Request.Setup(r => r.Cookies).Returns(Cookies);
Request.Setup(r => r.ValidateInput());
Request.Setup(r => r.UserAgent).Returns("Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.64 Safari/537.11");
Request.Setup(r => r.Browser).Returns(Browser.Object);
this.Setup(p => p.Request).Returns(Request.Object);
//response
Cache = new MockHttpCachePolicy(MockBehavior.Loose);
Response = new MockHttpResponse(mockBehavior);
Response.Setup(r => r.Cookies).Returns(Cookies);
Response.Setup(r => r.Cache).Returns(Cache.Object);
this.Setup(p => p.Response).Returns(Response.Object);
//user
if (auth != null)
{
this.Setup(p => p.User).Returns(() => auth.CurrentUser);
}
else
{
this.Setup(p => p.User).Returns(new UserProvider("", null));
}
//Session State
SessionState = new MockHttpSessionState();
this.Setup(p => p.Session).Returns(SessionState.Object);
//Server Utility
ServerUtility = new MockHttpServerUtility(mockBehavior);
this.Setup(p => p.Server).Returns(ServerUtility.Object);
//Items
var items = new ListDictionary();
this.Setup(p => p.Items).Returns(items);
}
}
Кроме этого создаем еще такие классы:
MockHttpCachePolicy
MockHttpBrowserCapabilities
MockHttpSessionState
MockHttpServerUtility
MockHttpResponse
MockHttpRequest
Все эти mock-объекты весьма тривиальны, кроме MockSessionState, где и хранится session-storage (/Mock/Http/MockHttpSessionState.cs):
Заметим, что Bind происходит на SingletonScope(), т.е. единожды авторизовавшись в каком-то тесте, мы в последующих тестах будем использовать эту же авторизацию.
Компилим и пытаемся с этим всем взлететь. Сейчас начнется магия…
Проверка валидации
Если мы просто вызовем что-то типа:
var registerUser = new UserView()
{
Email = "user@sample.com",
Password = "123456",
ConfirmPassword = "1234567",
AvatarPath = "/file/no-image.jpg",
BirthdateDay = 1,
BirthdateMonth = 12,
BirthdateYear = 1987,
Captcha = "1234"
};
var result = controller.Register(registerUser);
То, во-первых, никакая неявная валидация не выполнится, а во-вторых, у нас там есть session и мы ее не проинициализировали, она null и всё – ошибка. Так что проверку валидации (та, что в атрибутах) будем устраивать через отдельный класс. Назовем его Валидатор Валидаторович (/Tools/Validator.cs):
public class ValidatorException : Exception
{
public ValidationAttribute Attribute { get; private set; }
public ValidatorException(ValidationException ex, ValidationAttribute attribute)
: base(attribute.GetType().Name, ex)
{
Attribute = attribute;
}
}
public class Validator
{
public static void ValidateObject<T>(T obj)
{
var type = typeof(T);
var meta = type.GetCustomAttributes(false).OfType<MetadataTypeAttribute>().FirstOrDefault();
if (meta != null)
{
type = meta.MetadataClassType;
}
var typeAttributes = type.GetCustomAttributes(typeof(ValidationAttribute), true).OfType<ValidationAttribute>();
var validationContext = new ValidationContext(obj);
foreach (var attribute in typeAttributes)
{
try
{
attribute.Validate(obj, validationContext);
}
catch (ValidationException ex)
{
throw new ValidatorException(ex, attribute);
}
}
var propertyInfo = type.GetProperties();
foreach (var info in propertyInfo)
{
var attributes = info.GetCustomAttributes(typeof(ValidationAttribute), true).OfType<ValidationAttribute>();
foreach (var attribute in attributes)
{
var objPropInfo = obj.GetType().GetProperty(info.Name);
try
{
attribute.Validate(objPropInfo.GetValue(obj, null), validationContext);
}
catch (ValidationException ex)
{
throw new ValidatorException(ex, attribute);
}
}
}
}
}
Итак, что тут у нас происходит. Вначале мы получаем все атрибуты класса T, которые относятся к типу ValidationAttribute:
var typeAttributes = type.GetCustomAttributes(typeof(ValidationAttribute), true).OfType<ValidationAttribute>();
var validationContext = new ValidationContext(obj);
foreach (var attribute in typeAttributes)
{
try
{
attribute.Validate(obj, validationContext);
}
catch (ValidationException ex)
{
throw new ValidatorException(ex, attribute);
}
}
Потом аналогично для каждого свойства:
var propertyInfo = type.GetProperties();
foreach (var info in propertyInfo)
{
var attributes = info.GetCustomAttributes(typeof(ValidationAttribute), true).OfType<ValidationAttribute>();
foreach (var attribute in attributes)
{
var objPropInfo = obj.GetType().GetProperty(info.Name);
try
{
attribute.Validate(objPropInfo.GetValue(obj, null), validationContext);
}
catch (ValidationException ex)
{
throw new ValidatorException(ex, attribute);
}
}
}
Если валидация не проходит, то происходит исключение, и мы оборачиваем его в ValidatorException, передавая еще и атрибут, по которому произошло исключение.
Теперь по поводу капчи и Session. Мы должны контроллеру передать контекст (MockHttpContext):
var controller = DependencyResolver.Current.GetService<Areas.Default.Controllers.UserController>();
var httpContext = new MockHttpContext().Object;
ControllerContext context = new ControllerContext(new RequestContext(httpContext, new RouteData()), controller);
controller.ControllerContext = context;
controller.Session.Add(CaptchaImage.CaptchaValueKey, "1111");
И теперь всё вместе:
[Test]
public void Index_RegisterUserWithDifferentPassword_ExceptionCompare()
{
//init
var controller = DependencyResolver.Current.GetService<Areas.Default.Controllers.UserController>();
var httpContext = new MockHttpContext().Object;
ControllerContext context = new ControllerContext(new RequestContext(httpContext, new RouteData()), controller);
controller.ControllerContext = context;
//act
var registerUserView = new UserView()
{
Email = "user@sample.com",
Password = "123456",
ConfirmPassword = "1234567",
AvatarPath = "/file/no-image.jpg",
BirthdateDay = 1,
BirthdateMonth = 12,
BirthdateYear = 1987,
Captcha = "1111"
};
try
{
Validator.ValidateObject<UserView>(registerUserView);
}
catch (Exception ex)
{
Assert.IsInstanceOf<ValidatorException>(ex);
Assert.IsInstanceOf<System.ComponentModel.DataAnnotations.CompareAttribute>(((ValidatorException)ex).Attribute);
}
}
Запускаем, и всё получилось. Но капча проверяется непосредственно в методе контроллера. Специально для капчи:
[Test]
public void Index_RegisterUserWithWrongCaptcha_ModelStateWithError()
{
//init
var controller = DependencyResolver.Current.GetService<Areas.Default.Controllers.UserController>();
var httpContext = new MockHttpContext().Object;
ControllerContext context = new ControllerContext(new RequestContext(httpContext, new RouteData()), controller);
controller.ControllerContext = context;
controller.Session.Add(CaptchaImage.CaptchaValueKey, "2222");
//act
var registerUserView = new UserView()
{
Email = "user@sample.com",
Password = "123456",
ConfirmPassword = "1234567",
AvatarPath = "/file/no-image.jpg",
BirthdateDay = 1,
BirthdateMonth = 12,
BirthdateYear = 1987,
Captcha = "1111"
};
var result = controller.Register(registerUserView);
Assert.AreEqual("Текст с картинки введен неверно", controller.ModelState["Captcha"].Errors[0].ErrorMessage);
}
Круто!
Проверка авторизации
Например, мы должны проверить, что, если я захожу не под админом, то в авторизованную часть (в контроллер, помеченный атрибутом [Authorize(Roles=“admin”)]) – обычному польвателю не дадут войти. Есть отличный способ это проверить. Обратим внимание на класс ControllerActionInvoker и отнаследуем его для вызовов (/Fake/FakeControllerActionInvoker.cs + FakeValueProvider.cs):
public class FakeValueProvider
{
protected Dictionary<string, object> Values { get; set; }
public FakeValueProvider()
{
Values = new Dictionary<string, object>();
}
public object this[string index]
{
get
{
if (Values.ContainsKey(index))
{
return Values[index];
}
return null;
}
set
{
if (Values.ContainsKey(index))
{
Values[index] = value;
}
else
{
Values.Add(index, value);
}
}
}
}
public class FakeControllerActionInvoker<TExpectedResult> : ControllerActionInvoker where TExpectedResult : ActionResult
{
protected FakeValueProvider FakeValueProvider { get; set; }
public FakeControllerActionInvoker()
{
FakeValueProvider = new FakeValueProvider();
}
public FakeControllerActionInvoker(FakeValueProvider fakeValueProvider)
{
FakeValueProvider = fakeValueProvider;
}
protected override ActionExecutedContext InvokeActionMethodWithFilters(ControllerContext controllerContext, IList<IActionFilter> filters, ActionDescriptor actionDescriptor, IDictionary<string, object> parameters)
{
return base.InvokeActionMethodWithFilters(controllerContext, filters, actionDescriptor, parameters);
}
protected override object GetParameterValue(ControllerContext controllerContext, ParameterDescriptor parameterDescriptor)
{
var obj = FakeValueProvider[parameterDescriptor.ParameterName];
if (obj != null)
{
return obj;
}
return parameterDescriptor.DefaultValue;
}
protected override void InvokeActionResult(ControllerContext controllerContext, ActionResult actionResult)
{
Assert.IsInstanceOf<TExpectedResult>(actionResult);
}
}
По сути это «вызывальщик» action-методов контроллеров, где Generic класс – это ожидаемый класс результата. В случае неавторизации это будет HttpUnauthorizedResult. Сделаем тест (/Test/Admin/HomeControllerTest.cs):
[TestFixture]
public class AdminHomeControllerTest
{
[Test]
public void Index_NotAuthorizeGetDefaultView_RedirectToLoginPage()
{
var auth = DependencyResolver.Current.GetService<IAuthentication>();
auth.Login("chernikov@gmail.com", "password2", false);
var httpContext = new MockHttpContext(auth).Object;
var controller = DependencyResolver.Current.GetService<Areas.Admin.Controllers.HomeController>();
var route = new RouteData();
route.Values.Add("controller", "Home");
route.Values.Add("action", "Index");
route.Values.Add("area", "Admin");
ControllerContext context = new ControllerContext(new RequestContext(httpContext, route), controller);
controller.ControllerContext = context;
var controllerActionInvoker = new FakeControllerActionInvoker<HttpUnauthorizedResult>();
var result = controllerActionInvoker.InvokeAction(controller.ControllerContext, "Index");
}
}
Запускаем тест, он проходит. Сделаем, чтобы авторизация была под пользователем admin и будем ожидать получение ViewResult:
[Test]
public void Index_AdminAuthorize_GetViewResult()
{
var auth = DependencyResolver.Current.GetService<IAuthentication>();
auth.Login("admin", "password", false);
var httpContext = new MockHttpContext(auth).Object;
var controller = DependencyResolver.Current.GetService<Areas.Admin.Controllers.HomeController>();
var route = new RouteData();
route.Values.Add("controller", "Home");
route.Values.Add("action", "Index");
route.Values.Add("area", "Admin");
ControllerContext context = new ControllerContext(new RequestContext(httpContext, route), controller);
controller.ControllerContext = context;
var controllerActionInvoker = new FakeControllerActionInvoker<ViewResult>();
var result = controllerActionInvoker.InvokeAction(controller.ControllerContext, "Index");
}
Так же прошли. Молодцом.
На этом давайте остановимся и подумаем, чего мы достигли. Мы можем оттестировать любой контроллер, проверить правильность любой валидации, проверку прав пользователя. Но это касается только контроллера. А как же работа с моделью? Да, мы можем проверить, что вызывается метод репозитория, но на этом всё. Да, мы можем написать Mock-методы для добавления, изменения, удаления, но как это поможет решить ту проблему, о которой я писал вначале главы? Как мы заметим, что что-то не так при упущении поля с тегом? В хрестоматийном примере NerdDinner тесты не покрывают эту область.
Есть IRepository, есть SqlRepository, есть MockRepository. И всё что находится в SqlRepository – это не покрытая тестами область. А там может быть реализовано очень многое. Что же делать? К чему этот TDD?
Интегрированное тестирование
Идея будет совершенно безумной, мы будем использовать и проверять уже существующий код в SqlRepository. Для этого мы через Web.config находим базу (она должна располагаться локально), дублировать ее, подключаться к дубликату БД, проходить тесты и в конце, удалять дубликат БД.
Создаем проект LessonProject.IntegrationTest в папке Test.
Добавляем Ninject, Moq и NUnit:
Так же создаем папку Sandbox и в Setup наследуем UnitTestSetupFixture (/Setup/IntegrationTestSetupFixture.cs) и функцию по копированию БД:
[SetUpFixture]
public class IntegrationTestSetupFixture : UnitTestSetupFixture
{
public class FileListRestore
{
public string LogicalName { get; set; }
public string Type { get; set; }
}
protected static string NameDb = "LessonProject";
protected static string TestDbName;
private void CopyDb(StandardKernel kernel, out FileInfo sandboxFile, out string connectionString)
{
var config = kernel.Get<IConfig>();
var db = new DataContext(config.ConnectionStrings("ConnectionString"));
TestDbName = string.Format("{0}_{1}", NameDb, DateTime.Now.ToString("yyyyMMdd_HHmmss"));
Console.WriteLine("Create DB = " + TestDbName);
sandboxFile = new FileInfo(string.Format("{0}\{1}.bak", Sandbox, TestDbName));
var sandboxDir = new DirectoryInfo(Sandbox);
//backupFile
var textBackUp = string.Format(@"-- Backup the database
BACKUP DATABASE [{0}]
TO DISK = '{1}'
WITH COPY_ONLY",
NameDb, sandboxFile.FullName);
db.ExecuteCommand(textBackUp);
var restoreFileList = string.Format("RESTORE FILELISTONLY FROM DISK = '{0}'", sandboxFile.FullName);
var fileListRestores = db.ExecuteQuery<FileListRestore>(restoreFileList).ToList();
var logicalDbName = fileListRestores.FirstOrDefault(p => p.Type == "D");
var logicalLogDbName = fileListRestores.FirstOrDefault(p => p.Type == "L");
var restoreDb = string.Format("RESTORE DATABASE [{0}] FROM DISK = '{1}' WITH FILE = 1, MOVE N'{2}' TO N'{4}\{0}.mdf', MOVE N'{3}' TO N'{4}\{0}.ldf', NOUNLOAD, STATS = 10", TestDbName, sandboxFile.FullName, logicalDbName.LogicalName, logicalLogDbName.LogicalName, sandboxDir.FullName);
db.ExecuteCommand(restoreDb);
connectionString = config.ConnectionStrings("ConnectionString").Replace(NameDb, TestDbName);
}
}
По порядку:
В строках
var config = kernel.Get<IConfig>();
var db = new DataContext(config.ConnectionStrings("ConnectionString"));
//backupFile
var textBackUp = string.Format(@"-- Backup the database
BACKUP DATABASE [{0}]
TO DISK = '{1}'
WITH COPY_ONLY",
NameDb, sandboxFile.FullName);
db.ExecuteCommand(textBackUp);
— выполняем бекап БД в папку Sandbox.
var restoreFileList = string.Format("RESTORE FILELISTONLY FROM DISK = '{0}'", sandboxFile.FullName);
var fileListRestores = db.ExecuteQuery<FileListRestore>(restoreFileList).ToList();
var logicalDbName = fileListRestores.FirstOrDefault(p => p.Type == "D");
var logicalLogDbName = fileListRestores.FirstOrDefault(p => p.Type == "L");
— получаем логическое имя БД и файла логов, используя приведение к классу FIleListRestore.
var restoreDb = string.Format("RESTORE DATABASE [{0}] FROM DISK = '{1}' WITH FILE = 1, MOVE N'{2}' TO N'{4}\{0}.mdf', MOVE N'{3}' TO N'{4}\{0}.ldf', NOUNLOAD, STATS = 10", TestDbName, sandboxFile.FullName, logicalDbName.LogicalName, logicalLogDbName.LogicalName, sandboxDir.FullName);
db.ExecuteCommand(restoreDb);
— восстанавливаем БД под другим именем (TestDbName)
И теперь можем спокойно проинициализировать IRepository к SqlRepository:
protected override void InitRepository(StandardKernel kernel)
{
FileInfo sandboxFile;
string connectionString;
CopyDb(kernel, out sandboxFile, out connectionString);
kernel.Bind<webTemplateDbDataContext>().ToMethod(c => new webTemplateDbDataContext(connectionString));
kernel.Bind<IRepository>().To<SqlRepository>().InTransientScope();
sandboxFile.Delete();
}
Итак, у нас есть sandboxFile – это файл бекапа, и connectionString – это новая строка подключения (к дубликату БД). Мы копируем БД, связываем именно с SqlRepository, но базу подсовываем не основную. И с ней можно делать всё что угодно. Файл бекапа базы в конце удаляем.
И дописываем уже удаление тестовой БД, после прогона всех тестов:
private void RemoveDb()
{
var config = DependencyResolver.Current.GetService<IConfig>();
var db = new DataContext(config.ConnectionStrings("ConnectionString"));
var textCloseConnectionTestDb = string.Format(@"ALTER DATABASE [{0}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE", TestDbName);
db.ExecuteCommand(textCloseConnectionTestDb);
var textDropTestDb = string.Format(@"DROP DATABASE [{0}]", TestDbName);
db.ExecuteCommand(textDropTestDb);
}
Используя TestDbName, закрываем подключение (а то оно активное), и удаляем базу данных.
Не забываем сделать копию Web.config:
Но кстати, иногда БД нет необходимости удалять. Например, мы хотим заполнить базу кучей данных автоматически, чтобы проверить поиск или пейджинг. Это мы рассмотрим ниже. А сейчас тест – реальное создание в БД записи:
[TestFixture]
public class DefaultUserControllerTest
{
[Test]
public void CreateUser_CreateNormalUser_CountPlusOne()
{
var repository = DependencyResolver.Current.GetService<IRepository>();
var controller = DependencyResolver.Current.GetService<LessonProject.Areas.Default.Controllers.UserController>();
var countBefore = repository.Users.Count();
var httpContext = new MockHttpContext().Object;
var route = new RouteData();
route.Values.Add("controller", "User");
route.Values.Add("action", "Register");
route.Values.Add("area", "Default");
ControllerContext context = new ControllerContext(new RequestContext(httpContext, route), controller);
controller.ControllerContext = context;
controller.Session.Add(CaptchaImage.CaptchaValueKey, "1111");
var registerUserView = new UserView()
{
ID = 0,
Email = "rollinx@gmail.com",
Password = "123456",
ConfirmPassword = "123456",
Captcha = "1111",
BirthdateDay = 13,
BirthdateMonth = 9,
BirthdateYear = 1970
};
Validator.ValidateObject<UserView>(registerUserView);
controller.Register(registerUserView);
var countAfter = repository.Users.Count();
Assert.AreEqual(countBefore + 1, countAfter);
}
}
Проверьте, что нет в БД пользователя с таким email.
Запускаем, проверяем. Работает. Кайф! Тут понятно, какие мощности открываются. И если юнит-тестирование – это как обработка минимальных кусочков кода, а тут – это целый сценарий. Но, кстати, замечу, что MailNotify всё же высылает письма на почту. Так что перепишем его как сервис:
/LessonProject/Tools/Mail/IMailSender.cs:
Запускаем, тесты пройдены, но на почту уже ничего не отправляется.
===============
=====START=====
===============
Create DB = LessonProject_20130314_104218
Send mock email to: chernikov@googlemail.com, subject Регистрация на
===============
=====BYE!======
===============
Генерация данных
Кроме всего прочего, мы можем и не удалять базу данных после пробегов теста. (переписать)Я добавлю GenerateData проект в папку Test, но подробно рассматривать мы его не будем, просто чтобы был. Он достаточно тривиальный. Суть его – есть некоторые наименования, и мы используем их для генерации. Например, для генерации фамилии используются фамилии американских президентов (зная их, мы сразу отличаем их от других фамилий, которые скорее будут реальными).
Это также в будущем позволяет избежать «эффекта рыбы», когда в шаблоне тестовые данные были одной определенной, но не максимальной длины и шаблон выглядел прилично, но при использовании реальных данных всё поехало.
Создадим 100 пользователей и потом посмотрим на них:
[Test]
public void CreateUser_Create100Users_NoAssert()
{
var repository = DependencyResolver.Current.GetService<IRepository>();
var controller = DependencyResolver.Current.GetService<LessonProject.Areas.Default.Controllers.UserController>();
var httpContext = new MockHttpContext().Object;
var route = new RouteData();
route.Values.Add("controller", "User");
route.Values.Add("action", "Register");
route.Values.Add("area", "Default");
ControllerContext context = new ControllerContext(new RequestContext(httpContext, route), controller);
controller.ControllerContext = context;
controller.Session.Add(CaptchaImage.CaptchaValueKey, "1111");
var rand = new Random((int)DateTime.Now.Ticks);
for (int i = 0; i < 100; i++)
{
var registerUserView = new UserView()
{
ID = 0,
Email = Email.GetRandom(Name.GetRandom(), Surname.GetRandom()),
Password = "123456",
ConfirmPassword = "123456",
Captcha = "1111",
BirthdateDay = rand.Next(28) + 1,
BirthdateMonth = rand.Next(12) + 1,
BirthdateYear = 1970 + rand.Next(20)
};
controller.Register(registerUserView);
}
}
В IntegrationTestSetupFixture.cs отключим удаление БД после работы (/Setup/IntegrationTestSetupFixture.cs):
Unit-тесты и как этот инструмент позволяет улучшить нам качество кода
Integration-тесты, и как мы можем их использовать
Тестирование – это очень большая область, это даже отдельная профессия и склад ума (не совсем программистский). И качество кода будет зависеть не только от применения технологий, хотя, бесспорно, соблюдение логических принципов TDD и внутренних процессов при разработке программ позволяет избежать множества ошибок. Написание тестов – не панацея от всех бед, это инструмент, и важно правильно им пользоваться…
Мы обошли вниманием тестирование клиентской части, и честно говоря, я не знаю, как это должно происходить. В JQuery только в октябре 2011го начали развивать проект qUnit, но информации по нему почти нет.