Цель урока. Научиться создавать тесты для кода. NUnit. Принцип применения TDD. Mock. Юнит-тесты. Интегрированное тестирование. Генерация данных.
Тестирование для меня лично – это тема многих размышлений. Нужны или не нужны тесты? Но никто не будет спорить, что для написания тестов нужны ресурсы.
Рассмотрим два случая:
Видите ли, тут дилемма такова, что результат запуска сайта непредсказуемый. У меня было и так, что сделанный на коленке сайт запустился весьма бодро, а бывало, что крутую работу заказчик оплатил, но даже не принял. Итак, тактика поведения может быть такой:
Рассмотрим принцип TDD:
Например, было дано следующее исправление:
Мы решили добавить в блог поле тегов. Так как у нас уже существует много записей в блоге, то это поле решили сделать необязательным. Так как уже есть существующий код, то скаффолдингом не пользовались. Вручную проверили создание записи – всё ок. Прогнали тесты – всё ок. Но забыли добавить изменение поля в UpdatePost (cache.Tags = instance.Tags;). При изменении старой записи мы добавляем теги, которые собственно не сохраняются. При этом тесты прошли на ура. Жизнь — боль!
Что ж, как видно, мы нарушили основной принцип TDD – вначале пиши тест, который заваливается, а уже потом пиши код, который его обрабатывает. Но(!) тут есть и вторая хитрость — мы написали тест, который проверяет создание записи блога с тегом. Конечно, сразу же у нас это не скомпилировалось (т.е. тест не прошел), но мы добавили в ModelView что-то типа throw new NotImplementedException(). Всё скомпилировалось, тест горит красным, мы добавляем это поле с тегом, убирая исключение, тест проходит. Все остальные тесты тоже проходят. Принципы соблюдены, а ошибка осталась.
Что я могу сказать, на каждый принцип найдется ситуация, где он не сработает. Т.е. нет такого – отключили мозги и погнали. Одно можно сказать точно, и это главный вывод из этих рассуждений:
тесты должны писаться быстро
Так какие же задачи мы решаем в основном на сайте:
Это основные действия. Как, например, проходит регистрация:
Создадим для всего этого юнит-тесты:
Приступим, пожалуй.
Идем по ссылке http://sourceforge.net/projects/nunit/ и устанавливаем NUnit. Так же в VS устанавливаем NUnit Test Adapter (ну чтобы запускать тесты прямо в VS).
Создадим папочку типа Solution Folder Test и в нее добавим проект LessonProject.UnitTest и установим там NUnit:
Создадим класс UserControllerTest в (/Test/Default/UserContoller.cs):
Итак, принцип написания наименования методов тестов Method_Scenario_ExpectedBehavior:
Например, проверяем первое, что возвращаем View c классом UserView для регистрации:
Итак, все тесты делятся на 3 части Init->Act->Assert:
Откроем вкладку Test Explorer:
Если адаптер NUnit правильно был установлен, то мы увидим наш тест-метод.
Запускаем. Тест пройден, можно идти открывать шампанское. Стоооп. Это лишь самая легкая часть, а как быть с той частью, где мы что-то сохраняем. В данном случае мы не имеем БД, наш Repositary – null, ноль, ничего.
Изучим теперь класс и методы для инициализации (документация). SetUpFixture – класс, помеченный этим атрибутом, означает, что в нем есть методы, которые проводят инициализацию перед тестами и зачистку после тестов. Это относится к одному и тому же пространству имен.
Создадим класс UnitTestSetupFixture.cs (/Setup/UnitTestSetupFixture.cs):
Запустим и получим:
Итак, Mock – это объект-пародия. Т.е. например, не база данных, а что-то похожее на базу данных. Мираж, в общем-то. Есть еще Stub – это заглушка. Пример метода заглушки:
Но мы будем использовать Mock:
Определим, какое окружение есть у нас, чтобы мы проинициализировали для него Mock-объекты. В принципе, это всё, что мы некогда вынесли в Ninject Kernel:
И тут я сделаю небольшое замечание. Мы не можем вынести Config в объекты-миражи. Не в плане, что это совсем невозможно, а в плане – что это плохая затея. Например, мы изменили шаблон письма так, что string.Format() выдает ошибку FormatException. А в тесте всё хорошо, тест отлично проходит. И за что он после этого отвечает? Ни за что. Так что файл конфигурации надо использовать оригинальный. Оставим это на потом.
По поводу, IMapper – в этом нет необходимости, мы совершенно спокойно можем использовать и CommonMapper.
Но для начала проинициазируем IKernel для работы в тестовом режиме. В App_Start/NinjectWebCommon.cs мы в методе RegisterServices указываем, как должны быть реализованы интерфейсы, и вызываем это в bootstrapper.Initialize(CreateKernel). В дальнейшем мы обращаемся по поводу получения сервиса через DependencyResolver.GetService(). Так что создадим NinjectDependencyResolver (/Tools/NinjectDependencyResolver.cs):
Добавим в SetUp метод (/Setup/UnitTestSetupFixture.cs):
Создадим MockRepository
(/Mock/Repository/MockRepository.cs):
(/Mock/Repository/Entity/Language.cs)
(/Mock/Repository/Entity/Role.cs)
(/Mock/Repository/Entity/User.cs)
Рассмотрим, как работает Mock. У него есть такой хороший метод, как Setup (опять?! сплошной сетап!), который работает таким образом:
Например:
Рассмотрим подробнее, какие еще могут быть варианты:
Тестирование, принцип 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;
}
Создадим MockRepository
(/Mock/Repository/MockRepository.cs):
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(что мы отвечаем на это);
Например:
this.Setup(p => p.WillYou()).Returns(true);
Рассмотрим подробнее, какие еще могут быть варианты:
- Методы
var mock = new Mock<IFoo>(); mock.Setup(foo => foo.DoSomething("ping")).Returns(true);
- параметр out
var outString = "ack"; mock.Setup(foo => foo.TryParse("ping", out outString)).Returns(true);
- ссылочный параметр
var instance = new Bar(); mock.Setup(foo => foo.Submit(ref instance)).Returns(true);
- зависимость от входного параметра и возвращаемого значения (можно и несколько параметров)
mock.Setup(x => x.DoSomething(It.IsAny<string>())) .Returns((string s) => s.ToLower());
- кидаем исключение
mock.Setup(foo => foo.DoSomething("reset")).Throws<InvalidOperationException>(); mock.Setup(foo => foo.DoSomething("")).Throws(new ArgumentException("command");
- возвращает различные значения для (???) и использование Callback
var mock = new Mock<IFoo>(); var calls = 0; mock.Setup(foo => foo.GetCountThing()) .Returns(() => calls) .Callback(() => calls++);
- параметр out
- Соответсвие на аргументы
- любое значение
mock.Setup(foo => foo.DoSomething(It.IsAny<string>())).Returns(true);
- условие через Func<bool, T>
mock.Setup(foo => foo.Add(It.Is<int>(i => i % 2 == 0))).Returns(true);
- нахождение в диапазоне
mock.Setup(foo => foo.Add(It.IsInRange<int>(0, 10, Range.Inclusive))).Returns(true);
- Regex выражение
mock.Setup(x => x.DoSomething(It.IsRegex("[a-d]+", RegexOptions.IgnoreCase))).Returns("foo");
- любое значение
- Свойства
- Любое свойство
mock.Setup(foo => foo.Name).Returns("bar");
- Любой иерархии свойство
mock.Setup(foo => foo.Bar.Baz.Name).Returns("baz");
- Любое свойство
- Обратные вызовы (callback)
- Без параметров
mock.Setup(foo => foo.Execute("ping")) .Returns(true) .Callback(() => calls++);
- С параметром
mock.Setup(foo => foo.Execute(It.IsAny<string>())) .Returns(true) .Callback((string s) => calls.Add(s));
- С параметром, немного другой синтаксис
mock.Setup(foo => foo.Execute(It.IsAny<string>())) .Returns(true) .Callback<string>(s => calls.Add(s));
Несколько параметров
mock.Setup(foo => foo.Execute(It.IsAny<int>(), It.IsAny<string>())) .Returns(true) .Callback<int, string>((i, s) => calls.Add(s));
До и после вызова
mock.Setup(foo => foo.Execute("ping")) .Callback(() => Console.WriteLine("Before returns")) .Returns(true) .Callback(() => Console.WriteLine("After returns"));
Проверка (Mock объект сохраняет количество обращений к своим параметрам, тем самым мы также можем проверить правильно ли был исполнен код)
- Обычная проверка, что был вызван метод Execute с параметром “ping”
mock.Verify(foo => foo.Execute("ping"));
- С добавлением собственного сообщения об ошибке
mock.Verify(foo => foo.Execute("ping"), "When doing operation X, the service should be pinged always");
- Не должен был быть вызван ни разу
mock.Verify(foo => foo.Execute("ping"), Times.Never());
- Хотя бы раз должен был быть вызван
mock.Verify(foo => foo.Execute("ping"), Times.AtLeastOnce()); mock.VerifyGet(foo => foo.Name);
- Должен был быть вызван именно сеттер для свойства
mock.VerifySet(foo => foo.Name);
- Должен был быть вызван сеттер со значением “foo”
mock.VerifySet(foo => foo.Name = "foo");
- Сеттер должен был быть вызван со значением в заданном диапазоне
mock.VerifySet(foo => foo.Value = It.IsInRange(1, 5, Range.Inclusive));
Хорошо, этого нам пока хватит, остальное можно будет почитать здесь:
https://code.google.com/p/moq/wiki/QuickStart
Возвращаемся обратно в UnitTestSetupFixture.cs (/Setup/UnitTestSetupFixture.cs) и инициализируем конфиг:
protected virtual void InitRepository(StandardKernel kernel) { kernel.Bind<MockRepository>().To<MockRepository>().InThreadScope(); kernel.Bind<IRepository>().ToMethod(p => kernel.Get<MockRepository>().Object); }
Проверим какой-то наш вывод, например класс /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:
xcopy $(SolutionDir)LessonProject\Web.config $(ProjectDir)Sandbox\ /y
При каждом запуске билда мы копируем 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):
public interface IAuthCookieProvider { HttpCookie GetCookie(string cookieName); void SetCookie(HttpCookie cookie); }
И реализуем его для 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):
public class MockHttpSessionState : Mock<HttpSessionStateBase> { Dictionary<string, object> sessionStorage; public MockHttpSessionState(MockBehavior mockBehavior = MockBehavior.Strict) : base(mockBehavior) { sessionStorage = new Dictionary<string, object>(); this.Setup(p => p[It.IsAny<string>()]).Returns((string index) => sessionStorage[index]); this.Setup(p => p.Add(It.IsAny<string>(), It.IsAny<object>())).Callback<string, object>((name, obj) => { if (!sessionStorage.ContainsKey(name)) { sessionStorage.Add(name, obj); } else { sessionStorage[name] = obj; } }); } }
Создаем FakeAuthCookieProvider.cs (/Fake/FakeAuthCookieProvider.cs):
public class FakeAuthCookieProvider : IAuthCookieProvider { [Inject] public HttpCookieCollection Cookies { get; set; } public HttpCookie GetCookie(string cookieName) { return Cookies.Get(cookieName); } public void SetCookie(HttpCookie cookie) { if (Cookies.Get(cookie.Name) != null) { Cookies.Remove(cookie.Name); } Cookies.Add(cookie); } }
Фух! Инициализируем это в UnitTestSetupFixture.cs (/Setup/UnitTestSetupFixture.cs):
protected virtual void InitAuth(StandardKernel kernel) { kernel.Bind<HttpCookieCollection>().To<HttpCookieCollection>(); kernel.Bind<IAuthCookieProvider>().To<FakeAuthCookieProvider>().InSingletonScope(); kernel.Bind<IAuthentication>().ToMethod<CustomAuthentication>(c => { var auth = new CustomAuthentication(); auth.AuthCookieProvider = kernel.Get<IAuthCookieProvider>(); return auth; }); }
Заметим, что 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:
Install-Package Ninject Install-Package Moq Install-Package 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"));
— получаем подключение к БД.
TestDbName = string.Format("{0}_{1}", NameDb, DateTime.Now.ToString("yyyyMMdd_HHmmss"));
Создаем наименование тестовой БД.
//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)
connectionString = config.ConnectionStrings("ConnectionString").Replace(NameDb, TestDbName);
— меняем connectionString.
И теперь можем спокойно проинициализировать 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:
xcopy $(SolutionDir)LessonProject\Web.config $(ProjectDir)Sandbox\ /y
Но кстати, иногда БД нет необходимости удалять. Например, мы хотим заполнить базу кучей данных автоматически, чтобы проверить поиск или пейджинг. Это мы рассмотрим ниже. А сейчас тест – реальное создание в БД записи:
[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:
public interface IMailSender { void SendMail(string email, string subject, string body, MailAddress mailAddress = null); }
/LessonProject/Tools/Mail/MailSender.cs:
public class MailSender : IMailSender { [Inject] public IConfig Config { get; set; } private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); public void SendMail(string email, string subject, string body, MailAddress mailAddress = null) { try { if (Config.EnableMail) { if (mailAddress == null) { mailAddress = new MailAddress(Config.MailSetting.SmtpReply, Config.MailSetting.SmtpUser); } MailMessage message = new MailMessage( mailAddress, new MailAddress(email)) { Subject = subject, BodyEncoding = Encoding.UTF8, Body = body, IsBodyHtml = true, SubjectEncoding = Encoding.UTF8 }; SmtpClient client = new SmtpClient { Host = Config.MailSetting.SmtpServer, Port = Config.MailSetting.SmtpPort, UseDefaultCredentials = false, EnableSsl = Config.MailSetting.EnableSsl, Credentials = new NetworkCredential(Config.MailSetting.SmtpUserName, Config.MailSetting.SmtpPassword), DeliveryMethod = SmtpDeliveryMethod.Network }; client.Send(message); } else { logger.Debug("Email : {0} {1} \t Subject: {2} {3} Body: {4}", email, Environment.NewLine, subject, Environment.NewLine, body); } } catch (Exception ex) { logger.Error("Mail send exception", ex.Message); } } }
/LessonProject/Tools/Mail/NotifyMail.cs:
public static class NotifyMail { private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); private static IConfig _config; public static IConfig Config { get { if (_config == null) { _config = (DependencyResolver.Current).GetService<IConfig>(); } return _config; } } private static IMailSender _mailSender; public static IMailSender MailSender { get { if (_mailSender == null) { _mailSender = (DependencyResolver.Current).GetService<IMailSender>(); } return _mailSender; } } public static void SendNotify(string templateName, string email, Func<string, string> subject, Func<string, string> body) { var template = Config.MailTemplates.FirstOrDefault(p => string.Compare(p.Name, templateName, true) == 0); if (template == null) { logger.Error("Can't find template (" + templateName + ")"); } else { MailSender.SendMail(email, subject.Invoke(template.Subject), body.Invoke(template.Template)); } } }
/LessonProject/App_Start/NinjectWebCommon.cs:
private static void RegisterServices(IKernel kernel) {… kernel.Bind<IMailSender>().To<MailSender>(); }
Ну и в LessonProject.UnitTest добавим MockMailSender (/Mock/Mail/MockMailSender.cs):
public class MockMailSender : Mock<IMailSender> { public MockMailSender(MockBehavior mockBehavior = MockBehavior.Strict) : base(mockBehavior) { this.Setup(p => p.SendMail(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MailAddress>())) .Callback((string email, string subject, string body, MailAddress address) => Console.WriteLine(String.Format("Send mock email to: {0}, subject {1}", email, subject))); } }
В UnitTestSetupFixture.cs (/LessonProject.UnitTest/Setup/UnitTestSetupFixture.cs):
protected virtual IKernel InitKernel() { … kernel.Bind<MockMailSender>().To<MockMailSender>(); kernel.Bind<IMailSender>().ToMethod(p => kernel.Get<MockMailSender>().Object); return kernel; }
Запускаем, тесты пройдены, но на почту уже ничего не отправляется.
=============== =====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):
protected static bool removeDbAfter = false;
В Web.config установим соединение с тестовой БД:
<add name="ConnectionString" connectionString="Data Source=SATURN-PC;Initial Catalog=LessonProject_20130314_111020;Integrated Security=True;Pooling=False" providerName="System.Data.SqlClient" />
И запустим сайт:
Итог
В этом уроке мы рассмотрели:
- Принципы TDD и когда они не срабатывают
- NUnit и как с ним работать
- Mock и как с ним работать
- Unit-тесты и как этот инструмент позволяет улучшить нам качество кода
- Integration-тесты, и как мы можем их использовать
Тестирование – это очень большая область, это даже отдельная профессия и склад ума (не совсем программистский). И качество кода будет зависеть не только от применения технологий, хотя, бесспорно, соблюдение логических принципов TDD и внутренних процессов при разработке программ позволяет избежать множества ошибок. Написание тестов – не панацея от всех бед, это инструмент, и важно правильно им пользоваться…
Мы обошли вниманием тестирование клиентской части, и честно говоря, я не знаю, как это должно происходить. В JQuery только в октябре 2011го начали развивать проект qUnit, но информации по нему почти нет.
Все исходники находятся по адресу https://bitbucket.org/chernikov/lessons - Без параметров