Automated web application testing (MS Unit Testing Framework + Selenium WebDriver C #). Part 2.2: Selenium API wrapper - WebElement

  • Tutorial
Selenium + C #
Introduction

Hello! In the previous part, I described the main problems that arise when working with Selenium WebDriver, and also gave an example of a Browser wrapper. It didn’t seem difficult, right?) Well, let's move on. We need to deal with the remaining problems:
  • The description of the element occurs simultaneously with its search, i.e. at the time the element is defined, it must exist in the browser. Very often solved by writing getter for each element. It's overkill and bad in terms of performance
  • ISearchContext.FindElements accepts only one parameter of type OpenQA.Selenium.By, i.e. we cannot search by several properties at once. Usually, an element is searched by the first criterion, and then the screening begins with the rest
  • The absence of many seemingly obvious methods and properties. For example: Exist, SetText, Select, SetCheck, InnerHtml, etc. Instead, we are forced to be content with Click, SendKeys, and Text.
  • Many problems on different browsers, for example on Firefox and Chrome, the element clicks, but on IE - no. I have to write special cases, “crutches”
  • Performance. Yes, drivers do not work fast. IE is ahead of the rest as usual - a search can take seconds, sometimes tens of seconds

In this part, we will write wrapper WebElement, which is entirely aimed at the user, i.e. to developers of autotests. I admit that at the time of writing, my task was to create a “framework” that manual testing engineers should use to write autotests. Naturally, they were supposed to have very modest programming knowledge. Therefore, it was completely irrelevant how many tons of code would be in the framework itself and how complex it would be inside. The main thing is that outside it is as simple as three letters. I warn you, there will be a lot of code and few pictures =)

References

Part 1: Introduction
Part 2.1: Selenium API wrapper - Browser
Part 2.2: Selenium API wrapper - WebElement
Part 3: WebPages - describe pages
Part 4: Finally we write tests
Publishing the framework

Forward!

And so, I began to think how it would be convenient for me, as a developer of autotests, to describe web elements. Some developers solve the first problem by writing getters, it looks like this:
private IWebElement LoginEdit
{
	get
	{
		return WebDriver.FindElement(By.Id("Login"));
	}
}

If there are no unique properties, you will have to search by the set of properties using FindElements, and then filter them using GetAttribute and GetCssValue.

WebDriver.Support has such a feature as PageFactory and the FindsBy attribute:
[FindsBy(How = How.LinkText, Using = "Справка")]
public IWebElement HelpLink { get; set; }

The description of the properties is done through the attributes - not bad. In addition, it is possible to cache the search (CacheLookup). Cons of this decision:
  • It’s still inconvenient, you have to write attributes and getter (well, can you do better?)
  • PageFactory.InitElements does not work quite obviously, it has its own nuances. Read the notes in the documentation carefully. I have to write my decision (I do not want to call it a “crutch”).
  • IWebElement still sticks out (besides it is often public), which means that every developer of autotests will work with him as he wants. As a result, centralized code refactoring will be difficult.

In principle, many stop at this. We will go further. I will formulate some profits that I would like to get on the way out.

Idea and use cases

The main idea is to fill in the search criteria when describing an element, and to perform the element search itself during any actions with it. In addition, I want to implement caching of search results for optimal test performance.

It would also be very convenient to describe elements in one line, but not create and pass arrays of properties. And here, by the way, we have the “call chain” pattern. It is also necessary to be able to search for elements by the occurrence of parameters.

Well, for complete happiness it is necessary to implement group methods over Linq-style elements, for example, so that you can put down all the checkboxes according to some criteria or get an array of strings from an array of links.

I'll try to depict the WebElement scheme:
image

I note that all the same, when testing a complex application, you may encounter situations when an element cannot be recognized using Selenium WebDriver. To solve this problem, the Browser.ExecuteJavaScript method is provided (see the previous article), i.e. It is possible to work with elements through JavaScript and jQuery.

Before proceeding to the wrapper code, I will show examples of description:

Search by id:
private static readonly WebElement TestElement = new WebElement().ById("StartButton");

XPath Search:
private static readonly WebElement TestElement = new WebElement().ByXPath("//div[@class='Content']//tr[2]/td[2]");

Search for the last item by class:
private static readonly WebElement TestElement = new WebElement().ByClass("UserAvatar").Last();

Search by value in attribute:
private static readonly WebElement TestElement = new WebElement().ByAttribute(TagAttributes.Href, "TagEdit", exactMatch: false);

Search by several parameters:
private static readonly WebElement TestElement = new WebElement().ByClass("TimePart").ByName("Day").Index(0);

Search by tag and text (entry):
private static readonly WebElement TestElement = new WebElement().ByTagName(TagNames.Link).ByText("Hello", exactMach);

Note that a TestElement does not necessarily store a description for a single element. If there are several elements, then when you try to click, an exception should occur (but in my implementation the first element that comes across will be used). We are also able to specify the index of an element using Index (...), either First () or Last (), so that one element is guaranteed to be found. In addition, it is not necessary to perform an action with one element, you can perform it with all elements at once (see ForEach in the examples below).

And now I will give examples of use:

Click on an element
TestElement.Click();

Click on an element using Selenium WebDriver or using jQuery:
TestElement.Click(useJQuery: true);

Retrieving text (e.g. link or text field):
var text = TestElement.Text;

Text setting:
TestElement.Text = "Hello!";

Dragging an item to another item:
TestElement1.DragAndDrop(TestElement2);

Sending an event to an element:
TestElement.FireJQueryEvent(JavaScriptEvents.KeyUp);

Expanding all collapsed elements (click on the pluses):
TestElements.ForEach(i => i.Click());

Getting the value of all headers:
var subjects = new WebElement().ByClass("Subject").Select(i => i.Text);


The applied call chain pattern allows you to simultaneously determine an element and perform an action:
new WebElement().ById("Next").Click();
var text = new WebElement().ById("Help").Text;

For the end user (the developer of autotests, which will describe the elements of the pages), it looks very friendly, right? Nothing sticks out. Please note that we do not even allow the developer to pass arbitrary attributes and tag names as parameters using the enum TagAttributes and TagNames for this. This will save the code from numerous magic strings.

Unfortunately, to provide such an API, you will have to write a lot of code. The WebElement (partial) class will be divided into 5 parts:
  • WebElement.cs
  • WebElementActions.cs
  • WebElementByCriteria.cs
  • WebElementExceptions.cs
  • WebElementFilters.cs

As I warned in a previous article, there are no comments in the code, but I will try to comment on the main points under copy-paste.

WebElement.cs

namespace Autotests.Utilities.WebElement
{
    public partial class WebElement : ICloneable
    {
        private By _firstSelector;
        private IList _searchCache;
        private IWebElement FindSingle()
        {
            return TryFindSingle();
        }
        private IWebElement TryFindSingle()
        {
            Contract.Ensures(Contract.Result() != null);
            try
            {
                return FindSingleIWebElement();
            }
            catch (StaleElementReferenceException)
            {
                ClearSearchResultCache();
                return FindSingleIWebElement();
            }
            catch (InvalidSelectorException)
            {
                throw;
            }
            catch (WebDriverException)
            {
                throw;
            }
            catch (WebElementNotFoundException)
            {
                throw;
            }
            catch
            {
                throw WebElementNotFoundException;
            }
        }
        private IWebElement FindSingleIWebElement()
        {
            var elements = FindIWebElements();
            if (!elements.Any()) throw WebElementNotFoundException;
            var element = elements.Count() == 1
                ? elements.Single()
                : _index == -1
                    ? elements.Last()
                    : elements.ElementAt(_index);
            // ReSharper disable UnusedVariable
            var elementAccess = element.Enabled;
            // ReSharper restore UnusedVariable
            return element;
        }
        private IList FindIWebElements()
        {
            if (_searchCache != null)
            {
                return _searchCache;
            }
            Browser.WaitReadyState();
            Browser.WaitAjax();
            var resultEnumerable = Browser.FindElements(_firstSelector);
            try
            {
                resultEnumerable = FilterByVisibility(resultEnumerable).ToList();
                resultEnumerable = FilterByTagNames(resultEnumerable).ToList();
                resultEnumerable = FilterByText(resultEnumerable).ToList();
                resultEnumerable = FilterByTagAttributes(resultEnumerable).ToList();
                resultEnumerable = resultEnumerable.ToList();
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
                return new List();
            }
            var resultList = resultEnumerable.ToList();
            return resultList;
        }
        private WebElementNotFoundException WebElementNotFoundException
        {
            get
            {
                CheckConnectionFailure();
                return new WebElementNotFoundException(string.Format("Can't find single element with given search criteria: {0}.",
                    SearchCriteriaToString()));
            }
        }
        private static void CheckConnectionFailure()
        {
            const string connectionFailure = "connectionFailure";
            Contract.Assert(!Browser.PageSource.Contains(connectionFailure),
                "Connection can't be established.");
        }
        object ICloneable.Clone()
        {
            return Clone();
        }
        public WebElement Clone()
        {
            return (WebElement)MemberwiseClone();
        }
    }
}

Here, the main attention should be paid to FindIWebElements, FindSingleIWebElement and exception handling in TryFindSingle. In FindIWebElements, we wait until the browser completes all its work (WaitReadyState and WaitAjax), search for elements (FindElements), and then filter them according to various criteria. Also, _searchCache appears in the code, this is just our cache (the search is not cached automatically, you need to call the CacheSearchResult method on the element).

WebElementActions.cs

namespace Autotests.Utilities.WebElement
{
    internal enum SelectTypes
    {
        ByValue,
        ByText
    }
    public partial class WebElement
    {
        #region Common properties
        public int Count
        {
            get { return FindIWebElements().Count; }
        }
        public bool Enabled
        {
            get { return FindSingle().Enabled; }
        }
        public bool Displayed
        {
            get { return FindSingle().Displayed; }
        }
        public bool Selected
        {
            get { return FindSingle().Selected; }
        }
        public string Text
        {
            set
            {
                var element = FindSingle();
                if (element.TagName == EnumHelper.GetEnumDescription(TagNames.Input) || element.TagName == EnumHelper.GetEnumDescription(TagNames.TextArea))
                {
                    element.Clear();
                }
                else
                {
                    element.SendKeys(Keys.LeftControl + "a");
                    element.SendKeys(Keys.Delete);
                }
                if (string.IsNullOrEmpty(value)) return;
                Browser.ExecuteJavaScript(string.Format("arguments[0].value = \"{0}\";", value), element);
                Executor.Try(() => FireJQueryEvent(JavaScriptEvents.KeyUp));
            }
            get
            {
                var element = FindSingle();
                return !string.IsNullOrEmpty(element.Text) ? element.Text : element.GetAttribute(EnumHelper.GetEnumDescription(TagAttributes.Value));
            }
        }
        public int TextInt
        {
            set { Text = value.ToString(CultureInfo.InvariantCulture); }
            get { return Text.ToInt(); }
        }
        public string InnerHtml
        {
            get { return Browser.ExecuteJavaScript("return arguments[0].innerHTML;", FindSingle()).ToString(); }
        }
        #endregion
        #region Common methods
        public bool Exists()
        {
            return FindIWebElements().Any();
        }
        public bool Exists(TimeSpan timeSpan)
        {
            return Executor.SpinWait(Exists, timeSpan, TimeSpan.FromMilliseconds(200));
        }
        public bool Exists(int seconds)
        {
            return Executor.SpinWait(Exists, TimeSpan.FromSeconds(seconds), TimeSpan.FromMilliseconds(200));
        }
        public void Click(bool useJQuery = true)
        {
            var element = FindSingle();
            Contract.Assert(element.Enabled);
            if (useJQuery && element.TagName != EnumHelper.GetEnumDescription(TagNames.Link))
            {
                FireJQueryEvent(element, JavaScriptEvents.Click);
            }
            else
            {
                try
                {
                    element.Click();
                }
                catch (InvalidOperationException e)
                {
                    if (e.Message.Contains("Element is not clickable"))
                    {
                        Thread.Sleep(2000);
                        element.Click();
                    }
                }
            }
        }
        public void SendKeys(string keys)
        {
            FindSingle().SendKeys(keys);
        }
        public void SetCheck(bool value, bool useJQuery = true)
        {
            var element = FindSingle();
            Contract.Assert(element.Enabled);
            const int tryCount = 10;
            for (var i = 0; i < tryCount; i++)
            {
                element = FindSingle();
                Set(value, useJQuery);
                if (element.Selected == value)
                {
                    return;
                }
            }
            Contract.Assert(element.Selected == value);
        }
        public void Select(string optionValue)
        {
            SelectCommon(optionValue, SelectTypes.ByValue);
        }
        public void Select(int optionValue)
        {
            SelectCommon(optionValue.ToString(CultureInfo.InvariantCulture), SelectTypes.ByValue);
        }
        public void SelectByText(string optionText)
        {
            SelectCommon(optionText, SelectTypes.ByText);
        }
        public string GetAttribute(TagAttributes tagAttribute)
        {
            return FindSingle().GetAttribute(EnumHelper.GetEnumDescription(tagAttribute));
        }
        #endregion
        #region Additional methods
        public void SwitchContext()
        {
            var element = FindSingle();
            Browser.SwitchToFrame(element);
        }
        public void CacheSearchResult()
        {
            _searchCache = FindIWebElements();
        }
        public void ClearSearchResultCache()
        {
            _searchCache = null;
        }
        public void DragAndDrop(WebElement destination)
        {
            var source = FindSingle();
            var dest = destination.FindSingle();
            Browser.DragAndDrop(source, dest);
        }
        public void FireJQueryEvent(JavaScriptEvents javaScriptEvent)
        {
            var element = FindSingle();
            FireJQueryEvent(element, javaScriptEvent);
        }
        public void ForEach(Action action)
        {
            Contract.Requires(action != null);
            CacheSearchResult();
            Enumerable.Range(0, Count).ToList().ForEach(i => action(ByIndex(i)));
            ClearSearchResultCache();
        }
        public List Select(Func action)
        {
            Contract.Requires(action != null);
            var result = new List();
            ForEach(e => result.Add(action(e)));
            return result;
        }
        public List Where(Func action)
        {
            Contract.Requires(action != null);
            var result = new List();
            ForEach(e =>
                {
                    if (action(e)) result.Add(e);
                });
            return result;
        }
        public WebElement Single(Func action)
        {
            return Where(action).Single();
        }
        #endregion
        #region Helpers
        private void Set(bool value, bool useJQuery = true)
        {
            if (Selected ^ value)
            {
                Click(useJQuery);
            }
        }
        private void SelectCommon(string option, SelectTypes selectType)
        {
            Contract.Requires(!string.IsNullOrEmpty(option));
            var element = FindSingle();
            Contract.Assert(element.Enabled);
            switch (selectType)
            {
                case SelectTypes.ByValue:
                    new SelectElement(element).SelectByValue(option);
                    return;
                case SelectTypes.ByText:
                    new SelectElement(element).SelectByText(option);
                    return;
                default:
                    throw new Exception(string.Format("Unknown select type: {0}.", selectType));
            }            
        }
        private void FireJQueryEvent(IWebElement element, JavaScriptEvents javaScriptEvent)
        {
            var eventName = EnumHelper.GetEnumDescription(javaScriptEvent);
            Browser.ExecuteJavaScript(string.Format("$(arguments[0]).{0}();", eventName), element);
        }
        #endregion
    }
    public enum JavaScriptEvents
    {
        [Description("keyup")]
        KeyUp,
        [Description("click")]
        Click
    }
}

A flat list of properties and methods defined for items. Some take the useJQuery parameter, which tells the method that the action is worth using JQuery (done for complex cases and the ability to perform an action in all three browsers). In addition, JavaScript execution is much faster. Some methods have crutches, for example, a loop with tryCount in SetCheck. Of course, there will be special cases for each product tested.

WebElementByCriteria.cs

namespace Autotests.Utilities.WebElement
{
    internal class SearchProperty
    {
        public string AttributeName { get; set; }
        public string AttributeValue { get; set; }
        public bool ExactMatch { get; set; }
    }
    internal class TextSearchData
    {
        public string Text { get; set; }
        public bool ExactMatch { get; set; }
    }
    public partial class WebElement
    {
        private readonly IList _searchProperties = new List();
        private readonly IList _searchTags = new List();
        private bool _searchHidden;
        private int _index;
        private string _xPath;
        private TextSearchData _textSearchData;
        public WebElement ByAttribute(TagAttributes tagAttribute, string attributeValue, bool exactMatch = true)
        {
            return ByAttribute(EnumHelper.GetEnumDescription(tagAttribute), attributeValue, exactMatch);
        }
        public WebElement ByAttribute(TagAttributes tagAttribute, int attributeValue, bool exactMatch = true)
        {
            return ByAttribute(EnumHelper.GetEnumDescription(tagAttribute), attributeValue.ToString(), exactMatch);
        }
        public WebElement ById(string id, bool exactMatch = true)
        {
            return ByAttribute(TagAttributes.Id, id, exactMatch);
        }
        public WebElement ById(int id, bool exactMatch = true)
        {
            return ByAttribute(TagAttributes.Id, id.ToString(), exactMatch);
        }
        public WebElement ByName(string name, bool exactMatch = true)
        {
            return ByAttribute(TagAttributes.Name, name, exactMatch);
        }
        public WebElement ByClass(string className, bool exactMatch = true)
        {
            return ByAttribute(TagAttributes.Class, className, exactMatch);
        }
        public WebElement ByTagName(TagNames tagName)
        {
            var selector = By.TagName(EnumHelper.GetEnumDescription(tagName));
            _firstSelector = _firstSelector ?? selector;
            _searchTags.Add(tagName);
            return this;
        }
        public WebElement ByXPath(string xPath)
        {
            Contract.Assume(_firstSelector == null,
                "XPath can be only the first search criteria.");
            _firstSelector = By.XPath(xPath);
            _xPath = xPath;
            return this;
        }
        public WebElement ByIndex(int index)
        {
            _index = index;
            return this;
        }
        public WebElement First()
        {
            _index = 0;
            return this;
        }
        public WebElement Last()
        {
            _index = -1;
            return this;
        }
        public WebElement IncludeHidden()
        {
            _searchHidden = true;
            return this;
        }
        public WebElement ByText(string text, bool exactMatch = true)
        {
            var selector = exactMatch ?
                By.XPath(string.Format("//*[text()=\"{0}\"]", text)) :
                By.XPath(string.Format("//*[contains(text(), \"{0}\")]", text));
            _firstSelector = _firstSelector ?? selector;
            _textSearchData = new TextSearchData { Text = text, ExactMatch = exactMatch };
            return this;
        }
        private WebElement ByAttribute(string tagAttribute, string attributeValue, bool exactMatch = true)
        {
            var xPath = exactMatch ?
                        string.Format("//*[@{0}=\"{1}\"]", tagAttribute, attributeValue) :
                        string.Format("//*[contains(@{0}, \"{1}\")]", tagAttribute, attributeValue);
            var selector = By.XPath(xPath);
            _firstSelector = _firstSelector ?? selector;
            _searchProperties.Add(new SearchProperty
                {
                    AttributeName = tagAttribute,
                    AttributeValue = attributeValue,
                    ExactMatch = exactMatch
                });
            return this;
        }
        private string SearchCriteriaToString()
        {
            var result = _searchProperties.Select(searchProperty =>
                string.Format("{0}: {1} ({2})",
                    searchProperty.AttributeName,
                    searchProperty.AttributeValue,
                    searchProperty.ExactMatch ? "exact" : "contains")).ToList();
            result.AddRange(_searchTags.Select(searchTag =>
                string.Format("tag: {0}", searchTag)));
            if (_xPath != null)
            {
                result.Add(string.Format("XPath: {0}", _xPath));
            }
            if (_textSearchData != null)
            {
                result.Add(string.Format("text: {0} ({1})",
                    _textSearchData.Text,
                    _textSearchData.ExactMatch ? "exact" : "contains"));
            }
            return string.Join(", ", result);
        }
    }
}

Most of the functions are public, with their help developers will describe the elements in their tests. For almost all criteria, it is possible to search by entry (exactMatch). As you can see, in the final case it all comes down to XPath (and I do not exclude that XPath works a little slower than a regular search, but I personally did not notice this).

WebElementExceptions.cs

namespace Autotests.Utilities.WebElement
{
    public class WebElementNotFoundException : Exception
    {
        public WebElementNotFoundException(string message) : base(message)
        {
        }
    }
}

Well, there’s just one custom exception.

WebElementFilters.cs

namespace Autotests.Utilities.WebElement
{
    public partial class WebElement
    {
        private IEnumerable FilterByVisibility(IEnumerable result)
        {
            return !_searchHidden ? result.Where(item => item.Displayed) : result;
        }
        private IEnumerable FilterByTagNames(IEnumerable elements)
        {
            return _searchTags.Aggregate(elements, (current, tag) => current.Where(item => item.TagName == EnumHelper.GetEnumDescription(tag)));
        }
        private IEnumerable FilterByText(IEnumerable result)
        {
            if (_textSearchData != null)
            {
                result = _textSearchData.ExactMatch
                    ? result.Where(item => item.Text == _textSearchData.Text)
                    : result.Where(item => item.Text.Contains(_textSearchData.Text, StringComparison.InvariantCultureIgnoreCase));
            }
            return result;
        }
        private IEnumerable FilterByTagAttributes(IEnumerable elements)
        {
            return _searchProperties.Aggregate(elements, FilterByTagAttribute);
        }
        private static IEnumerable FilterByTagAttribute(IEnumerable elements, SearchProperty searchProperty)
        {
            return searchProperty.ExactMatch ?
                elements.Where(item => item.GetAttribute(searchProperty.AttributeName) != null && item.GetAttribute(searchProperty.AttributeName).Equals(searchProperty.AttributeValue)) :
                elements.Where(item => item.GetAttribute(searchProperty.AttributeName) != null && item.GetAttribute(searchProperty.AttributeName).Contains(searchProperty.AttributeValue));
        }
    }
}

Filters that are called in FindIWebElements (WebElement.cs file) to filter items. I only note that with large datasets, Linq works much longer than for and foreach, so it may make sense to rewrite this code using classic loops.

Conclusion

I will once again see errors in the article made in the article, as well as any questions in the comments.

Remarks

- The article does not provide code for enum, EnumHelper and Executor. I will lay out the full code in the final part
- the string.Contains method used is an extension:
public static bool Contains(this string source, string target, StringComparison stringComparison)
{
	return source.IndexOf(target, stringComparison) >= 0;
}

Also popular now: