Click here to Skip to main content
15,393,946 members
Articles / DevOps / Automation
Article
Posted 24 Feb 2015

Stats

44.9K views
8 bookmarked

Tips to fix CodedUI tests

Rate me:
Please Sign up or sign in to vote.
4.68/5 (9 votes)
24 Feb 2015CPOL17 min read
This article introduces some techniques to fix CoedUI automation tests, including a few generic approaches instead of much more specific functional definitions to handle problematic components such as Dialog and Table, SQL template to handle complex queries that generate test data and JavaScript bas

Introduction

After working with CodedUI for 4 months by fixing some Microsoft CodedUI tests, now it is a time for me to summarize the techniques and tips achieved from this experience before moving to mobile tests.

This article would focus on the conceptual realization instead of compilable codes due to property restriction of my company. Hopefully, this can provide some clues or inspired automation testers in this area to cope with problems they are facing. <o:p>

Background

Microsoft Coded UI testing (CUIT) framework provides a good support of Windows Applications, Web Application, WPF applications, SharePoint, Office Client Applications Dynamics CRM Web Client applications. For web application, the orthodox way to develop CodedUI tests is heavily rely on the UIMap generated by Microsoft tools. However, that means if the web application changes, the page objects and methods must be updated accordingly, that actually leads to problems in long term. In this article, I would present some techniques, though not recommended by Microsoft, to fix some common problems effectively and efficiently.

Topics

Using UIMap.designer.cs with caution

The first challenge encounterd by me came with Dialog handling, and it actually exposed a fundamental problem of Microsoft CodedUI codes template for any *.designer.cs: just as other typical CUIT test, the codes of DialogWindow is generated with Coded UI as this:

C#
public DialogWindow DialogWindow
{
    get
    {
        if ((this.mDialogWindow == null))
        {
            this.mDialogWindow = new DialogWindow();
        }
        return this.mDialogWindow;
    }
}

This automatically generated getter is used to locate some dialog windows emerged during the test as its name suggested and the same instance (this.UIMap.somePage.DialogWindow) is used in multiple tests to refer multiple dialog windows. From time to time, the tests would failed when using it to refer and dismiss some pop-up dialogs with exceptions saying the referred DialogWindow instance is stale or something. Such errors happened because the mDialogWindow instance is initially null, thus when the DialogWindow is first called, the mDialogWindow would be instantialized to point to a live dialog. However, after using it to dismiss the dialog, the mDialogWindow might still keep holding a dialog instance that has been closed and disposed. Consequently, when CodedUI framework uses DialogWindow to match another modal dialog, it won't bother to search because mDialogWindow does hold something that is not existed any longer and lead to failed tests.

Apparently, the above codes shall be generated like this:

C#
public DialogWindow DialogWindow
{
    get
    {
        if ((this.mDialogWindow == null || !this.mDialogWindow.Exists))
        {
            this.mDialogWindow = new DialogWindow();
        }
        return this.mDialogWindow;
    }
}

However, properties like this are generated automatically in UIMap.designer.cs, thus any modification would always be overriden.

Instead of moving properties like this from UIMap.designer.cs to UIMap.cs, a static property within the UIMap.cs or Dialog.cs can be decalred like this:

C#
private static DialogWindow currentDialog = null;
public static DialogWindow CurrentDialog
{
    get
    {
        if (currentDialog == null || !currentDialog.DialogWindow.Exists)
        {
            currentDialog = new DialogWindow();
        }
        return currentDialog;
    }
}

Replacing variables like "UIMap.DialogWindow" with "UIMap.CurrentDialog" could then make Dialog window handling much more reliable. Because CUIT uses the same types of code to refer and use the controls, that is a very fundamental mistake: you have to reset the variables like mDialogWindow to guarantee the elements referred by the properties are still there; and to make it worse, adding "AlwaysSearch" to the "Search Configuration" doesn't work as it should be and that is why I prefer finding the element with JavaScript before using it.

Extension Methods of String

In many scenarios, the testers validate web app function normally by comparing the displayed results with some expected strings, but defining these strings exactly same as the products is definitely not a good idea. Taken the project I have fixed for example, there are HUNDREDs of methods defined like this:

C#
public void AssertSomeMessage(string dialogMessage, string accountNumber, string billerName, decimal amount, DateTime startDate)
{
    var expected = string.Format(
        "You are about to do something from your xxxx account {0} to {1} for the amount of {2} scheduled for the {3}.{4}Is this Correct?",
        accountNumber, billerName, Format.AsCurrency(amount), Format.AsShortDate(startDate), Environment.NewLine);

    StringAssert.Contains(dialogMessage, expected);
}

As you can see, even an extra space of the format string would result in assertion failure and since the products are always changing, it is no surprise tests depending on these methods would keep failing. Actually, maintaining methods like this is a mission impossible and also, very likely, useless:

  1. The tests must keep synchroize with any changes made by developers to maintain these functions, that is a big challenge to the maintainability, especailly when the tests concern only some key parameters like "accountNumber", "decimal", "startDate" to validate the server process the previous operation properly.
  2. It is really annoying to be alerted when the actual message doesn't match with the expected one with alert like "'A long message containing this and that.' doesn't contain 'A long message containing this and that.'" when there exists only minor difference like the extra SPACE character of the second string.

To assert if a message contains every keywords, we can iterate these parameters to see if they are contained by the object string, and Method extension and "params" of C# could make it extremely convenient with a single method:

C#
public static bool ContainsAll(this string content, params object[] keys)
{
    foreach (object k in keys)
    {
        if (k is decimal)
        {
            decimal amount = (decimal)k;
            if (!content.Contains(amount.ToString("$#,##0.00")) && !content.Contains(amount.ToString("#,##0.00")) && !content.Contains("$"+amount))
            {
                return false;
            }
        }
        else if (k is DateTime)
        {
            DateTime date = (DateTime)k;
            if (!content.Contains(date.ToString("dd/MM/yyyy"))
                && !content.Contains(date.ToString("d-MMM-yyyy")))
            {
                return false;
            }
        }
        else
        {
            if (content.IndexOf(k.ToString(), StringComparison.InvariantCultureIgnoreCase) < 0)
            {
                return false;
            }
        }
    }
    return true;
}

For my working projects, only decimal and DateTime keys shall be treated specially because they are always converted to two formats and either would be acceptable. For other types of data, the default ToString() would be used and ignoring the case for simplicity. As a result, the previous AssertSomeMessage(string dialogMessage, string accountNumber, string billerName, decimal amount, DateTime startDate) can be replaced with Assert.IsTrue(dialogMessage.ContainsAll(accountNumber, billerName, amount, startDate) or Assert.IsTrue(dialogMessage.ContainsAll("You are about to", accountNumber, billerName, amount, startDate).

Another extension method was also defined to facilitate Assertion (notice that I have iterate the keys intentionally to highlight the missing information when Assertion is failed):

C#
public static void AssertContainsAll(this string content, params object[] keys)
{
    foreach(key in keys)
    {
        Assert.IsTrue(content.ContainsAll(key));
    }
}

Now for the previous calling to "AssertSomeMessage(dialogMessage, accountNumber, billerName, amount, startDate)", it can be replaced with "dialogMessage.AssertContainsAll(accountNumber, billerName, amount, startDate)" or "dialogMessage.AssertContainsAll(accountNumber, amount)" and Visual Studio would highlight which keyword is missing to make the assertion fail.

Evaluating rows of a table

Selecting a row from a table also result in many failed tests. The designer of these tests developed a full set of wrapper of HtmlTable, HtmlRow, HtmlCell and even some TableColumnAttribute to access specific columns of each rows of a table with totally 200+ classes by defining column indexes attributes with EACH column properties, as well as maybe thousands of properties/functions to access cell content or perform operations like matching/clicking/inputing. Again, coupling the codes with detail product implementation with such huge amount of codes brings no benefit: whenever the products changed the layout of a table, the column indexes have to be adjusted manually to allow the column classes to be mapped correctly.

Initially, I have planned to adapt the methods discussed in my previous article about a wrapper of WebDriver, that is, accessing each cell with rowIndex and columnIndex/columnHeader. However, that still means a fearsome amout of coding for me. Then I asked myself: supposing we are just trying to locate a row with some keywords, is it really uncessary to position first to some cells then extract exact text within them? Actually, treating the content of a row as a whole provides enough clues to achieve this goal with much more convenience.

Actually, finding a row from a table is very similar to evaluating a dialog to see if it contains all expected keywords, thus matching rows with the string.ContainsAll(params object[] keys) could have most of such job done with an extention method to HtmlTable:

public static HtmlRow FindRow(this HtmlTable table, params object[] keys)
{
    table.TopParent.WaitForControlReady(60000);
    int rowCount = table.RowCount;

    for (var rowIndex = 0; rowIndex < rowCount; rowIndex++)
    {
        HtmlRow row = table.Rows[rowIndex] as HtmlRow;
        if (row != null && row.InnerText.ContainsAll(keys))
            return row;
    }

    throw new InvalidOperationException("Row could not be found meeting expected criteria.");
}

Instead of comparing text of specific cells, extracting the InnerText of a whole row can still differentiate one row from another if it contains some unique strings like Account number for those failed tests to be fixed. As a result, because the Table definitions of previous UIMap can still be used to find the right HtmlTable instances, I can abandon and leave the previous Row/Column defintions untouched by replacing previous complex function calls with a single line of codes like this:

SomeTableDefinedInUIMap.FindRow("AccountNumber", ...).Click();

Noticably, the FindRow() function use row.InnerText to do comparison because HtmlElement of CodedUI dosen't expose properties like DisplayText. Besides of the text displayed on the table, the InnerText might actually contain some JavaScript codes, but that doesn't change the matching result even if the product changes its layout dramatically. In addition, as a very generic function, it is easy to append more logics, such as "

table.TopParent.WaitForControlReady(60000)

" which means that the browser shall wait 1 minute to get the page loaded, to this single function to ensure all such operations more reliable and that would be very hard when there are hundreds similar functions need to be maintained.

After getting the right row, usually clicking it could make it selected in my projects. However, maybe out of debugging purposes, there is one and only one table defines onClick() for a radio button within the first cell of the row instead of the row. In this case, clicking the radio button with a cell can still be wrapped with an extra step:

C#
public void SelectFromAccount(string fromAcc, string nextPayDateString="")
{
    HtmlRow fromRow = SomeTableDefinedInUIMap.FindRow(fromAcc, nextPayDateString);
    HtmlRadioButton radioBtn = new HtmlRadioButton(fromRow);
    radioBtn.Find();
    radioBtn.ClickByScript();
}

By defining radioBtn as new HtmlRadioButton(fromRow) instead of new HtmlRadioButton(someCellWithinFromRow), CodedUI can still find it even without information you can find from the UIMap.designer.cs. the last line of ClickByScript(this HtmlControl control) is another useful extension method designed to replace problematic Mouse.Click() or HtmlControl.Click() and I will elaborate my consideration and mechanism at the last part of this article.

There are also many tests heavily depended on some dynamic information. For example, there are many functions were defined to find rows by not only matching the account number, but also comparing some date in future. For more complex row finding, it is also possible by apply this function:

C#
public static HtmlRow FindRow(this HtmlTable table, Predicate<HtmlRow> predicate)
{
    int rowCount = table.RowCount;
    for (var rowIndex = 0; rowIndex < rowCount; rowIndex++)
    {
        HtmlRow row = table.Rows[rowIndex] as HtmlRow;
        if (row != null && predicate(row))
            return row;
    }
    throw new InvalidOperationException("Row could not be found meeting expected criteria.");
}

A sample to call this function looks like this:

C#
var fromRow = table.FindRow(row => {
    if (!row.InnerText.Contains(fromAcc))
        return false;

    HtmlCell nextDueDateCell = new HtmlCell(row);
    nextDueDateCell.SearchProperties.Add(HtmlCell.PropertyNames.ColumnIndex, "2", PropertyExpressionOperator.EqualTo);
    DateTime nextDueDate = new DateTime();
    var result = nextDueDateCell.TryFind() && DateTime.TryParse(nextDueDateCell.InnerText, out nextDueDate)
        && nextDueDate >= expectedDate;
    return result;
});

Noticeably, in this function, the index of the cell containing the date is fixed to "2". As a result, if the table changed, tests calling it might fail unless the method is updated accordingly; so for me, this is not a good practice and I have managed to get the specific future date from data mining as an extra key to call the previous FindRow() like this:

C#
SomeTable.FindRow(account, specificDate)

Dismiss Dialogs

Modal dialog windows are always problematic in CodedUI tests. Actually, the PC running CodedUI tests may even be hang when one unexpected dialog emerged and without being closed, then the SearchTimeoutMinutes setting of CodedUI.Playback.PlaybackSettings would fail to work, and only when the test timeout setting of .testsettings files could cause the test controller/agent realize the involved test is time-out. You can verify this bug on your PC: when one modal dialog is not closed, the following operations would just wait until the test is time-out.

In many cases, the test need to dismiss some warning dialogs before proceeding to simulate user operations. Officially, Microsoft would recommend recording closing these dialogs with a lot of classes generated; that is again, not an efficient way to fix tests that just need to dismiss these dialogs.

The article of “Intercept and manage windows…” suggests a much better way and I would prefer using some static methods/listeners to achieve this goal if I develop a project from scratch. But to fix existing failed tests, calling a generic function could be more convenient:

C#
 public static bool DismissDialog(int waitMillis = 5*1000)

{

    WinControl dialog = new WinControl();
    dialog.SearchProperties[UITestControl.PropertyNames.ControlType] = "Dialog";
    dialog.SearchProperties[UITestControl.PropertyNames.ClassName] = "#32770";
    //dialog.WindowTitles.Add("Title of the dialog");

    if (dialog.WaitForControlExist(waitMillis))
    {
        Playback.Wait(5 * 1000);
        TheBrowserInstance.PerformDialogAction(Microsoft.VisualStudio.TestTools.UITest.Extension.BrowserDialogAction.Ok);
        return true;
    }
    return false;
}

To get the current browser, you can modify the codes to refer any activeControl.topParent, then calling this method would close a dialog window emerged within 5 seconds. To close multiple dialogs, you can simply use a timer and loop to close any matched dialogs within a specific period.

SQL Template to query/create Test Data

A good point of the projects I have worked with is that the test data (such as account number, user ID, scheduled transaction date and etc.) is generated dynamically by querying SQL database instead of fetching static data from .csv files. However, the original data within the database might be stale and thus data-mining would fail: for example, there are about 10 tests failed because they need some account with transactions scheduled in future but querying data of several months ago would always return 0 row. Consequently, the query for these tests must follow these steps:

1) Query as usual, if there does exist some qualified data, or there are enough qualified data, return them immediately;
2) Otherwise some candidate rows shall be selected, then new rows shall be generated with just a small set of the columns modified and inserted into the involved table before deleted the original rows (some keys must be modified, so update method doesn't work). Then query as usual again, then comes the qualified data.

Because I cannot simply update the candidate rows when some keys are to be changed thus leave 100+ columns untouched and I also have to merge both cases into a single query, it is prefer to use a template to modify all existing queries, and temporary table and scroll cursor are used to compose a template as below to make this task much easier.

SQL
DECLARE @resultCount int
SET @resultCount = 10

IF OBJECT_ID('TempDB..#resultTable') IS NOT NULL
  DROP TABLE #resultTable
IF OBJECT_ID('TempDB..#tempTable') IS NOT NULL
  DROP TABLE #tempTable

/*First, run query as usual, but save the result to a temp table*/
SELECT TOP (@resultCount)
    table1.key1
    , table1.key2
    , table2.account
    , table2.key3
    , table2.key4
    , table2.nextTransactionDate
INTO #resultTable
from table1
    inner join table3 on table1.key1 = table3.key1
    inner join table2 on table2.key2 = right(table1.key2, 16)
WHERE
    cast(table2.nextTransactionDate-1 as datetime) > sysdatetime()
    and (table2.term_date = 0 or cast(table2.term_date-1 as datetime)>  getdate())
    -- A lot of other select criteria
GROUP BY table1.key2, table1.key1, table2.account, table2.key3, table2.key4, table2.key5, table2.key6, table2.nextTransactionDate
--SELECT * FROM #resultTable

BEGIN TRANSACTION
/* If there is no qualified data, or there are not enough qualified data, begin updating database to generate expected test data */
IF CAST( (SELECT count(*) FROM #resultTable) as int) <> @resultCount    -- In case there are not enough qualified data
--IF NOT EXISTS (SELECT * FROM #resultTable)    -- In case there is no qualified data
BEGIN
    /* Using the #resultTable to store the items to be modified*/
    -- Step 1) clear #resultTable
    DELETE FROM #resultTable;

    -- Step 2) fetch candidate data to #resultTable
    INSERT INTO #resultTable
    SELECT TOP (@resultCount)
        table1.key1
        , table1.key2
        , table2.account
        , table2.key3
        , table2.key4
        , table2.nextTransactionDate
    FROM table1
        inner join table3 on table1.key1 = table3.key1
        inner join table2 on table2.key2 = right(table1.key2, 16)
    WHERE
        /* Then use different select criteria to get data that can be modified afterwards*/
        /*To get some existing MONTHLY records that are outdated and with no term_date specified */
        cast(table2.nextTransactionDate-1 as datetime) < sysdatetime()
        AND DAY(CAST(table2.nextTransactionDate as datetime)) < 29 --For simplicity, just take those records with day of the nextTransactionDate is less than 29
        and table2.term_date = 0
        AND table2.FREQ = 0x3031    --Monthly Payment
        -- A lot of other select criteria
    GROUP BY table1.key2, table1.key1, table2.account, table2.key3, table2.key4, table2.key5, table2.key6, table2.nextTransactionDate

    --SELECT * FROM #resultTable

    -- Step 3) Create a temp table #tempTable to keep the rows need to be changed
    SELECT * INTO #tempTable from table2 WHERE 1=0

    -- Step 4) Declare variables to keep the original items that would be updated later
    DECLARE @key3 binary(3)
    DECLARE @key4 binary(8)
    DECLARE @key5 binary(8)
    DECLARE @key6 binary(8)
    DECLARE @nextTransactionDate decimal(9,0)

    -- Step 5) Scroll Cursor is used to iterate target table2 rows
    DECLARE cur SCROLL CURSOR FOR
        SELECT key3, key4, key5, key6, nextTransactionDate FROM #resultTable
    OPEN cur
    FETCH NEXT FROM cur INTO @key3, @key4, @key5, @key6, @nextTransactionDate
    -- Step 6) Iterate the table to be modified and keep a copy of the target rows to #tempTable
    /* ********Notice: all rows involved will be reserved in this way !!!!!!!!!!!! ********* */
    WHILE @@FETCH_STATUS = 0 BEGIN
        -- Get the copy of table2 record that is uniquely identified
        INSERT INTO #tempTable
            SELECT * FROM table2
                WHERE table2.key3 = @key3 AND table2.key4 = @key4 AND table2.key5 = @key5 AND table2.key6 = @key6
        FETCH NEXT FROM cur INTO @key3, @key4, @key5, @key6, @nextTransactionDate
    END

    --SELECT cast(key4 as varchar) AS OLD_PROS_DAY, cast(nextTransactionDate as datetime) AS OLD_NEXT_PAY, * FROM #tempTable

    -- Step 7) Declare variables to store values to be used to replace the original values
    DECLARE @dayDiff decimal(9,0)
    DECLARE @newNextPayDate decimal(9,0)
    DECLARE @firstOfNextMonth decimal(9,0)
    SET @firstOfNextMonth = CAST(DATEADD(month, DATEDIFF(month, 0, GETDATE()) + 1, 0) as decimal(9,0));
    --SELECT @firstOfNextMonth, convert(datetime, @firstOfNextMonth, 112)

    -- Step 8) Use the same curson to iterate rows kept in #tempTable
    FETCH FIRST FROM cur INTO @key3, @key4, @key5, @key6, @nextTransactionDate
    WHILE @@FETCH_STATUS = 0 BEGIN
        -- Update the copy by changing its nextTransactionDate, YYYYMMDD
        SET @dayDiff = @nextTransactionDate - cast(cast(cast(@key4 as varchar) as datetime) as decimal(9,0));
        SET @newNextPayDate = @firstOfNextMonth + DAY(CAST(@nextTransactionDate as datetime)) - 1;
        --SELECT @dayDiff as DAY_DIFF, @newNextPayDate as NEW_NEXT

        -- Step 9) Change the data within #tempTable to desired ones
        UPDATE #tempTable
            SET nextTransactionDate = @newNextPayDate
                , key4 = cast(convert(varchar(MAX), Cast(@newNextPayDate-@dayDiff as datetime), 112) as binary(8))
        WHERE key3 = @key3 AND key4 = @key4 AND key5 = @key5 AND key6 = @key6
        -- Step 10) Delete original row from the affected table
        DELETE FROM table2
            WHERE key3 = @key3 AND key4 = @key4 AND key5 = @key5 AND key6 = @key6

        FETCH NEXT FROM cur INTO @key3, @key4, @key5, @key6, @nextTransactionDate
    END

    --SELECT cast(key4 as varchar) AS NEW_PROS_DAY, cast(nextTransactionDate as datetime) AS NEW_NEXT_PAY, * FROM #tempTable

    -- Step 11) Don't forget to release the resources
    CLOSE cur
    DEALLOCATE cur

    -- Step 12) If everything's fine, insert the modified rows back to the target table
    INSERT INTO table2 SELECT * FROM #tempTable

END

-- Step 13) Now query+delete+insert all happened as expected, commit the transaction
COMMIT
--ROLLBACK

-- Return results kept in #resultTable
SELECT KEY1, ACCOUNT, nextTransactionDate FROM #resultTable

-- Drop the temp tables
IF OBJECT_ID('TempDB..#resultTable') IS NOT NULL
  DROP TABLE #resultTable
IF OBJECT_ID('TempDB..#tempTable') IS NOT NULL
  DROP TABLE #tempTable

Amend methods with Validation and Retry

Most of the time, we develop functions expecting everything happen step by step with nothing unusual happens, however, for automation web testing execution, it is better to be cautious with this pre-assumption especially when it involves network connectivity/response, database availability and even the testing framework or windows.

Taken an enterSMS() function for example, it just enters username and SMS code, that is fetched from a log file on server side, before clicking Login button with pseudo codes like this:

C#
public virtual void EnterCode(string id)
{
    var smsCode = getSmsCodeFromServer();
    usernameTxt.Text = id;
    smsCodeTxt.Text = smsCode;
    Mouse.Click(loginBtn);
}

Many tests would call this function before perform following operations in this way:

C#
public virtual void SomeTest()
{
    String id = getIdFromDataMining();
    EnterCode(id);
    //Now the user portal shall be displayed.
    Mouse.Click(someUserPortalElement);
    ...
}

From time to time, these tests would throw exception when they tried to click the elements that shall be displayed only after entering the user portal when entered SMS Code was wrong or even the CodedUI failed to enter all of the code. From my perspective, there are two issues shall be improved with the EnterCode() method:

  • EnterCode() shall include some logic to assert the login does succeed.
  • It shall also tolerate the failures caused by failing to get the correct code or entering the wrong code.

The first goal can be achieved by asserting some element disappear within a reasonable period, the second one can be done by calling itself recursively with an extra attempt parameter. The original function could be modified like this:

C#
public virtual void EnterCode(string id, int attemp=3)
 {
     var smsCode = getSmsCodeFromServer();
     usernameTxt.Text = id;

     smsCodeTxt.Text = smsCode;
     Mouse.Click(loginBtn);

     //Check if the login disappear within 20s, if not, then retry or fail immediately
     if (!loginBtn.WaitForControlNotExist(20*1000))
     {
         if ((attempts--) > 0)
         {
             //We can still try again by calling EnterCode() itself
             EnterCode(id, attemps);
         }
         else
         {
             Assert.Fail("Login failed!");
         }
     }
 }

In this way, the EnterCode() function could now be run at most 3 times without touching its caller, and the test result would show the right reason if it is due to Login failure.

TryFind() vs. WaitForControlXXX()

From the original tests that I have worked with, there were a lot of calling of TryFind() changed the test execution. The reason is that TryFind() would return true/false immediately when pages are still loading and its returned value would heavily depend on the responsiveness of the web server, so some tests would be fixed by simply replacing it with WaitForControlExist() which would block the current thread until timeout specified by default PlaybackSettings.WaitForReadyTimeout.

Personally, I prefer specifying timeout setting explicitly by calling WaitForControlReady(int millisecondsTimeout) or WaitForControlExist(int millisecondsTimeout). Somebody might accustomed with Playback.Wait(int thinkTimeMilliseconds) or Thread. Sleep(int millisecondsTimeout), but they might wait unnecessarily long. In addition, if combined with Assertion like this:

C#
Assert.IsTrue(someElement.WaitForControlReady(60*1000)); //Wait for 1 min

Unexpected delay could be highlighted immediately.

There are multiple WaitForControlXXX() functions defined with UITestControl, some of them could be very helpful but are usually neglected, for example:

  • WaitForControlCondition(Predicate<UITestControl> conditionEvaluator, int millisecondsTimeout), combined with LINQ, provides a very powerful means to evaluate any status of the target control.
  • WaitForControlPropertyEqual(string propertyName, object propertyValue, int millisecondsTimeout) enables tester to monitor changes of any attribute of the control effectively.
  • bool WaitForControlNotExist(int millisecondsTimeout), combined with Assert.IsTrue(), could be used to evaluate operations have successfully caused the page changing to another state.
  • bool WaitForControlExist(int millisecondsTimeout) and bool WaitForControlReady(int millisecondsTimeout) should be used from time to time before performing solid operations like clicking, selecting or typing. Usually, the former is good enough especially when such operations would be carried out only when the browser/control is ready. But they might return different values for a specific control: usually WaitForControlExist() would return true when the target control is displayed, but in some of my projects, WaitForControlReady() would return true several minutes after WaitForControlExist() returning true.

Operation with JavaScript

Initially, I tried to use JavaScript to speed up the test execution when I noticed that many tests would start first operation (such as selecting one option from a combo box, clicking on a link or button and etc.) several minutes after the pages loaded. This is hard to tolerate when running tests from my PC and I can only wait several minutes to run a single line of code. In these cases, though CodedUI cannot step forward, I can run JavaScript from command line of IE browser directly. So I have tried many means to try to make it run by calling ExecuteScript() before I realized that BrowserWindow would execute JavaScript only after WaitForControlReady() returning true. Fortunately, my efforts of developing JavaScript to replace native operations like Mouse.Click() could still be very helpful.

From time to time, CodedUI tests would fail to perform some very basic operations like clicking on a button or entering text to a text control. Personally, I think this is caused by Windows instead of CodedUI: sometime when I click on a line/button of a web page, I can hear the “Click” sound but the browser would simply do nothing. JavaScript would then be more effective when it operates directly on the element instead of via UI.

The key scripts I composed are listed below:

C#
        #region JavaScript function names
        public const string FindByCssFunction = "querySelector";
        public const string FindByIdFunction = "getElementById";
        public const string FindFirstByCssFunction = "querySelectorAll";
        public const string FindFirstByClassFunction = "getElementsByClassName";
        public const string FindFirstByNameFunction = "getElementsByName";
        public const string FindFirstByTagFunction = "getElementsByTagName";
        #endregion

        public const string GetElementByIdScript = @"
var result = document.getElementById(arguments[0]);
if (result)
    return result;

var frames = document.getElementsByTagName('frame');
if (arguments[1]) {
    var frame = frames[arguments[1]];
    if (frame.document)
        return frame.document.getElementById(arguments[0]);
    else
        return frame.contentWindow.document.getElementById(arguments[0]);
}

for(var i = 0; i < frames.length; i ++) {
    if (frames[i].document)
        result = frames[i].document.getElementById(arguments[0]);
    else
        result = frames[i].contentWindow.document.getElementById(arguments[0]);

    if (result) break;
}
return result;";

        public const string FrameGetElementByIdScript = @"
var result = arguments[0].contentDocument.getElementById(arguments[1]);
return result;";

        public const string GetFirstByCssScript = @"
var elements = document.querySelectorAll(arguments[0]);
if (elements.length)
    return elements[0];

var frames = document.getElementsByTagName('frame');
if (arguments[1]) {
    var frame = frames[arguments[1]];
    if (frame.document)
        return frame.document.querySelectorAll(arguments[0]);
    else
        return frame.contentWindow.document.querySelectorAll(arguments[0]);
}

for(var i = 0; i < frames.length; i ++) {
    if (frames[i].document)
        elements = frames[i].document.querySelectorAll(arguments[0]);
    else
        elements = frames[i].contentWindow.document.querySelectorAll(arguments[0]);
if (elements.length)
    return elements[0];
}
return null;";

        public const string GetClickablePoint = @"
var element = arguments[0];
var absoluteLeft = element.width/2;
var absoluteTop = element.height/2;

do {
absoluteLeft += element.offsetLeft;
absoluteTop += element.offsetTop;
element = element.parentElement;
}while(element)

var result = new Array();
result[0] = Math.round(absoluteLeft).toString();
result[1] = Math.round(absoluteTop).toString();
return result;";

        public const string GetAttributeScript = @"try{{return arguments[0].getAttribute('{0}');}}catch(err){{return null;}}";

        public enum FindFirstMethod
        {
            ById,
            ByCSS,
            FirstByCSS,
            FirstByClass,
            FirstByName,
            FirstByTag,
        }

The web app under test uses multiple frames as container to show different panels. To search one element with its ID, The JavaScript “GetElementByIdScript” would first search the root document, then after getting all frames, would try to iterate all frames to see if the element with specified ID could be found within one of them. The “FrameGetElementByIdScript” is used to search a single frame only give element ID. The “GetFirstByCssScript” provides a more generic means to search element with CSS selectors which however would normally return an array and actually only the first one is expected. The “GetAttributeScript” is used to retrieve attribute from a given element, notice that when there is an error catched, it shall return “null”.

To reuse the above scripts with different methods (by ID, by Class, by Name and etc.), the FindFirstMethod enum is defined and the default value ById would be used as below:

C#
public static HtmlControl FindControl(this BrowserWindow window, string locatorKey, FindFirstMethod method = FindFirstMethod.ById, string frameName = "body")
{
    if (window == null || !window.WaitForControlReady(DefaultWaitReadyTimeMillis))
        throw new Exception("Browser is not specified or is not ready after " + DefaultWaitReadyTimeMillis / 1000 + "s.");

    string script = null;
    switch (method)
    {
        case FindFirstMethod.ById:
            script = GetElementByIdScript;
            break;
        case FindFirstMethod.ByCSS:
            script = GetElementByIdScript.Replace(FindByIdFunction, FindByCssFunction);
            break;
        case FindFirstMethod.FirstByCSS:
            script = GetFirstByCssScript;
            break;
        case FindFirstMethod.FirstByClass:
            script = GetFirstByCssScript.Replace(FindFirstByCssFunction, FindFirstByClassFunction);
            break;
        case FindFirstMethod.FirstByName:
            script = GetFirstByCssScript.Replace(FindFirstByCssFunction, FindFirstByNameFunction);
            break;
        case FindFirstMethod.FirstByTag:
            script = GetFirstByCssScript.Replace(FindFirstByCssFunction, FindFirstByTagFunction);
            break;
        default:
            throw new NotSupportedException();
    }

    object result = null;
    Stopwatch watch = new Stopwatch();
    watch.Start();
    while (result == null && watch.ElapsedMilliseconds < 20 * 1000)
    {
        result = window.ExecuteScript(script, locatorKey, frameName);

        //To cope with the bug of BrowserWindow..ExecuteScript()
        var optionList = result as IList<object>;
        if (optionList != null)
        {
            var child = optionList.FirstOrDefault(o => o != null) as HtmlControl;
            result = (child == null || !child.Exists ) ? null : child.GetParent();
        }
    }
    return result as HtmlControl;
}

The script would then be finalized by replacing some keywords and run by a BrowserWindow instance. Noticeably, when trying to get a HtmlComboBox, CodedUI would wrongly return an array of its option children and that is why there is some special treatment to get a single HtmlControl.

CodedUI only defines BrowserWindow with virtual object ExecuteScript(string script, params object[] args), to make it easier to use, there are two helper methods introduced to target one specific control with or without extra parameters:

C#
public static object RunScript(this HtmlControl control, string script)
{
    if (control == null)
        throw new Exception("Failed to locating the control?!");

    BrowserWindow window = control.TopParent as BrowserWindow;
    if (window == null || !window.WaitForControlReady(DefaultWaitReadyTimeMillis))
        throw new Exception("Browser is not specified or is not ready after " + DefaultWaitReadyTimeMillis / 1000 + "s.");

    return window.ExecuteScript(script, control);
}

public static object RunScript(this HtmlControl control, string script, params object[] extraArguments)
{
    if (control == null)
        throw new Exception("Failed to locating the control?!");

    BrowserWindow window = control.TopParent as BrowserWindow;
    if (window == null || !window.WaitForControlReady(DefaultWaitReadyTimeMillis))
        throw new Exception("Browser is not specified or is not ready after " + DefaultWaitReadyTimeMillis / 1000 + "s.");

    var len = extraArguments.Length;
    object[] arguments = new object[len + 1];
    arguments[0] = control;
    for (int i = 0; i < len; i++)
    {
        arguments[i + 1] = extraArguments[i];
    }

    return window.ExecuteScript(script, arguments);
}

Then some helper functions are quite straightforward:

C#
public static string AttributeByScript(this HtmlControl control, string attributename)
{
    return control.RunScript(string.Format(GetAttributeScript, attributename)) as string;
}

public static string InnerText(this HtmlControl control)
{
    return control.RunScript("return arguments[0].innerText;") as string;
}

public static string InnerHTML(this HtmlControl control)
{
    return control.RunScript("return arguments[0].innerHtml;") as string;
}

public static string OuterHTML(this HtmlControl control)
{
    return control.RunScript("return arguments[0].outerHtml;") as string;
}

public static Point ClickablePointByScript(this HtmlControl control)
{
    object result = control.RunScript(GetClickablePoint);
    List<object> position = (List<object>)result;
    return new Point(int.Parse(position[0].ToString()), int.Parse(position[1].ToString()));
}

Their meanings are explained below:

  • SomeControl.AttributeByScript(attributename): to get Attribute of a control by attribute name;
  • SomeControl.InnerText(): would retrieve everything within the opening and closing tag of the control as innerText as used in section “Evaluating rows of a table”. It could be tuned to get displayed text.
  • SomeControl.InnerHtml()/OuterHtml(): returns InnerHtml and OuterHtml respectively.
  • SomeControl.ClickablePointByScript(): I have tried this to get the clickable point to avoid waiting CodedUI to be ready to click some link/button, but it doesn’t return before the target control is really ready.

The more useful methods are listed here:

C#
public const bool HighLightControlBeforeOperation = true;
public static void ClickByScript(this HtmlControl control)
{
    control.ShowByScript();

    if (HighLightControlBeforeOperation)
        control.HighlightByScript();

    control.RunScript("arguments[0].click();");
}

public static void SetValue(this HtmlControl control, string valueString)
{
    control.RunScript("arguments[0].value = arguments[1];", valueString);
}

public static void ShowByScript(this HtmlControl control)
{
    control.RunScript("arguments[0].scrollIntoView(true);");
}

public const string DefaultHighlightStyle = "color: green; border: solid red; background-color: yellow;";
public static void HighlightByScript(this HtmlControl control)
{
    //*/ Highlight by script: changing the style of concerned element
    var oldStyle = control.AttributeByScript("style");

    control.RunScript("arguments[0].setAttribute('style', arguments[1]);", DefaultHighlightStyle);
    System.Threading.Thread.Sleep(DefaultHighlightTimeMillis);
    if (oldStyle != null)
    {
        control.RunScript("arguments[0].setAttribute('style', arguments[1]);", oldStyle);
    }
    else
        control.RunScript(string.Format("arguments[0].removeAttribute('style');"));
}

Their meanings are explained below:

  • SomeEdit.SetValue(valueString): is used to input text to Edit control even when it is not displayed yet.
  • SomeControl.ShowByScript(): would make the control visible for further operation/observation.
  • SomeControl.HighlightByScript(): to modify the style of the target control to make it highlighted for several seconds.
  • SomeControl.ClickByScript(): might be the most useful method to fix tests. The ShowByScript() would be called to make the control visible, then HighlightByScript() would be called to mark it outstanding before perfroming “someControl.click()”. This design is out of intention: by performing operations 3 times(scrolling once, changing styles twice), it is very unlikely to miss clicking on the target control.

To use these scripts are also quite simple, taken Click() for example: for some problematic Mouse.Click(someControl), just replacing them with “someControl.ClickByScript()” would make many failed tests passed.

Points of Interest

The methods discussed in this article could also be applied to other testing framework. As you can see, it could be much more efficient to wrap some basic operations as extension methods instead of defining element/control specific functions.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

JIANGWilliam
Software Developer
Australia Australia
No Biography provided

Comments and Discussions

 
QuestionNice article Pin
Ryan Waltrip27-Oct-15 6:01
MemberRyan Waltrip27-Oct-15 6:01 
QuestionI am getting paid Pin
Member 1207823821-Oct-15 22:19
MemberMember 1207823821-Oct-15 22:19 
GeneralExcellent article Pin
Tomer Salem19-Oct-15 1:16
MemberTomer Salem19-Oct-15 1:16 
QuestionFinding control Pin
danaea17-Jun-15 10:32
Memberdanaea17-Jun-15 10:32 
AnswerRe: Finding control Pin
JIANGWilliam18-Jun-15 1:48
MemberJIANGWilliam18-Jun-15 1:48 
GeneralRe: Finding control Pin
danaea18-Jun-15 9:43
Memberdanaea18-Jun-15 9:43 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.