От автора: Protractor – популярный фреймворк комплексного тестирования. С помощью Protractor Angular приложение можно тестировать в реальном браузере, имитируя взаимодействия, как с реальным пользователем. Комплексное тестирование проверяет, чтобы приложение вело себя ровно так, как ожидает пользователь. Помимо этого, тесты напрямую не относятся к коду.
Protractor запускается поверх популярного Selenium WebDriver (API для автоматизации браузера и тестирования). Помимо функций Selenium WebDriver Protractor предлагает локаторы и методы для захвата UI компонентов приложения Angular. В этом уроке вы узнаете о:
Установке, настройке и запуске Protractor
Написании базовых тестов для Protractor
Объектах страниц и о том, как их использовать
Руководствах для написания тестов
Написании E2E тестов для приложения с нуля и до самого конца
Звучит круто? Но сначала о главном.
Нужно ли мне использовать Protractor?
Если вы работали с Angular CLI, то вы должны знать, что по умолчанию с ним идет 2 фреймворка для тестирования. И это:
Юнит тесты на Jasmine и Karma
Комплексные тесты на Protractor
Основное различие – первый используется для тестирования логики компонентов и сервисов, а второй используется для проверки функциональности высокого уровня приложения (в том числе и UI элементы).
Если вы ни разу не тестировали в Angular, рекомендую почитать серию статей «тестирование компонентов в Angular на Jasmine», чтобы лучше понять принцип.
В первом случае можно использовать мощь тестовых утилит Angular и Jasmine для написания не просто юнит тестов для компонентов и сервисов, а также для написания базовых UI тестов. Однако если необходимо протестировать front end функционал приложения полностью, Protractor вам в руки. Protractor API совмещен с шаблонами проектирования, такими как объекты страниц, что упрощает написание тестов и делает их более читаемыми. Пример.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/* 1. It should have a create Paste button 2. Clicking the button should bring up a modal window */ it('should have a Create Paste button and modal window', () => { expect(addPastePage.isCreateButtonPresent()).toBeTruthy("The button should exist"); expect(addPastePage.isCreatePasteModalPresent()).toBeFalsy("The modal window shouldn't exist, not yet!"); addPastePage.clickCreateButton(); expect(addPastePage.isCreatePasteModalPresent()).toBeTruthy("The modal window should appear now"); }); |
Настройка Protractor
Если для генерации проекта вы используете Angular CLI, настройка Protractor не вызовет затруднений. Команду ng new создает следующую структуру папок.
Стандартный шаблон проекта, созданный Protractor, зависит от двух файлов для запуска тестов: специальные файлы из папки e2e и файл конфигураций (protractor.conf.js). Давайте посмотрим, как настраивается файл protractor.conf.js:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
/* Path: protractor.conf.ts*/ // Protractor configuration file, see link for more information // //github.com/angular/protractor/blob/master/lib/config.ts const { SpecReporter } = require('jasmine-spec-reporter'); exports.config = { allScriptsTimeout: 11000, specs: [ './e2e/**/*.e2e-spec.ts' ], capabilities: { 'browserName': 'chrome' }, directConnect: true, baseUrl: '//localhost:4200/', framework: 'jasmine', jasmineNodeOpts: { showColors: true, defaultTimeoutInterval: 30000, print: function() {} }, onPrepare() { require('ts-node').register({ project: 'e2e/tsconfig.e2e.json' }); jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); } }; |
Если вы умеете запускать тесты в Chrome, то можете оставить все, как есть и пропустить оставшуюся часть раздела.
Настройка Protractor с Selenium Standalone Server
Запись directConnect: true подключает Protractor напрямую к драйверам браузера. Однако на момент написания этого урока Chrome – единственный поддерживаемый браузер. Если нужна поддержка нескольких браузеров или другой браузер, вам понадобится установить Selenium standalone server. Что для этого нужно сделать.
Установите Protractor глобально с помощью npm:
1 |
npm install -g protractor |
Это установит инструмент командной строки для вебдрайвер менеджера вместе с Protractor. Теперь обновите вебдрайвер менеджер на использование последних дистрибутивов и запустите Selenium standalone server.
1 2 |
webdriver-manager update webdriver-manager start |
Установите directConnect: false и добавьте свойство seleniumAddress:
1 2 3 4 5 6 7 8 9 10 11 12 |
capabilities: { 'browserName': 'firefox' }, directConnect: false, baseUrl: '//localhost:4200/', seleniumAddress: '//localhost:4444/wd/hub', framework: 'jasmine', jasmineNodeOpts: { showColors: true, defaultTimeoutInterval: 30000, print: function() {} }, |
Конфиг файл на GitHub дает больше информации о вариантах настройки в Protractor. Я в уроке буду использовать стандартные опции.
Запуск тестов
Если вы работаете в Angular CLI, то для запуска тестов вам нужна команда ng e2e. Если кажется, что тесты медленные, это потому что Angular должен компилировать код при каждом запуске e2e. Если хотите немного ускорить их, вам нужно сделать следующее. Запустите ng serve. Откройте новую вкладку в консоли и запустите:
1 |
ng e2e -s false |
Теперь тесты должны грузиться быстрее.
Наша цель
Мы будем писать E2E тесты для стандартного приложения Pastebin. Клонируйте объект из репозитория GitHub. Обе версии (стартовая без тестов и финальная с тестами) доступны в отдельных ветках. Клонируйте ветку starter. Можете пробежаться по коду, чтобы вручную ознакомиться с приложением.
Давайте кратко опишем наше приложение Pastebin. Приложение сначала будет загружать список вставок (полученных с ложного сервера) в таблицу. У каждой строки в таблице будет кнопка View Paste, по клику на которую будет открываться модальное окно первичной загрузки. Модальное окно отображает данные вставки с опциями редактирования и удаления. В конце таблицы есть кнопка Create Paste, с помощью которой можно добавить новые вставки.
Оставшаяся часть урока посвящена написанию Protractor тестов в Angular.
Основы Protractor
В файле спецификации, который оканчивается на .e2e-spec.ts, будут храниться реальные тесты приложения. Все тестовые спецификации будут помещаться в папку e2e, так как мы настроили, чтобы Protractor искал спецификации именно в этом месте. При написании тестов на Protractor необходимо учесть 2 вещи:
Синтаксис Jasmine
Protractor API
Синтаксис Jasmine
Создайте новый файл test.e2e-spec.ts с кодом.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
/* Path: e2e/test.e2e-spec.ts */ import { browser, by, element } from 'protractor'; describe('Protractor Demo', () => { beforeEach(() => { //The code here will get executed before each it block is called //browser.get('/'); }); it('should display the name of the application',() => { /*Expectations accept parameters that will be matched with the real value using Jasmine's matcher functions. eg. toEqual(),toContain(), toBe(), toBeTruthy() etc. */ expect("Pastebin Application").toEqual("Pastebin Application"); }); it('should click the create Paste button',() => { //spec goes here }); }); |
Здесь описывается, как наши тесты будут организованы в файле спецификации с помощью синтаксиса Jasmine. describe(), beforeEach() и it() – глобальные функции Jasmine.
У Jasmine хороший синтаксис для написания тестов, и он отлично работает с Protractor. Если вы не знакомы с Jasmine, рекомендую сначала зайти на страницу Jasmine на GitHub.
Блок describe разбивает тесты на логические сьюты. В каждом блоке describe (или тест сьюте) может быть несколько блоков it (тестовых спецификаций). Сами тесты пишутся в тестовых спецификациях.
Вы спросите: «зачем мне делать такую структуру в тестах?». Тест сьют логически описывает отдельную функцию приложения. Например, все тестовые спецификации по компоненту Pastebin должны, в идеале, быть покрыты в блоке describe с заголовком Pastebin Page. Однако это может привести к избыточности тестов, но сделает их более читаемыми и обслуживаемыми.
В блоке describe может быть метод beforeEach(), который запускается один раз перед каждой спецификацией в этом блоке. То есть если вам перед каждым тестом необходимо редиректить браузер на заданный URL, разместите редирект внутри beforeEach().
Выражения expect, принимающие значение, сцеплены с функциями поиска. Реальное и ожидаемое значение сравниваются, и возвращается булево значение, показывающее, провалился тест или нет.
Protractor API
Давайте «обтянем наш скелет плотью».
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
/* Path: e2e/test.e2e-spec.ts */ import { browser, by, element } from 'protractor'; describe('Protractor Demo', () => { beforeEach(() => { browser.get('/'); }); it('should display the name of the application',() => { expect(element(by.css('.pastebin')).getText()).toContain('Pastebin Application'); }); it('create Paste button should work',() => { expect(element(by.id('source-modal')).isPresent()).toBeFalsy("The modal window shouldn't appear right now "); element(by.buttonText('create Paste')).click(); expect(element(by.id('source-modal')).isPresent()).toBeTruthy('The modal window should appear now'); }); }); |
browser.get(‘/’) и element(by.css(‘.pastebin’)).getText() – часть Protractor API. Давайте испачкаем руки и запрыгнем в Protractor. Популярные компоненты из Protractor API перечислены ниже.
Browser(): вызывается для всех операций на уровне браузера, например, навигация, дебагинг и т.д.
Element(): используется для поиска элемента в DOM по условию поиска или связки условий. Возвращается объект ElementFinder, с ним можно выполнять операции типа getText() или click().
element.all(): ищет массив элементов по связке условий. Возвращает объект ElementArrayFinder. К ElementArrayFinder применимы все операции от ElementFinder.
Локаторы: с помощью локаторов в приложении Angular можно искать элементы.
Мы очень часто будем использовать локаторы, поэтому вот самые исползуемые:
By.css(‘selector-name’): самый часто используемый локатор для поиска элемента по названию CSS селектора
by.name(‘name-value’): обнаруживает элемент по значению атрибута name
by.buttonText(‘button-value’): находит элемент button или массив button по внутреннему тексту
Обратите внимание: локаторы by.model, by.binding и by.repeater не работают в приложениях Angular 2+ на момент написания урока. Используйте CSS локаторы. Напишем тесты для нашего приложения Pastebin.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
it('should accept and save input values', () => { element(by.buttonText('create Paste')).click(); //send input values to the form using sendKeys element(by.name('title')).sendKeys('Hello world in Ruby'); element(by.name('language')).element(by.cssContainingText('option', 'Ruby')).click(); element(by.name('paste')).sendKeys("puts 'Hello world';"); element(by.buttonText('Save')).click(); //expect the table to contain the new paste const lastRow = element.all(by.tagName('tr')).last(); expect(lastRow.getText()).toContain("Hello world in Ruby"); }); |
Код выше работает, можете сами проверить. Однако не будет ли удобнее писать тесты без словаря Protractor в файле спецификации? О чем я говорю:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
it('should have an Create Paste button and modal window', () => { expect(addPastePage.isCreateButtonPresent()).toBeTruthy("The button should exist"); expect(addPastePage.isCreatePasteModalPresent()).toBeFalsy("The modal window shouldn't appear, not yet!"); addPastePage.clickCreateButton(); expect(addPastePage.isCreatePasteModalPresent()).toBeTruthy("The modal window should appear now"); }); it('should accept and save input values', () => { addPastePage.clickCreateButton(); //Input field should be empty initially const emptyInputValues = ["","",""]; expect(addPastePage.getInputPasteValues()).toEqual(emptyInputValues); //Now update the input fields addPastePage.addNewPaste(); addPastePage.clickSaveButton(); expect(addPastePage.isCreatePasteModalPresent()).toBeFalsy("The modal window should be gone"); expect(mainPage.getLastRowData()).toContain("Hello World in Ruby"); }); |
Спецификации кажутся проще без дополнительного мусор от Protractor. Как я это сделал? Позвольте познакомить вас с Page Objects.
Page Objects
Page Objects – шаблон проектирования, популярный среди автоматизаторов кейсов. Объект страницы моделирует страницу или часть приложения с помощью объектно-ориентированного класса. Все объекты (нашего теста), такие как текст, заголовки, таблицы, кнопки и ссылки захватываются в объект страницы. Эти объекты страниц можно импортировать в файл спецификации и выполнять их методы. Это снижает повторения в коде и упрощает его обслуживание.
Создайте папку page-objects и добавьте новый файл pastebin.po.ts. Все объекты компонента Pastebin будут здесь. Как я раньше уже сказал, мы разбили все приложения на 3 компонента, и у каждого компонента будет объект страницы. Схема именования .po.ts, имена можете придумать, какие угодно.
Макет страницы для тестирования.
Код pastebin.po.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
/* Path e2e/page-objects/pastebin.po.ts*/ import { browser, by, element, promise, ElementFinder, ElementArrayFinder } from 'protractor'; export class Pastebin extends Base { navigateToHome():promise.Promise<any> { return browser.get('/'); } getPastebin():ElementFinder { return element(by.css('.pastebin')); } /* Pastebin Heading */ getPastebinHeading(): promise.Promise<string> { return this.getPastebin().element(by.css("h2")).getText(); } /*Table Data */ getTable():ElementFinder { return this.getTable().element(by.css('table')); } getTableHeader(): promise.Promise<string> { return this.getPastebin().all(by.tagName('tr')).get(0).getText(); } getTableRow(): ElementArrayFinder { return this.getPastebin().all(by.tagName('tr')); } getFirstRowData(): promise.Promise<string> { return this.getTableRow().get(1).getText(); } getLastRowData(): promise.Promise<string> { return this.getTableRow().last().getText(); } /*app-add-paste tag*/ getAddPasteTag(): ElementFinder { return this.getPastebin().element(by.tagName('app-add-paste')); } isAddPasteTagPresent(): promise.Promise<boolean> { return this.getAddPasteTag().isPresent(); } } |
Что мы уже знаем. Protractor API возвращает объекты. Пока что мы работали с объектами трех типов. Это:
promise.Promise
ElementFinder
ElementArrayFinder
Если коротко, element() возвращает ElementFinder, element().all возвращает ElementArrayFinder. С помощью локаторов (by.css, by.tagName и т.д.) можно искать положение элемента в DOM и передавать его в element() или element.all().
ElementFinder и ElementArrayFinder можно сцепить действиями, например, isPresent(), getText(), click() и т.д. Эти метод возвращают промис, который разрешается, когда завершается определенное действие.
Почему у нас в тесте нет цепи then() – потому что Protractor делает это внутри. Тесты кажутся синхронными, хотя это не так. Поэтому наш код будет линейным. Но я рекомендую использовать синтаксис async/await , чтобы защитить код.
Ниже показано, как можно сцепить объекты ElementFinder. Это очень удобно, если в DOM есть несколько селекторов на одно имя, а нам нужно выбрать правильное.
1 2 3 4 |
getTable():ElementFinder { return this.getPastebin().element(by.css('table')); } |
Наш код для объекта страницы готов. Давайте импортируем его в спецификацию. Код для первичных тестов.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
/* Path: e2e/mainPage.e2e-spec.ts */ import { Pastebin } from './page-objects/pastebin.po'; import { browser, protractor } from 'protractor'; /* Scenarios to be Tested 1. Pastebin Page should display a heading with text Pastebin Application 2. It should have a table header 3. The table should have rows 4. app-add-paste tag should exist */ describe('Pastebin Page', () => { const mainPage: Pastebin = new Pastebin(); beforeEach(() => { mainPage.navigateToHome(); }); it('should display the heading Pastebin Application', () => { expect(mainPage.getPastebinHeading()).toEqual("Pastebin Application"); }); it('should have a table header', () => { expect(mainPage.getTableHeader()).toContain("id Title Language Code"); }) it('table should have at least one row', () => { expect(mainPage.getFirstRowData()).toContain("Hello world"); }) it('should have the app-add-paste tag', () => { expect(mainPage.isAddPasteTagPresent()).toBeTruthy(); }) }); |
Организация тестов и рефакторинг
Тесты должны быть организованы так, чтобы общая структура выглядела просто и понятно. Вот пара советов, которых следует придерживаться при организации E2E тестов.
Отделите E2E тесты от юнит тестов
Сгруппируйте E2E тесты разумно. Организуйте тесты по структуре проекта
Если у вас много страниц, у объектов страниц должен быть отдельный путь
Если объекты страниц делят методы (например navigateToHome()), создайте объект базовой страницы. Модели других страниц могут наследоваться от модели базовой страницы
Сделайте тесты независимыми друг от друга. Вам не нужно, чтобы все тесты провалились из-за небольшого изменения в UI, так ведь?
Не используйте проверки в определениях объектов страниц. Проверки должны быть в файле спецификации
Следуя советам выше, мы получаем следующую иерархию страниц и организацию файлов.
О файлах pastebin.po.ts и mainPage.e2e-spec.ts мы уже говорили. Ниже представлены оставшиеся файлы. Base Page Object
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
/* path: e2e/page-objects/base.po.ts */ import { browser, by, element, promise, ElementFinder, ElementArrayFinder } from 'protractor'; export class Base { /* Navigational methods */ navigateToHome():promise.Promise<any> { return browser.get('/'); } navigateToAbout():promise.Promise<any> { return browser.get('/about'); } navigateToContact():promise.Promise<any> { return browser.get('/contact'); } /* Mock data for creating a new Paste and editing existing paste */ getMockPaste(): any { let paste: any = { title: "Something here",language: "Ruby",paste: "Test"} return paste; } getEditedMockPaste(): any { let paste: any = { title: "Paste 2", language: "JavaScript", paste: "Test2" } return paste; } /* Methods shared by addPaste and viewPaste */ getInputTitle():ElementFinder { return element(by.name("title")); } getInputLanguage(): ElementFinder { return element(by.name("language")); } getInputPaste(): ElementFinder { return element(by.name("paste")); } } |
Объект Add Paste Page
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
/* Path: e2e/page-objects/add-paste.po.ts */ import { browser, by, element, promise, ElementFinder, ElementArrayFinder } from 'protractor'; import { Base } from './base.po'; export class AddPaste extends Base { getAddPaste():ElementFinder { return element(by.tagName('app-add-paste')); } /* Create Paste button */ getCreateButton(): ElementFinder { return this.getAddPaste().element(by.buttonText("create Paste")); } isCreateButtonPresent() : promise.Promise<boolean> { return this.getCreateButton().isPresent(); } clickCreateButton(): promise.Promise<void> { return this.getCreateButton().click(); } /*Create Paste Modal */ getCreatePasteModal(): ElementFinder { return this.getAddPaste().element(by.id("source-modal")); } isCreatePasteModalPresent() : promise.Promise<boolean> { return this.getCreatePasteModal().isPresent(); } /*Save button */ getSaveButton(): ElementFinder { return this.getAddPaste().element(by.buttonText("Save")); } clickSaveButton():promise.Promise<void> { return this.getSaveButton().click(); } /*Close button */ getCloseButton(): ElementFinder { return this.getAddPaste().element(by.buttonText("Close")); } clickCloseButton():promise.Promise<void> { return this.getCloseButton().click(); } /* Get Input Paste values from the Modal window */ getInputPasteValues(): Promise<string[]> { let inputTitle, inputLanguage, inputPaste; // Return the input values after the promise is resolved // Note that this.getInputTitle().getText doesn't work // Use getAttribute('value') instead return Promise.all([this.getInputTitle().getAttribute("value"), this.getInputLanguage().getAttribute("value"), this.getInputPaste().getAttribute("value")]) .then( (values) => { return values; }); } /* Add a new Paste */ addNewPaste():any { let newPaste: any = this.getMockPaste(); //Send input values this.getInputTitle().sendKeys(newPaste.title); this.getInputLanguage() .element(by.cssContainingText('option', newPaste.language)).click(); this.getInputPaste().sendKeys(newPaste.paste); //Convert the paste object into an array return Object.keys(newPaste).map(key => newPaste[key]); } } |
Файл Add Paste Spec
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
/* Path: e2e/addNewPaste.e2e-spec.ts */ import { Pastebin } from './page-objects/pastebin.po'; import { AddPaste } from './page-objects/add-paste.po'; import { browser, protractor } from 'protractor'; /* Scenarios to be Tested 1. AddPaste Page should have a button when clicked on should present a modal window 2. The modal window should accept the new values and save them 4. The saved data should appear in the MainPage 3. Close button should work */ describe('Add-New-Paste page', () => { const addPastePage: AddPaste = new AddPaste(); const mainPage: Pastebin = new Pastebin(); beforeEach(() => { addPastePage.navigateToHome(); }); it('should have an Create Paste button and modal window', () => { expect(addPastePage.isCreateButtonPresent()).toBeTruthy("The button should exist"); expect(addPastePage.isCreatePasteModalPresent()).toBeFalsy("The modal window shouldn't appear, not yet!"); addPastePage.clickCreateButton(); expect(addPastePage.isCreatePasteModalPresent()).toBeTruthy("The modal window should appear now"); }); it("should accept and save input values", () => { addPastePage.clickCreateButton(); const emptyInputValues = ["","",""]; expect(addPastePage.getInputPasteValues()).toEqual(emptyInputValues); const newInputValues = addPastePage.addNewPaste(); expect(addPastePage.getInputPasteValues()).toEqual(newInputValues); addPastePage.clickSaveButton(); expect(addPastePage.isCreatePasteModalPresent()).toBeFalsy("The modal window should be gone"); expect(mainPage.getLastRowData()).toContain("Something here"); }); it("close button should work", () => { addPastePage.clickCreateButton(); addPastePage.clickCloseButton(); expect(addPastePage.isCreatePasteModalPresent()).toBeFalsy("The modal window should be gone"); }); }); |
Упражнения
Мы кое-что не учли: тесты для кнопки View Paste и модальное окно, которое появляется после клика на нее. Это будет ваше упражнение. Но я дам подсказку. Структура объектов страницы и спецификаций для ViewPastePage такая же, как для AddPastePage.
Вам необходимо протестировать следующие сценарии:
Страница ViewPaste должна иметь кнопку, по клику должна появляться модалка
Модальное окно должно отображать данные последней вставки
Модальное окно должно позволять обновлять значения
Должна работать кнопка удаления
Попробуйте максимально придерживаться советов. Если сомневаетесь, переключитесь на финальную ветку, чтобы увидеть конечный черновик кода.
Заключение
Вот и все. В этой статье мы научились писать комплексные тесты для Angular приложения с помощью Protractor. Сначала мы поговорили о юнит тестах и e2e тестах, после чего изучили установку, настройку и запуск Protractor. Оставшаяся часть урока была отдана на написание тестов под демо приложение Pastebin.
Автор: Manjunath M
Источник: //code.tutsplus.com/
Редакция: Команда webformyself.