- PVSM.RU - https://www.pvsm.ru -
Цель урока. Научиться создавать тесты для кода. NUnit. Принцип применения TDD. Mock. Юнит-тесты. Интегрированное тестирование. Генерация данных.
Тестирование для меня лично – это тема многих размышлений. Нужны или не нужны тесты? Но никто не будет спорить, что для написания тестов нужны ресурсы.
Рассмотрим два случая:
Видите ли, тут дилемма такова, что результат запуска сайта непредсказуемый. У меня было и так, что сделанный на коленке сайт запустился весьма бодро, а бывало, что крутую работу заказчик оплатил, но даже не принял. Итак, тактика поведения может быть такой:
Рассмотрим принцип TDD:
Например, было дано следующее исправление:
Мы решили добавить в блог поле тегов. Так как у нас уже существует много записей в блоге, то это поле решили сделать необязательным. Так как уже есть существующий код, то скаффолдингом не пользовались. Вручную проверили создание записи – всё ок. Прогнали тесты – всё ок. Но забыли добавить изменение поля в UpdatePost (cache.Tags = instance.Tags;). При изменении старой записи мы добавляем теги, которые собственно не сохраняются. При этом тесты прошли на ура. Жизнь — боль!
Что ж, как видно, мы нарушили основной принцип TDD – вначале пиши тест, который заваливается, а уже потом пиши код, который его обрабатывает. Но(!) тут есть и вторая хитрость — мы написали тест, который проверяет создание записи блога с тегом. Конечно, сразу же у нас это не скомпилировалось (т.е. тест не прошел), но мы добавили в ModelView что-то типа throw new NotImplementedException(). Всё скомпилировалось, тест горит красным, мы добавляем это поле с тегом, убирая исключение, тест проходит. Все остальные тесты тоже проходят. Принципы соблюдены, а ошибка осталась.
Что я могу сказать, на каждый принцип найдется ситуация, где он не сработает. Т.е. нет такого – отключили
тесты должны писаться быстро
Так какие же задачи мы решаем в основном на сайте:
Это основные действия. Как, например, проходит регистрация:
Создадим для всего этого юнит-тесты:
Приступим, пожалуй.
Идем по ссылке http://sourceforge.net/projects/nunit/ [3] и устанавливаем 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:
Например, проверяем первое, что возвращаем 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:
Откроем вкладку Test Explorer:
Если адаптер NUnit правильно был установлен, то мы увидим наш тест-метод.
Запускаем. Тест пройден, можно идти открывать шампанское. Стоооп. Это лишь самая легкая часть, а как быть с той частью, где мы что-то сохраняем. В данном случае мы не имеем БД, наш Repositary – null, ноль, ничего.
Изучим теперь класс и методы для инициализации (документация). SetUpFixture – класс, помеченный этим атрибутом, означает, что в нем есть методы, которые проводят инициализацию перед тестами и зачистку после тестов. Это относится к одному и тому же пространству имен.
Создадим класс 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 – это объект-пародия. Т.е. например, не база данных, а что-то похожее на базу данных. Мираж, в общем-то. Есть еще Stub – это заглушка. Пример метода заглушки:
public int GetRandom()
{
return 4;
}
Но мы будем использовать Mock:
Install-Package Moq
Определим, какое окружение есть у нас, чтобы мы проинициализировали для него Mock-объекты. В принципе, это всё, что мы некогда вынесли в Ninject Kernel:
И тут я сделаю небольшое замечание. Мы не можем вынести 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);
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");
var mock = new Mock<IFoo>();
var calls = 0;
mock.Setup(foo => foo.GetCountThing())
.Returns(() => calls)
.Callback(() => calls++);
mock.Setup(foo => foo.DoSomething(It.IsAny<string>())).Returns(true);
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);
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");
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 объект сохраняет количество обращений к своим параметрам, тем самым мы также можем проверить правильно ли был исполнен код)
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);
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 [4]
Возвращаемся обратно в 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. Это будет круто!
Что нам нужно сделать. Нам нужно:
Начнем. Для того чтобы взять Web.Config – нам нужно скопировать его в свою папку. Назовем её Sandbox. Теперь скопируем, поставим на pre-build Event в Project Properties:
xcopy $(SolutionDir)LessonProjectWeb.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.
В веб-приложении, когда мы уже исполняем код в контроллере, мы уже имеем какой-то заданный контекст, окружение, сформированное 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);
Теперь создадим 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);
}
}
Кроме этого создаем еще такие классы:
Все эти 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)LessonProjectWeb.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 и внутренних процессов при разработке программ позволяет избежать множества ошибок. Написание тестов – не панацея от всех бед, это инструмент, и важно правильно им пользоваться…
Мы обошли вниманием тестирование клиентской части, и честно говоря, я не знаю, как это должно происходить. В JQuery только в октябре 2011го начали развивать проект qUnit, но информации по нему почти нет.
Все исходники находятся по адресу https://bitbucket.org/chernikov/lessons [5]
Автор: chernikov
Источник [6]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/asp-net-mvc/31651
Ссылки в тексте:
[1] хостинг: https://www.reg.ru/?rlink=reflink-717
[2] мозги: http://www.braintools.ru
[3] http://sourceforge.net/projects/nunit/: http://sourceforge.net/projects/nunit/
[4] https://code.google.com/p/moq/wiki/QuickStart : https://code.google.com/p/moq/wiki/QuickStart
[5] https://bitbucket.org/chernikov/lessons: https://bitbucket.org/chernikov/lessons
[6] Источник: http://habrahabr.ru/post/176137/
Нажмите здесь для печати.