Pull to refresh

Автоматизированное тестирование веб-приложения (MS Unit Testing Framework + Selenium WebDriver C#). Часть 2.2: Selenium API wrapper — WebElement

Reading time 14 min
Views 44K
Selenium + C#
Введение

Привет! В предыдущей части я описал основные проблемы, возникающие при работе с Selenium WebDriver, а так же привел пример обертки Browser. Вроде было не сложно, да?) Ну что ж, идем дальше. Надо разобраться с оставшимися проблемами:
  • Описание элемента происходит одновременно с его поиском, т.е. на момент определения элемента он должен существовать в браузере. Очень часто решается путем написания getter для каждого элемента. Это накладно и плохо с точки зрения производительности
  • ISearchContext.FindElements принимает только один параметр типа OpenQA.Selenium.By, т.е. мы не можем искать сразу по нескольким свойствам. Обычно элемент ищется по первому критерию, а затем начинается отсеивание по остальным
  • Отсутствие многих, казалось бы, очевидных методов и свойств. Например: Exist, SetText, Select, SetCheck, InnerHtml и т.д. Вместо этого мы вынуждены довольствоваться Click, SendKeys и Text
  • Множество проблем на различных браузерах, например на Firefox и Chrome элемент кликается, а на IE — нет. Приходится писать special cases, «костыли»
  • Производительность. Да, драйвера работают не быстро. Впереди планеты всей как обычно IE — поиск может занимать секунды, иногда и десятки секунд

В этой части мы будем писать wrapper WebElement, который целиком направлен на пользователя, т.е. на разработчиков автотестов. Признаюсь, что в момент его написания моя задача заключалась в создании «фреймворка», которым должны пользоваться инженеры по ручному тестированию для написания автотестов. Естественно предполагалось, что они имеют весьма скромные познания в программировании. Поэтому было совершенно не важно, сколько тонн кода будет в самом фреймворке и насколько он будет сложным внутри. Главное, чтобы снаружи он был прост как три буквы. Предупреждаю, будет много кода и мало картинок =)

Ссылки

Часть 1: Введение
Часть 2.1: Selenium API wrapper — Browser
Часть 2.2: Selenium API wrapper — WebElement
Часть 3: WebPages — описываем страницы
Часть 4: Наконец-то пишем тесты
Публикация фреймворка

Вперед!

И так, я начал думать, как мне, как разработчику автотестов, было бы удобно описывать web-элементы. Первую проблему некоторые разработчики решают написанием getter'ов, выглядит это вот так:
private IWebElement LoginEdit
{
	get
	{
		return WebDriver.FindElement(By.Id("Login"));
	}
}

Если уникальных свойств нет, то придется искать по набору свойств, воспользовавшись FindElements, а затем отсеивать с помощью GetAttribute и GetCssValue.

В WebDriver.Support есть такая фича, как PageFactory и атрибут FindsBy:
[FindsBy(How = How.LinkText, Using = "Справка")]
public IWebElement HelpLink { get; set; }

Описание свойств делается через атрибуты — неплохо. К тому же есть возможность кэшировать поиск (CacheLookup). Минусы такого решения:
  • По-прежнему неудобно, приходится писать атрибуты и getter (ну можно же сделать лучше?)
  • PageFactory.InitElements работает не совсем очевидно, там есть свои нюансы. Внимательно читайте замечания в документации. Придется писать свое решение (не хочу называть его «костылем»).
  • IWebElement по-прежнему торчит наружу (к тому же зачастую он public), а это значит, что каждый разработчик автотестов будет работать с ним как ему захочется. Как следствие этого производить централизованный рефакторинг кода будет сложно.

В принципе, на этом многие останавливаются. Мы же пойдем дальше. Сформулирую некоторые профиты, которые я хотел бы получить на выходе.

Идея и примеры использования

Основная идея заключается в том, чтобы при описании элемента заполнять критерии поиска, а сам поиск элемента выполнять при любых действиях с ним. Кроме того хочется реализовать кэширование результатов поиска для оптимальной работы тестов.

Также было бы весьма удобно описывать элементы в одну строчку, но не создавать и передавать массивы свойств. И тут как нельзя кстати приходится паттерн «цепочка вызовов» (call chain). Еще необходимо иметь возможность искать элементы по вхождению параметров.

Ну а для полного счастья необходимо реализовать групповые методы над элементами в стиле Linq, например, чтобы можно было проставить все чекбоксы по какому-либо критерию или получить массив строк из массива ссылок.

Попробую изобразить схему WebElement:
image

Замечу, что все равно при тестировании сложного приложения вы можете столкнуться с ситуациями, когда элемент не получается распознать с помощью Selenium WebDriver. Для решения этой проблемы предусмотрен метод Browser.ExecuteJavaScript (см. предыдущую статью), т.е. есть возможность работать с элементами через JavaScript и JQuery.

Перед тем, как переходить к коду wrapper'а, я покажу примеры описания:

Поиск по id:
private static readonly WebElement TestElement = new WebElement().ById("StartButton");

Поиск по XPath:
private static readonly WebElement TestElement = new WebElement().ByXPath("//div[@class='Content']//tr[2]/td[2]");

Поиск последнего элемента по классу:
private static readonly WebElement TestElement = new WebElement().ByClass("UserAvatar").Last();

Поиск по вхождению значения в атрибут:
private static readonly WebElement TestElement = new WebElement().ByAttribute(TagAttributes.Href, "TagEdit", exactMatch: false);

Поиск по нескольким параметрам:
private static readonly WebElement TestElement = new WebElement().ByClass("TimePart").ByName("Day").Index(0);

Поиск по тэгу и по тексту (вхождению):
private static readonly WebElement TestElement = new WebElement().ByTagName(TagNames.Link).ByText("Hello", exactMach);

Заметьте, что в TestElement не обязательно хранится описание для одного элемента. Если элементов несколько, то при попытке кликнуть должно возникнуть исключение (но у меня в реализации будет использован первый попавшийся элемент). Так же мы имеем возможность указать индекс элемента, используя Index(...), либо First() или Last(), чтобы гарантированно нашелся один элемент. Кроме того, не обязательно выполнять действие с одним элементом, можно выполнять его со всеми элементами сразу (см. ForEach в примерах ниже).

А теперь приведу примеры использования:

Клик по элементу
TestElement.Click();

Клик по элементу с помощью Selenium WebDriver или с помощью JQuery:
TestElement.Click(useJQuery: true);

Получение текста (например ссылки или текстового поля):
var text = TestElement.Text;

Установка текста:
TestElement.Text = "Hello!";

Перетаскивание элемента на другой элемент:
TestElement1.DragAndDrop(TestElement2);

Отправка события элементу:
TestElement.FireJQueryEvent(JavaScriptEvents.KeyUp);

Разворачивание всех свернутых элементов (клик по плюсикам):
TestElements.ForEach(i => i.Click());

Получение значения всех заголовков:
var subjects = new WebElement().ByClass("Subject").Select(i => i.Text);


Примененный паттерн call chain позволяет одновременно определять элемент и выполнять действие:
new WebElement().ById("Next").Click();
var text = new WebElement().ById("Help").Text;

Для конечного пользователя (разработчика автотестов, который будет описывать элементы страниц) выглядит весьма дружелюбно, не так ли? Ничего не торчит наружу. Обратите внимание, что мы даже не разрешаем разработчику передавать в качестве параметров произвольные атрибуты и названия тэгов, используя для этого enum TagAttributes и TagNames. Это избавит код от многочисленных magic strings.

К сожалению, чтобы предоставить такой API, придется написать очень много кода. Класс WebElement (partial) будет разбит на 5 частей:
  • WebElement.cs
  • WebElementActions.cs
  • WebElementByCriteria.cs
  • WebElementExceptions.cs
  • WebElementFilters.cs

Как я уже предупреждал в предыдущей статье, в коде нет комментариев, но я постараюсь прокомментировать основные моменты под копипастой.

WebElement.cs

namespace Autotests.Utilities.WebElement
{
    public partial class WebElement : ICloneable
    {
        private By _firstSelector;
        private IList<IWebElement> _searchCache;

        private IWebElement FindSingle()
        {
            return TryFindSingle();
        }

        private IWebElement TryFindSingle()
        {
            Contract.Ensures(Contract.Result<IWebElement>() != 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<IWebElement> 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<IWebElement>();
            }

            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();
        }
    }
}

Тут основное внимание стоит обратить на FindIWebElements, FindSingleIWebElement и обработку исключений в TryFindSingle. В FindIWebElements мы дожидаемся, пока браузер завершит все свои дела (WaitReadyState и WaitAjax), производим поиск элементов (FindElements), а затем фильтруем их по различным критериям. Также в коде фигурирует _searchCache, это как раз наш кэш (автоматически поиск не кэшируется, у элемента нужно вызвать метод CacheSearchResult).

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<WebElement> action)
        {
            Contract.Requires(action != null);

            CacheSearchResult();

            Enumerable.Range(0, Count).ToList().ForEach(i => action(ByIndex(i)));

            ClearSearchResultCache();
        }

        public List<T> Select<T>(Func<WebElement, T> action)
        {
            Contract.Requires(action != null);

            var result = new List<T>();
            
            ForEach(e => result.Add(action(e)));

            return result;
        }

        public List<WebElement> Where(Func<WebElement, bool> action)
        {
            Contract.Requires(action != null);

            var result = new List<WebElement>();

            ForEach(e =>
                {
                    if (action(e)) result.Add(e);
                });

            return result;
        }

        public WebElement Single(Func<WebElement, bool> 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
    }
}

Плоский список свойств и методов, определенных для элементов. Некоторые принимают параметр useJQuery, который указывает методу, что действие стоит производить с помощью JQuery (сделано для сложных случаев и возможности совершить действие во всех трех браузерах). Кроме того выполнение JavaScript работает намного быстрее. В некоторых методах располагаются «костыли», например цикл с tryCount в SetCheck. Конечно, для каждого тестируемого продукта будут свои special cases.

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<SearchProperty> _searchProperties = new List<SearchProperty>();
        private readonly IList<TagNames> _searchTags = new List<TagNames>();
        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);
        }
    }
}

Большинство функций публичные, с их помощью разработчики будут описывать элементы в своих тестах. Почти для всех критериев предусмотрена возможность искать по вхождению (exactMatch). Как видно, в конечном случае все сводится к XPath (и я не исключаю, что XPath работает немного медленнее обычного поиска, но лично я этого не заметил).

WebElementExceptions.cs

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

Ну тут просто одно кастомное исключение.

WebElementFilters.cs

namespace Autotests.Utilities.WebElement
{
    public partial class WebElement
    {
        private IEnumerable<IWebElement> FilterByVisibility(IEnumerable<IWebElement> result)
        {
            return !_searchHidden ? result.Where(item => item.Displayed) : result;
        }

        private IEnumerable<IWebElement> FilterByTagNames(IEnumerable<IWebElement> elements)
        {
            return _searchTags.Aggregate(elements, (current, tag) => current.Where(item => item.TagName == EnumHelper.GetEnumDescription(tag)));
        }

        private IEnumerable<IWebElement> FilterByText(IEnumerable<IWebElement> 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<IWebElement> FilterByTagAttributes(IEnumerable<IWebElement> elements)
        {
            return _searchProperties.Aggregate(elements, FilterByTagAttribute);
        }

        private static IEnumerable<IWebElement> FilterByTagAttribute(IEnumerable<IWebElement> 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));
        }
    }
}

Фильтры, которые вызываются в FindIWebElements (файл WebElement.cs) для отсеивания элементов. Замечу только то, что с большими наборами данных Linq работает значительно дольше, чем for и foreach, поэтому, возможно, имеет смысл переписать этот код с использованием классических циклов.

Заключение

Буду раз увидеть в ЛС ошибки, допущенные в статье, а так же любые вопросы в комментариях.

Замечания

— в статье не приведен код enum'ов, EnumHelper и Executor. Полный код я выложу в заключительной части
— используемый метод string.Contains это расширение:
public static bool Contains(this string source, string target, StringComparison stringComparison)
{
	return source.IndexOf(target, stringComparison) >= 0;
}
Tags:
Hubs:
+8
Comments 7
Comments Comments 7

Articles