Введение
Как лозунг на Angular.org гордостью объясняет:
Angular is what HTML would have been, had it been designed for applications , что в вольном переводе звучит так: Angular является тем, чем был бы HTML — если бы он с самого начала был предназначен для создания (веб -) приложений. AngularJS был разработан с нуля, чтобы быть тестируемым. Но многие разработчиков Selenium хотят продолжать использовать свои существующие Java или C # кодовую базу и навыки но обнаруживают при переключении на тестирование AngularJS SPA и MVVM веб-приложений, что Protractor, лидирующий инструмент тестирования приложегий AngularJS, написан на JavaScript тоже.
К счастью, Protractor довольно легко портируется на другие языки — он использует небольшое подмножество протокола JsonWire на котором основан Selenium WebDriver, а именно всего один интерфейс.
За короткое время был дополнен и развит проект protractor-net представляющий порт существующих методов Protractor https://github.com/angular/protractor/blob/master/lib/clientsidescripts.js из Javascript на C# и затем другой проект, выполняющий ту же задачу из Java.
Для тестирования был выбран сайт http://www.way2automation.com на котором среди прочего есть и проект для AngularJS,
http://www.way2automation.com/angularjs-protractor/banking.
тесты представляют собой «стандарные» действия «клиента» и «менеджера» банка «XYZ Bank» по проверке баланса, созданию учетных записей, проведения платежей и т.п. — это позволило проиллюстрировать все имеющиеся методы. Вызов тестов осуществлен из проекта на C# и из Java
Примеры кода
C#
«Клиент» заходит, выбирает счет, заносит сумму, и когда транзакция прошла, проверяет баланс (есть и тест на съем средств — тут он не показан, смотрите архив).
[TestFixture] public class Way2AutomationTests { private StringBuilder verificationErrors = new StringBuilder(); private IWebDriver driver; private NgWebDriver ngDriver; private WebDriverWait wait; private IAlert alert; private string alert_text; private Regex theReg; private MatchCollection theMatches; private Match theMatch; private Capture theCapture; private int wait_seconds = 3; private int highlight_timeout = 100; private Actions actions; private String base_url = "http://www.way2automation.com/angularjs-protractor/banking"; [TestFixtureSetUp] public void SetUp() { driver = new FirefoxDriver(); driver.Manage().Timeouts().SetScriptTimeout(TimeSpan.FromSeconds(60)); ngDriver = new NgWebDriver(driver); wait = new WebDriverWait(driver, TimeSpan.FromSeconds(wait_seconds)); actions = new Actions(driver); } [SetUp] public void NavigateToBankingExamplePage() { driver.Navigate().GoToUrl(base_url); ngDriver.Url = driver.Url; } [TestFixtureTearDown] public void TearDown() { try { driver.Close(); driver.Quit(); } catch (Exception) { } Assert.IsEmpty(verificationErrors.ToString()); } [Test] public void ShouldDeposit() { ngDriver.FindElement(NgBy.ButtonText("Customer Login")).Click(); ReadOnlyCollection<NgWebElement> ng_customers = ngDriver.FindElement(NgBy.Model("custId")).FindElements(NgBy.Repeater("cust in Customers")); // select customer to log in ng_customers.First(cust => Regex.IsMatch(cust.Text, "Harry Potter")).Click(); ngDriver.FindElement(NgBy.ButtonText("Login")).Click(); ngDriver.FindElement(NgBy.Options("account for account in Accounts")).Click(); NgWebElement ng_account_number_element = ngDriver.FindElement(NgBy.Binding("accountNo")); int account_id = 0; int.TryParse(ng_account_number_element.Text.FindMatch(@"(?<result>\d+)$"), out account_id); Assert.AreNotEqual(0, account_id); int account_amount = -1; int.TryParse(ngDriver.FindElement(NgBy.Binding("amount")).Text.FindMatch(@"(?<result>\d+)$"), out account_amount); Assert.AreNotEqual(-1, account_amount); ngDriver.FindElement(NgBy.PartialButtonText("Deposit")).Click(); // core Selenium wait.Until(ExpectedConditions.ElementExists(By.CssSelector("form[name='myForm']"))); NgWebElement ng_form_element = new NgWebElement(ngDriver, driver.FindElement(By.CssSelector("form[name='myForm']"))); NgWebElement ng_deposit_amount_element = ng_form_element.FindElement(NgBy.Model("amount")); ng_deposit_amount_element.SendKeys("100"); NgWebElement ng_deposit_button_element = ng_form_element.FindElement(NgBy.ButtonText("Deposit")); ngDriver.Highlight(ng_deposit_button_element); ng_deposit_button_element.Click(); // inspect status message var ng_message_element = ngDriver.FindElement(NgBy.Binding("message")); StringAssert.Contains("Deposit Successful", ng_message_element.Text); ngDriver.Highlight(ng_message_element); // re-read the amount int updated_account_amount = -1; int.TryParse(ngDriver.FindElement(NgBy.Binding("amount")).Text.FindMatch(@"(?<result>\d+)$"), out updated_account_amount); Assert.AreEqual(updated_account_amount, account_amount + 100); }
Java
«Клиент» заходит, выбирает счет, смотрит транзакции, умеет найти записи «Credit».
@Test public void testListTransactions() throws Exception { // customer login ngDriver.findElement(NgBy.buttonText("Customer Login")).click(); // select customer/account with transactions assertThat(ngDriver.findElement(NgBy.input("custId")).getAttribute("id"), equalTo("userSelect")); Enumeration<WebElement> customers = Collections.enumeration(ngDriver.findElement(NgBy.model("custId")).findElements(NgBy.repeater("cust in Customers"))); while (customers.hasMoreElements()){ WebElement next_customer = customers.nextElement(); if (next_customer.getText().indexOf("Hermoine Granger") >= 0 ){ System.err.println(next_customer.getText()); next_customer.click(); } } NgWebElement login_element = ngDriver.findElement(NgBy.buttonText("Login")); assertTrue(login_element.isEnabled()); login_element.click(); Enumeration<WebElement> accounts = Collections.enumeration(ngDriver.findElements(NgBy.options("account for account in Accounts"))); while (accounts.hasMoreElements()){ WebElement next_account = accounts.nextElement(); if (Integer.parseInt(next_account.getText()) == 1001){ System.err.println(next_account.getText()); next_account.click(); } } // inspect transactions NgWebElement ng_transactions_element = ngDriver.findElement(NgBy.partialButtonText("Transactions")); assertThat(ng_transactions_element.getText(), equalTo("Transactions")); highlight(ng_transactions_element); ng_transactions_element.click(); wait.until(ExpectedConditions.visibilityOf(ngDriver.findElement(NgBy.repeater("tx in transactions")).getWrappedElement())); Iterator<WebElement> ng_transaction_type_columns = ngDriver.findElements(NgBy.repeaterColumn("tx in transactions", "tx.type")).iterator(); while (ng_transaction_type_columns.hasNext() ) { WebElement column = (WebElement) ng_transaction_type_columns.next(); if (column.getText().isEmpty()){ break; } if (column.getText().equalsIgnoreCase("Credit") ){ highlight(column); } } }
Для интерактивного тестирования, стоит запустить Selenium-ноду и хаб локально на порт
4444
@BeforeClass public static void setup() throws IOException { DesiredCapabilities capabilities = new DesiredCapabilities("firefox", "", Platform.ANY); FirefoxProfile profile = new ProfilesIni().getProfile("default"); capabilities.setCapability("firefox_profile", profile); seleniumDriver = new RemoteWebDriver(new URL("http://127.0.0.1:4444/wd/hub"), capabilities); try{ seleniumDriver.manage().window().setSize(new Dimension(600, 800)); seleniumDriver.manage().timeouts() .pageLoadTimeout(50, TimeUnit.SECONDS) .implicitlyWait(20, TimeUnit.SECONDS) .setScriptTimeout(10, TimeUnit.SECONDS); } catch(Exception ex) { System.out.println(ex.toString()); } ngDriver = new NgWebDriver(seleniumDriver); }
Для билда используем
@BeforeClass public static void setup() throws IOException { seleniumDriver = new PhantomJSDriver(); wait = new WebDriverWait(seleniumDriver, flexible_wait_interval ); wait.pollingEvery(wait_polling_interval,TimeUnit.MILLISECONDS); actions = new Actions(seleniumDriver); ngDriver = new NgWebDriver(seleniumDriver); }
Особенности
Синхронизация
Для динамических страниц в дополнение / вместо многообразных порой весьма трудночитаемых методов проверки того как отдельные элементы страницы выглядят или что с ними происходит, которые предлагает «core Selenium»:
elementSelectionStateToBe ( By locator, boolean selected)
проверяет, что данный элемент выбран или нет
elementToBeClickable ( By locator)
элемент доступен
stalenessOf ( WebElement element)
пока элемент больше не прикреплены к DOM
textToBePresentInElementLocated ( By locator, java.lang.String text)
текст присутствует в элементе, который найдет данный локатор
textToBePresentInElementValue ( By locator, java.lang.String text)
текст присутствует в присутствует в выбранном атрибуте элемента который найдет данный локатор
visibilityOfAllElementsLocatedBy ( By locator)
проверка, что все элементы, которые найдет соответствуют локатор, видны на веб-странице
(фрагмент взят из документации https://selenium.googlecode.com/git/docs/api/java/org/openqa/selenium/support/ui/ExpectedConditions.html), Protractor вызывет Angular напрямую
public boolean isSelected() { this.ngDriver.WaitForAngular(); return this.element.isSelected(); } public void WaitForAngular() { if (!this.IgnoreSynchronization){ this.jsExecutor.executeAsyncScript(ClientSideScripts.WaitForAngular, this.rootElement); } }
посылая
var el = document.querySelector(arguments[0]); var callback = arguments[1]; angular.element(el).injector().get('$browser').notifyWhenNoOutstandingRequests(callback);
и/или
var rootSelector = arguments[0]; var callback = arguments[1]; if (window.getAngularTestability) { window.getAngularTestability(el).whenStable(callback); return; }
этот метод вызывается изо всех стандартных действий с элементами страницы перед тем как будет вызван «core» метод, например:
public bool Displayed { get { this.ngDriver.WaitForAngular(); return this.element.Displayed; } }
и в итоге тестируемый сайт и тестовый сценарий оказываются хорошо синхронизованы без каких-либо дополнительных усилий.
Создание тестов
Вместо того чтобы копировать CSS Selector и XPaths нужного элемента, расработчик теста смотрит на шаблон страницы
http://www.way2automation.com/angularjs-protractor/banking/depositTx.html
<span class="error" ng-show="message" >{{message}}</span><br>
и её контроллер
http://www.way2automation.com/angularjs-protractor/banking/depositController.js
if (txObj.success) { $scope.message = "Deposit Successful"; } else { $scope.message = "Something went wrong. Please try again."; }
чтобы произвести проверку:
// inspect message var ng_message = ngDriver.FindElement(NgBy.Binding("message")); StringAssert.Contains("Deposit Successful", ng_message.Text); ngDriver.Highlight(ng_message);
Дополнительные возможности
Protractor позволяет не только находить, но и вычислять интересующие нас объекты:
[Test] public void ShouldEvaluateTransactionDetails() { ngDriver.FindElement(NgBy.ButtonText("Customer Login")).Click(); // select customer/account with transactions ngDriver.FindElement(NgBy.Model("custId")).FindElements(NgBy.Repeater("cust in Customers")).First(cust => Regex.IsMatch(cust.Text, "Hermoine Granger")).Click(); ngDriver.FindElement(NgBy.ButtonText("Login")).Click(); ngDriver.FindElements(NgBy.Options("account for account in Accounts")).First(account => Regex.IsMatch(account.Text, "1001")).Click(); // switch to transactions NgWebElement ng_transaction_list_button = ngDriver.FindElement(NgBy.PartialButtonText("Transactions")); StringAssert.Contains("Transactions", ng_transaction_list_button.Text); ngDriver.Highlight(ng_transaction_list_button); ng_transaction_list_button.Click(); // wait for transaction information to be loaded and rendered wait.Until(ExpectedConditions.ElementExists(NgBy.Repeater("tx in transactions"))); // examine first few transactions using Evaluate ReadOnlyCollection<NgWebElement> ng_transactions = ngDriver.FindElements(NgBy.Repeater("tx in transactions")); int cnt = 0; foreach (NgWebElement ng_current_transaction in ng_transactions) { if (cnt++ > 5) { break; } StringAssert.IsMatch("(?i:credit|debit)", ng_current_transaction.Evaluate("tx.type").ToString()); StringAssert.IsMatch(@"(?:\d+)", ng_current_transaction.Evaluate("tx.amount").ToString()); // 'tx.date' is in Javascript UTC format similar to UniversalSortableDateTimePattern in C# var transaction_date = ng_current_transaction.Evaluate("tx.date"); StringAssert.IsMatch(@"(?:\d{4}\-\d{2}\-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z)", transaction_date.ToString()); } }
Полный список тестов на 01.08.2016:
C#
ShouldAddCustomer
ShouldDeleteCustomer
ShouldDeposit
ShouldEvaluateTransactionDetails
ShouldListTransactions
ShouldLoginCustomer
ShouldOpenAccount
ShouldSortCustomersAccounts
ShouldWithdraw
Java (десктоп)
testAddCustomer
testCustomerLogin
testDepositAndWithdraw
testEvaluateTransactionDetails
testListTransactions
testOpenAccount
testSortCustomerAccounts
Java (CI/travis)
testAddition
testChangeSelectedtOption
testEvaluate
testFindElementByOptions
testFindElementByRepeaterColumn
testFindElementByRepeaterWithBeginEnd
testFindSelectedtOption
CI тесты — упрощенные, тестируеумые страницы загружаются прямо с диска:
String localFile = "bind_select_option_data_from_array_example.htm"; URI uri = NgByIntegrationTest.class.getClassLoader().getResource(localFile).toURI(); ngDriver.navigate().to(uri.toString()));
для выполнения упрощенного сценария напр.
Iterator<WebElement> options = ngDriver.findElements(NgBy.repeater("option in options")).iterator(); while (options.hasNext() ) { WebElement option = (WebElement) options.next(); if (option.getText().isEmpty()){ break; } if (option.getText().equalsIgnoreCase("two") ){ option.click(); } } NgWebElement element = ngDriver.findElement(NgBy.selectedOption("myChoice")); assertThat(element.getText(),containsString("two"));
вызывается драйвер
phantomJS
Статья (более подробная версия) также опубликована мною на Code Project, туда же периодически загружаются наиболее свежие архивы проектов. Оба проекта на гитхабе:
— полностью рабочие, коммиты практически каждый день.
Комментарии (6)
sergueik
02.01.2016 05:31хорошй вопрос.
не успел — потому это все сделано совсем недавно — буквально на каникулах дня благодарения и рождествениских каникулах — совершенно один. точнее upstream проекты с которых я начал (форкнув) живут уже давно но по моему заброшены (больше чем полтора года ) не обновлялись. я связался с Carlos Alexandro Becker и он вроде одобрил PR но как то продолжения не вижу.
C Aaron «F1tZy» Van Prooyen связаться не удалось.
А Bruno Baia вообще в прощлом году отказался рассматривать PR в котором просили добавить ButtonText (у кого-то другого) потому что это типа не кошерный AngularJS хотя в AngularJS Protractor он прекрасно есть.
так что милости просим форк и присоединяйтесь к разработке.
voidnugget
Лучше уж JSDOM для E2E тестирования использовать, а не Selenium суррогаты.
Фронтендерам проще самостоятельно покрывать свой JS фронтенд тестами на JS'е, и не зависить от разработчиков бэкенда, на чём бы он не был написан. По этому я считаю нецелесообразным портировать тот же protractor под C# или что либо другое.
sergueik
если вас интепесует мое мнение, то мне кажется что под Practor код получается чище и его легче сопровождать но это не повод для holy war.
статью(и) написал потому что незаслуженно заброшенная тема (не могу поверить что пользовались проектами protractor-jvm и protractor-net в которых существенная (по — моему ) часть функционала не была внедрена потому что разработчики потеряли к теме интерес
voidnugget
Я не говорю что протрактор плох, или что его не стоит использовать, я просто не вижу смысла использовать его с Java/Net, и есть решения гораздо эффективнее (jsdom). Почти все известные мне фронтендеры пишут приёмочные тесты сами под себя на JS'е. Бэкенд с REST эндпоинтами имеет смысл покрывать тестами на C#/Java в случае реализации гипермедиатипов (hateoas), кодогенерации, или шаблонных контроллеров.
Можете пожалуйста аргументировать вашу точку зрения: почему кому-то стоит использовать E2E тестирование для JS проекта на С#/Java?
sergueik
Добавил в статью описание специфических возможностей Protractor. Мне кажется есть много клиетнов которые уже имеют значительные объемы бизнес приложений на Spring и ASP и присматриваются или внедряют Angular (банки, медицина) — они могут быть заинтересованы в сохранении существующей code base