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