В этой статье я хочу показать решение, которое применил при тестирования legacy asp.net кода. Т.е. кода который был разработан без тестов, и уже перешел тот этап, когда небольшие изменения давали регрессионные ошибки.
Я сформулировал цель примерно так: "юнит тестирование любой из страниц, возможность подготовить необходимые входные данные, последующая валидация значений в контролах, а также валидация объекта Http.Response".
Проблемы.
Какие проблемы я встретил при разработке первого теста.
1. Page зависит от Http.Context и не может работать без него (в большинстве случаев).
2. Page_Load метод (entry point страницы) protected метод и не может быть вызван на прямую из теста.
3. Контролы помещенные на страницу инициализиются asp.net рантаймом (в контесте веб-приложения), поэтому при создании объекта страницы вне контекста, они все равны null.
4. Page может быть создан для загрузки страницы, и в тоже время для Postback.
Часть 1 Http.Context
В одном из блогов, я наткнулся на очень интересный класс - HttpSimulator. HttpSimulator позволяет, "подменить" Http.Context объект, для страницы так, как будто она запускается веб сервером. Сделан и задокументирован он классно, и идельно подходил для решения первой проблемы. Более детально можно ознакомится в блоге Haacked.
/// <summary> /// Validates that page is loaded with no exceptions /// </summary> [Test] public void Smoke() { using (var simulator = new HttpSimulator("/", @"c:\inetpub\")) { var request = simulator.SimulateRequest(new Uri(@"http://localhost/rd2010/ard2/closingsheet.aspx?company=0")); var page = new ard_closingsheet(); var context = new ASPNetContext(HttpContext.Current);После создания объекта request, HttpContext.Current корректно проинициализирован, и может быть использован в тесте.
Часть 2 Page_Load method
Page_Load является закрытым методом, поэтому его нельзя напрямую вызвать из теста. Чтобы обойти это, я добавил еще один метод _Page_Load, специально для тестирования. Реализация находится в _Page_Load, а Page_Load просто делегирует вызов к нему.
Часть 3 Page Controls
Если попробывать создать объект страницы и вызвать _Page_Load, то скорее всего будет NullReferenceException, так как объекты контролов на странице не инициализированны, так как страница запускается вне Web контекста. Необходимо иметь возможность подменить реальные контролы тестовыми, во время запуска тестов, и сохранить прежнюю функциональность во время работы приложения. Я пошел по самому простому пути: для тестов подготовить control-context (контейнер содержащий контролы), доступ к контролу на странице делать не на прямую, а через оберточную функцию. Создание control-context в тесте:Метод опробован и используется для тестирования старых и новых страниц, без необходимости использования каких либо дополнительный фреймворков, и веб сервера.
private static IDictionary<string, object> CreateControlList() { var controlList = new Dictionary<string, object> { {"closingsheet_company", new HtmlSelect()}, {"closingsheet_accountingyear", new HtmlSelect()}, {"closingsheet_periode1", new TextBox()}, {"cbxHideRowsWithZeros", new CheckBox()}, {"textAffiliated", new HtmlGenericControl()}, {"lblPostentries", new HtmlGenericControl()}, {"iconline_seperator_1", new HtmlTableCell()}, {"iconline_seperator_2", new HtmlTableCell()}, {"ColumnsHeaders", new HtmlTable()}, {"ColumnsHeadersLeft", new HtmlTable()}, {"ColumnsHeadersRight", new HtmlTable()}, {"ColumnsData", new HtmlTable()}, {"ClosingSheetLeft", new HtmlTable()}, {"ClosingSheetRight", new HtmlTable()}, {"ClosingSheetBalanceTable", new HtmlTable()}, {"rowcount", new HtmlInputHidden()}, {"company", new HtmlInputHidden()}, {"hidexportExcel", new HtmlInputHidden()}, {"sortarrow_account", new HtmlImage()}, {"sortarrow_code", new HtmlImage()}, {"filterselectimg", new HtmlImage()} }; return controlList; }_Page_Load будет принимать контекст как параметер, и сохранять ссылку на него:
// used for testing.. public void _Page_Load(ASPNetContext ctx, IDictionary<string, object> controls, object sender, EventArgs e, bool postback) { _controlList = controls;На страницу добавим метод доступа к контролу через контекст:
private object GetControl(string s) { if (_controlList == null) return null; object control = null; _controlList.TryGetValue(s, out control); return control; }Также необходимо изменить код страницы так, чтобы доступ контролам шел не напрямую, а через метод.. вот так:
((HtmlImage)GetControl("sortarrow_account")).Src = "img/sortarrow_down.gif";Наконец, чтобы страница работала в продакшн моде, нужно подготовить аналогичный контекст, который будет содержать ссылки на реальные контролы страницы. После всех изменения, Page_Load страницы выглядит так:
protected void Page_Load(object sender, EventArgs e) { var controlList = new Dictionary<string, object> { {"closingsheet_company", closingsheet_company}, {"closingsheet_accountingyear", closingsheet_accountingyear}, {"closingsheet_periode1",closingsheet_periode1}, {"cbxHideRowsWithZeros", cbxHideRowsWithZeros}, {"textAffiliated", textAffiliated}, {"lblPostentries", lblPostentries}, {"iconline_seperator_1", iconline_seperator_1}, {"iconline_seperator_2", iconline_seperator_2}, {"ColumnsHeaders", ColumnsHeaders}, //{"ColumnsHeadersLeft", ColumnsHeadersLeft}, //{"ColumnsHeadersRight", ColumnsHeadersRight}, {"ColumnsData", ColumnsData}, {"ClosingSheetLeft", ClosingSheetLeft}, {"ClosingSheetRight", ClosingSheetRight}, {"ClosingSheetBalanceTable", ClosingSheetBalanceTable}, {"rowcount", rowcount}, {"company", company}, {"hidexportExcel", hidexportExcel}, {"sortarrow_account", sortarrow_account}, {"sortarrow_code", sortarrow_code}, {"filterselectimg", filterselectimg} }; var postback = Page.IsPostBack; using (ASPNetContext ctx = new ASPNetContext(Context)) { _Page_Load(ctx, controlList, sender, e, postback); } }Часть 4 Postbacks
Страница дожна быть протестирована как на загрузку, так и на постбэк, поэтому я добавил еще один параметер, который решает эту проблему. Пример можно видеть сверху.
Вот и все! Страница готова для тестирования.
Несколько тестов для примера.
Тесты, тесты, тесты
Первый, самый простой тест, который показывает что загрузка страницы происходит без исключений:
Второй более сложный, он загружает страницу, проверяет корректность формата и значений вывода./// <summary> /// Validates that page is loaded with no exceptions /// </summary> [Test] public void Smoke() { using (var simulator = new HttpSimulator("/", @"c:\inetpub\")) { var request = simulator.SimulateRequest(new Uri(@"http://localhost/rd2010/ard2/closingsheet.aspx?company=0")); var page = new ard_closingsheet(); var context = new ASPNetContext(HttpContext.Current); //INIT var sessionVariables = CreateSessionVariables(); var controls = CreateControlList(); InitContext(context, sessionVariables); //ACT / POST page._Page_Load(context, controls, new object(), new EventArgs(), false); } }
/// <summary> /// Validates the amount in result column for account /// /// </summary> [Test] public void SheetCorrectValue() { using (var simulator = new HttpSimulator("/", @"c:\inetpub\")) { var request = simulator.SimulateRequest(new Uri(@"http://localhost/rd2010/ard2/closingsheet.aspx?company=0")); var page = new ard_closingsheet(); var context = new ASPNetContext(HttpContext.Current); //INIT var sessionVariables = CreateSessionVariables(); var controls = CreateControlList(); InitContext(context, sessionVariables); //ACT page._Page_Load(context, controls, new object(), new EventArgs(), false); //POST var closingSheetLeft = (HtmlTable)controls["ClosingSheetLeft"]; var data = (HtmlTable)controls["ColumnsData"]; // 0 - account, 2 - control holds account number..find a account with name "Debtors" int row = GetRowNumberWithValue(closingSheetLeft, 0, 2, "1100"); string cellValue = GetCellValue(data, row, 0, 0); // Expected 4 cells, as 4 colums in template (it gives now 6, as 2 additional columns present) Assert.Equals(cellValue, "12,750.00"); } }Использование HttpRespose и Postback
/// <summary> /// Same as SheetColumnsNumber but it validates cvs data (as input to Excel report) /// /// </summary> [Test] public void ExcelColumnsNumber() { // Do a post back and set hidexportExcel to true, output will be in http response using (var simulator = new HttpSimulator("/", @"c:\inetpub\")) { var request = simulator.SimulateRequest(new Uri(@"http://localhost/rd2010/ard2/closingsheet.aspx?closingsheet_accountingyear=2009&closingsheet_periode1=")); var page = new ard_closingsheet(); var context = new ASPNetContext(HttpContext.Current); //INIT var sessionVariables = CreateSessionVariables(); var controls = CreateControlList(); InitContext(context, sessionVariables); //ACT (with post) page._Page_Load(context, controls, new object(), new EventArgs(), false); ((HtmlInputHidden) controls["hidexportExcel"]).Value = "true"; // page is recreated and we do post pack page = new ard_closingsheet(); page._Page_Load(context, controls, new object(), new EventArgs(), true); //POST string response = request.ResponseText; string[] cvsLines = response.Split('\n'); string[] colums = cvsLines[0].Split(';'); // report contains of 14 columns for now, but of them are redudant.. Assert.Equals(colums.Length, 12); } }
Также для более продвинутых случаев, в конкст контров можно укладывать mock-объекты, использовать из в тесте и на странице по своему усмотрению.
