From 6107972e537470287e09f746ac4d9c5f3796b4c9 Mon Sep 17 00:00:00 2001 From: Tomas Novacik Date: Tue, 24 Jun 2014 15:05:27 +0200 Subject: [PATCH] Add regions module to integration tests Regions module should contains basic functionality that should be shared among multiple page objects: common tables, forms, notifications etc.. Basically every piece of code that can be reused by multiple classes in a form of a new object should be placed in here. * I would suggest that already created regions should be moved into this module. * I would consider region everything that can be reused by at least two page objects and aim for maximal code reusability in the future. * Every new region should inherit from class BaseRegion located in baseregion.py. Partially implements blueprint: selenium-integration-testing Change-Id: Ib8c20d8833e0830c85030633091663d33de6e3ab --- .../test/integration_tests/basewebobject.py | 7 +- .../test/integration_tests/pages/basepage.py | 58 +---- .../integration_tests/pages/projectpage.py | 20 ++ .../integration_tests/regions/__init__.py | 0 .../test/integration_tests/regions/bars.py | 60 +++++ .../integration_tests/regions/baseregion.py | 94 ++++++++ .../test/integration_tests/regions/forms.py | 73 ++++++ .../integration_tests/regions/messages.py | 23 ++ .../test/integration_tests/regions/tables.py | 221 ++++++++++++++++++ 9 files changed, 508 insertions(+), 48 deletions(-) create mode 100644 openstack_dashboard/test/integration_tests/regions/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/regions/bars.py create mode 100644 openstack_dashboard/test/integration_tests/regions/baseregion.py create mode 100644 openstack_dashboard/test/integration_tests/regions/forms.py create mode 100644 openstack_dashboard/test/integration_tests/regions/messages.py create mode 100644 openstack_dashboard/test/integration_tests/regions/tables.py diff --git a/openstack_dashboard/test/integration_tests/basewebobject.py b/openstack_dashboard/test/integration_tests/basewebobject.py index 99b1ca435e..0720c28053 100644 --- a/openstack_dashboard/test/integration_tests/basewebobject.py +++ b/openstack_dashboard/test/integration_tests/basewebobject.py @@ -22,14 +22,14 @@ class BaseWebObject(object): def _is_element_present(self, *locator): try: - self.driver.find_element(*locator) + self._get_element(*locator) return True except Exceptions.NoSuchElementException: return False def _is_element_visible(self, *locator): try: - return self.driver.find_element(*locator).is_displayed() + return self._get_element(*locator).is_displayed() except (Exceptions.NoSuchElementException, Exceptions.ElementNotVisibleException): return False @@ -37,6 +37,9 @@ class BaseWebObject(object): def _get_element(self, *locator): return self.driver.find_element(*locator) + def _get_elements(self, *locator): + return self.driver.find_elements(*locator) + def _fill_field_element(self, data, field_element): field_element.clear() field_element.send_keys(data) diff --git a/openstack_dashboard/test/integration_tests/pages/basepage.py b/openstack_dashboard/test/integration_tests/pages/basepage.py index 7725f76140..ca1492f610 100644 --- a/openstack_dashboard/test/integration_tests/pages/basepage.py +++ b/openstack_dashboard/test/integration_tests/pages/basepage.py @@ -14,12 +14,15 @@ from selenium.webdriver.common import by from openstack_dashboard.test.integration_tests import basewebobject from openstack_dashboard.test.integration_tests.pages import pageobject +from openstack_dashboard.test.integration_tests.regions import bars +from openstack_dashboard.test.integration_tests.regions import messages class BasePage(pageobject.PageObject): """Base class for all dashboard page objects.""" - _heading_locator = (by.By.CSS_SELECTOR, "div.page-header > h2") + _heading_locator = (by.By.CSS_SELECTOR, 'div.page-header > h2') + _error_msg_locator = (by.By.CSS_SELECTOR, 'div.alert-danger.alert') @property def heading(self): @@ -27,7 +30,7 @@ class BasePage(pageobject.PageObject): @property def topbar(self): - return BasePage.TopBarRegion(self.driver, self.conf) + return bars.TopBarRegion(self.driver, self.conf) @property def is_logged_in(self): @@ -37,6 +40,13 @@ class BasePage(pageobject.PageObject): def navaccordion(self): return BasePage.NavigationAccordionRegion(self.driver, self.conf) + def error_message(self): + src_elem = self._get_element(*self._error_msg_locator) + return messages.ErrorMessageRegion(self.driver, self.conf, src_elem) + + def is_error_message_present(self): + return self._is_element_present(*self._error_msg_locator) + def go_to_login_page(self): self.driver.get(self.login_url) @@ -52,50 +62,6 @@ class BasePage(pageobject.PageObject): self.topbar.user_dropdown_menu.click() self.topbar.help_link.click() - class TopBarRegion(basewebobject.BaseWebObject): - _user_dropdown_menu_locator = (by.By.CSS_SELECTOR, - 'div#profile_editor_switcher' - ' > button') - _settings_link_locator = (by.By.CSS_SELECTOR, - 'a[href*="/settings/"]') - _help_link_locator = (by.By.CSS_SELECTOR, - 'ul#editor_list li:nth-of-type(2) > a') - _logout_link_locator = (by.By.CSS_SELECTOR, - 'a[href*="/auth/logout/"]') - _openstack_brand_locator = (by.By.CSS_SELECTOR, 'a[href*="/home/"]') - - @property - def user(self): - return self._get_element(*self._user_dropdown_menu_locator) - - @property - def brand(self): - return self._get_element(*self._openstack_brand_locator) - - @property - def logout_link(self): - return self._get_element(*self._logout_link_locator) - - @property - def user_dropdown_menu(self): - return self._get_element(*self._user_dropdown_menu_locator) - - @property - def settings_link(self): - return self._get_element(*self._settings_link_locator) - - @property - def help_link(self): - return self._get_element(*self._help_link_locator) - - @property - def is_logout_visible(self): - return self._is_element_visible(*self._logout_link_locator) - - @property - def is_logged_in(self): - return self._is_element_visible(*self._user_dropdown_menu_locator) - class NavigationAccordionRegion(basewebobject.BaseWebObject): # TODO(sunlim): change Xpath to CSS _project_bar_locator = ( diff --git a/openstack_dashboard/test/integration_tests/pages/projectpage.py b/openstack_dashboard/test/integration_tests/pages/projectpage.py index 59b952d971..c38bdca1d4 100644 --- a/openstack_dashboard/test/integration_tests/pages/projectpage.py +++ b/openstack_dashboard/test/integration_tests/pages/projectpage.py @@ -9,13 +9,22 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. +from selenium.webdriver.common import by from openstack_dashboard.test.integration_tests.pages import accesssecuritypage from openstack_dashboard.test.integration_tests.pages import basepage from openstack_dashboard.test.integration_tests.pages import settingspage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables class ProjectPage(basepage.BasePage): + + _usage_table_locator = (by.By.CSS_SELECTOR, 'table#project_usage') + _date_form_locator = (by.By.CSS_SELECTOR, 'form#date_form') + + USAGE_TABLE_ACTIONS = ("download_csv",) + def __init__(self, driver, conf): super(ProjectPage, self).__init__(driver, conf) self._page_title = 'Instance Overview' @@ -33,3 +42,14 @@ class ProjectPage(basepage.BasePage): self.navaccordion.access_security.click() return accesssecuritypage.AccessSecurityPage( self.driver, self.conf) + + @property + def usage_table(self): + src_elem = self._get_element(*self._usage_table_locator) + return tables.ActionsTableRegion(self.driver, self.conf, src_elem, + self.USAGE_TABLE_ACTIONS) + + @property + def date_form(self): + src_elem = self._get_element(*self._date_form_locator) + return forms.DateFormRegion(self.driver, self.conf, src_elem) diff --git a/openstack_dashboard/test/integration_tests/regions/__init__.py b/openstack_dashboard/test/integration_tests/regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/regions/bars.py b/openstack_dashboard/test/integration_tests/regions/bars.py new file mode 100644 index 0000000000..dcfd276f12 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/regions/bars.py @@ -0,0 +1,60 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from selenium.webdriver.common import by + +from openstack_dashboard.test.integration_tests.regions import baseregion + + +class TopBarRegion(baseregion.BaseRegion): + _user_dropdown_menu_locator = (by.By.CSS_SELECTOR, + 'div#profile_editor_switcher' + ' > button') + _settings_link_locator = (by.By.CSS_SELECTOR, + 'a[href*="/settings/"]') + _help_link_locator = (by.By.CSS_SELECTOR, + 'ul#editor_list li:nth-of-type(2) > a') + _logout_link_locator = (by.By.CSS_SELECTOR, + 'a[href*="/auth/logout/"]') + _openstack_brand_locator = (by.By.CSS_SELECTOR, 'a[href*="/home/"]') + + @property + def user(self): + return self._get_element(*self._user_dropdown_menu_locator) + + @property + def brand(self): + return self._get_element(*self._openstack_brand_locator) + + @property + def logout_link(self): + return self._get_element(*self._logout_link_locator) + + @property + def user_dropdown_menu(self): + return self._get_element(*self._user_dropdown_menu_locator) + + @property + def settings_link(self): + return self._get_element(*self._settings_link_locator) + + @property + def help_link(self): + return self._get_element(*self._help_link_locator) + + @property + def is_logout_visible(self): + return self._is_element_visible(*self._logout_link_locator) + + @property + def is_logged_in(self): + return self._is_element_visible(*self._user_dropdown_menu_locator) diff --git a/openstack_dashboard/test/integration_tests/regions/baseregion.py b/openstack_dashboard/test/integration_tests/regions/baseregion.py new file mode 100644 index 0000000000..0b7be6ed23 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/regions/baseregion.py @@ -0,0 +1,94 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import types + +from openstack_dashboard.test.integration_tests import basewebobject + + +class BaseRegion(basewebobject.BaseWebObject): + """Base class for region module + * there is necessity to override some basic methods for obtaining elements + as in content of regions it is required to do relative searches + + * self.driver cannot be easily replaced with self.src_elem because that + would result in functionality loss, self.driver is WebDriver and + src_elem is WebElement its usage is different. + + * this does not mean that self.src_elem cannot be self.driver + """ + + # private methods + def __init__(self, driver, conf, src_elem=None): + super(BaseRegion, self).__init__(driver, conf) + self.src_elem = src_elem or driver + # variable for storing names of dynamic properties and + # associated 'getters' - meaning method that are supplying + # regions or web elements + self._dynamic_properties = {} + + def __getattr__(self, name): + """It is not possible to create property bounded just to object + and not class at runtime, therefore it is necessary to + override __getattr__ and make fake 'properties' by storing them in + the protected attribute _dynamic_attributes and returning result + of the method associated with the specified attribute. + + This way the feeling of having regions accessed as 'properties' + is created, which is one of the requirement of page object pattern. + """ + try: + return self._dynamic_properties[name]() + except KeyError: + msg = "'{0}' object has no attribute '{1}'" + raise AttributeError(msg.format(type(self).__name__, name)) + + # protected methods and classes + class _DynamicProperty(object): + """Serves as new property holder.""" + + def __init__(self, method, index=None): + """In case object was created with index != None, + it is assumed that the result of self.method should be tuple() + and just certain index should be returned + """ + self.method = method + self.index = index + + def __call__(self, *args, **kwargs): + result = self.method() + return result if self.index is None else result[self.index] + + def _init_dynamic_properties(self, new_attr_names, method): + """Create new object's 'properties' at runtime.""" + for index, new_attr_name in enumerate(new_attr_names): + self._init_dynamic_property(new_attr_name, method, index) + + def _init_dynamic_property(self, new_attr_name, method, index=None): + """Create new object's property at runtime. If index argument is + supplied it is assumed that method returns tuple() and only element + on ${index} position is returned. + """ + if (new_attr_name in dir(self) or + new_attr_name in self._dynamic_properties): + raise AttributeError("%s class has already attribute %s." + "The new property could not be " + "created." % (self.__class__.__name__, + new_attr_name)) + new_method = self.__class__._DynamicProperty(method, index) + inst_method = types.MethodType(new_method, self) + self._dynamic_properties[new_attr_name] = inst_method + + def _get_element(self, *locator): + return self.src_elem.find_element(*locator) + + def _get_elements(self, *locator): + return self.src_elem.find_elements(*locator) diff --git a/openstack_dashboard/test/integration_tests/regions/forms.py b/openstack_dashboard/test/integration_tests/regions/forms.py new file mode 100644 index 0000000000..326d363a34 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/regions/forms.py @@ -0,0 +1,73 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from selenium.webdriver.common import by + +from openstack_dashboard.test.integration_tests.regions import baseregion + + +class BaseFormFieldRegion(baseregion.BaseRegion): + """Base class for form fields classes.""" + + _label_locator = None + _element_locator = None + + @property + def label(self): + return self._get_element(*self._label_locator) + + @property + def element(self): + return self._get_element(*self._element_locator) + + def is_required(self): + classes = self.driver.get_attribute('class') + return 'required' in classes + + +class BaseFormRegion(baseregion.BaseRegion): + """Base class for forms.""" + + _submit_locator = (by.By.CSS_SELECTOR, 'button.btn.btn-primary,' + ' a.btn.btn-primary') + + def submit(self): + self._get_element(*self._submit_locator).click() + + +class DateFormRegion(BaseFormRegion): + """Form that queries data to table that is regularly below the form, + typical example is located on Project/Compute/Overview page. + """ + + _from_field_locator = (by.By.CSS_SELECTOR, 'input#id_start') + _to_field_locator = (by.By.CSS_SELECTOR, 'input#id_end') + + @property + def from_date(self): + return self._get_element(*self._from_field_locator) + + @property + def to_date(self): + return self._get_element(*self._to_field_locator) + + def query(self, start, end): + self._set_from_field(start) + self._set_to_field(end) + self.submit() + + def _set_from_field(self, value): + self._fill_field_element(value, self.from_date) + + def _set_to_field(self, value): + self._fill_field_element(value, self.to_date) diff --git a/openstack_dashboard/test/integration_tests/regions/messages.py b/openstack_dashboard/test/integration_tests/regions/messages.py new file mode 100644 index 0000000000..e306533549 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/regions/messages.py @@ -0,0 +1,23 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from selenium.webdriver.common import by + +from openstack_dashboard.test.integration_tests.regions import baseregion + + +class ErrorMessageRegion(baseregion.BaseRegion): + + _close_locator = (by.By.CSS_SELECTOR, 'a.close') + + def close(self): + self._get_element(*self._close_locator).click() diff --git a/openstack_dashboard/test/integration_tests/regions/tables.py b/openstack_dashboard/test/integration_tests/regions/tables.py new file mode 100644 index 0000000000..adf531bab3 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/regions/tables.py @@ -0,0 +1,221 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from selenium.webdriver.common import by + +from openstack_dashboard.test.integration_tests.regions import baseregion + + +class RowRegion(baseregion.BaseRegion): + """Classic table row.""" + + _cell_locator = (by.By.CSS_SELECTOR, 'td.normal_column') + + @property + def cells(self): + return self._get_elements(*self._cell_locator) + + +class BaseActionRowRegion(RowRegion): + """Base class for creating ActionRow class derivative.""" + + _row_checkbox_locator = (by.By.CSS_SELECTOR, 'td > input') + + def mark(self): + chck_box = self._get_element(*self._row_checkbox_locator) + chck_box.click() + + +class BtnActionRowRegion(BaseActionRowRegion): + """Row with buttons in action column.""" + + _action_locator = (by.By.CSS_SELECTOR, 'td.actions_column > button') + + def __init__(self, driver, conf, src_elem, action_name): + super(BtnActionRowRegion, self).__init__(driver, conf, src_elem) + self.action_name = action_name + self._init_action() + + def _init_action(self): + self._init_dynamic_property(self.action_name, self._get_action) + + def _get_action(self): + return self._get_element(*self._action_locator) + + @property + def action(self): + return self._get_action() + + +class ComplexActionRowRegion(BaseActionRowRegion): + """Row with button and select box in action column.""" + + _primary_action_locator = (by.By.CSS_SELECTOR, + 'td.actions_column > div.btn-group > *.btn') + _secondary_actions_dropdown_locator = (by.By.CSS_SELECTOR, + 'div.btn-group') + + PRIMARY_ACTION = "primary_action" + SECONDARY_ACTIONS = "secondary_actions" + + ACTIONS_ERROR_MSG = ("Actions must be supplied in dictionary:" + " {%s: 'action_name', '%s': ('action_name',...)}" + % (PRIMARY_ACTION, SECONDARY_ACTIONS)) + + def __init__(self, driver, conf, src_elem, action_names): + super(ComplexActionRowRegion, self).__init__(driver, conf, src_elem) + try: + self.primary_action_name = action_names[self.PRIMARY_ACTION] + self.secondary_action_names = action_names[self.SECONDARY_ACTIONS] + self._init_actions() + except (TypeError, KeyError): + raise AttributeError(self.ACTIONS_ERROR_MSG) + + def _init_actions(self): + self._init_dynamic_property(self.primary_action_name, + self._get_primary_action) + self._init_dynamic_properties(self.secondary_action_names, + self._get_secondary_actions) + + def _get_primary_action(self): + return self._get_element(*self._primary_action_locator) + + def _get_secondary_actions(self): + return self._get_elements(*self._secondary_actions_dropdown_locator) + + @property + def primary_action(self): + return self._get_primary_action() + + @property + def secondary_actions(self): + return self._get_secondary_actions() + + +class BasicTableRegion(baseregion.BaseRegion): + """Basic class representing table object.""" + + _heading_locator = (by.By.CSS_SELECTOR, 'h3.table_title') + _columns_names_locator = (by.By.CSS_SELECTOR, 'thead > tr > th') + _footer_locator = (by.By.CSS_SELECTOR, 'tfoot > tr > td > span') + _rows_locator = (by.By.CSS_SELECTOR, 'tbody > tr') + _empty_table_locator = (by.By.CSS_SELECTOR, 'tbody > tr.empty') + _search_field_locator = (by.By.CSS_SELECTOR, + 'div.table_search.client > input') + _search_button_locator = (by.By.CSS_SELECTOR, + 'div.table_search.client > button') + + @property + def heading(self): + return self._get_element(*self._heading_locator) + + @property + def rows(self): + if self._is_element_present(*self._empty_table_locator): + return [] + else: + return self._get_rows() + + @property + def column_names(self): + return self._get_elements(*self._columns_names_locator) + + @property + def footer(self): + return self._get_element(*self._footer_locator) + + def filter(self, value): + self._set_search_field(value) + self._click_search_btn() + + def get_row(self, column_index, text): + """Get row that contains in specified column specified text.""" + for row in self.rows: + if text in row.cells[column_index].text: + return row + return None + + def _set_search_field(self, value): + srch_field = self._get_element(*self._search_field_locator) + srch_field.send_keys(value) + + def _click_search_btn(self): + btn = self._get_element(*self._search_button_locator) + btn.click() + + def _get_rows(self): + rows = [] + for elem in self._get_elements(*self._rows_locator): + rows.append(RowRegion(self.driver, self.conf, elem)) + + +class ActionsTableRegion(BasicTableRegion): + """Base class for creating derivative of BasicTableRegion that + has some actions. + """ + + _actions_locator = (by.By.CSS_SELECTOR, 'div.table_actions > button,' + ' div.table_actions > a') + + # private methods + def __init__(self, driver, conf, src_elm, action_names): + super(ActionsTableRegion, self).__init__(driver, conf, src_elm) + self.action_names = action_names + self._init_actions() + + # protected methods + def _init_actions(self): + """Create new methods that corresponds to picking table's + action buttons. + """ + self._init_dynamic_properties(self.action_names, self._get_actions) + + def _get_actions(self): + return self._get_elements(*self._actions_locator) + + # properties + @property + def actions(self): + return self._get_actions() + + +class SimpleActionsTableRegion(ActionsTableRegion): + """Table which rows has buttons in action column.""" + + def __init__(self, driver, conf, src_elm, action_names, row_action_name): + super(SimpleActionsTableRegion, self).__init__(driver, conf, src_elm, + action_names) + self.row_action_name = row_action_name + + def _get_rows(self): + rows = [] + for elem in self._get_elements(*self._rows_locator): + rows.append(BtnActionRowRegion(self.driver, self.conf, elem, + self.row_action_name)) + return rows + + +class ComplexActionTableRegion(ActionsTableRegion): + """Table which has button and selectbox in the action column.""" + + def __init__(self, driver, conf, src_elm, action_names, row_action_names): + super(ComplexActionTableRegion, self).__init__(driver, conf, src_elm, + action_names) + self.row_action_names = row_action_names + + def _get_rows(self): + rows = [] + for elem in self._get_elements(*self._rows_locator): + rows.append(ComplexActionRowRegion(self.driver, self.conf, elem, + self.row_action_names)) + return rows