
Введение
Привет! В предыдущей части я описал основные проблемы, возникающие при работе с 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:

Замечу, что все равно при тестировании сложного приложения вы можете столкнуться с ситуациями, когда элемент не получается распознать с помощью 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;
}