Alexander Beletsky's Development Blog: 2010-03

Testing: Тестирование legacy ASP.NET страниц (Web Server less testing)

В этой статье я хочу показать решение, которое применил при тестирования 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-объекты, использовать из в тесте и на странице по своему усмотрению.