Skip to content

Commit

Permalink
#920 #921 test js ecommerce (#926)
Browse files Browse the repository at this point in the history
* Add implicit_wait to env

* Create SiteDriver.short_wait for stale elements

* Implement methods for CatalogCard and CartPosition

* Implement context-manager to wait cart changes

* Use Cart context-manager

* Test Yandex ecommerce event on a product adding from a category page

* Update todo parent number

* Apply review fixes

* Apply linter rules

* Add SELENIUM_IMPLICIT_WAIT to .env

* Trigger CI
  • Loading branch information
ArtemijRodionov authored Jul 5, 2019
1 parent 495434f commit 3aa1b47
Show file tree
Hide file tree
Showing 12 changed files with 180 additions and 46 deletions.
3 changes: 2 additions & 1 deletion .drone.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ docker-compose: &docker-compose
test: &test
<<: *when-pr-push
image: fidals/se:dev
secrets: [ FTP_IP, FTP_USER, FTP_PASS, SELENIUM_WAIT_SECONDS, SELENIUM_TIMEOUT_SECONDS ]
secrets: [ FTP_IP, FTP_USER, FTP_PASS, SELENIUM_WAIT_SECONDS, SELENIUM_IMPLICIT_WAIT, SELENIUM_TIMEOUT_SECONDS ]
environment:
- TEST_ENV=true
- DJANGO_SETTINGS_MODULE=shopelectro.settings.drone
Expand All @@ -29,6 +29,7 @@ test: &test
- FTP_PASS=${FTP_PASS}
- SELENIUM_WAIT_SECONDS=${SELENIUM_WAIT_SECONDS}
- SELENIUM_TIMEOUT_SECONDS=${SELENIUM_TIMEOUT_SECONDS}
- SELENIUM_IMPLICIT_WAIT=${SELENIUM_IMPLICIT_WAIT}

lint: &lint
<<: *when-pr-push
Expand Down
1 change: 1 addition & 0 deletions docker/drone_env/app
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ RABBITMQ_PORT=5672

SELENIUM_WAIT_SECONDS=120
SELENIUM_TIMEOUT_SECONDS=60
SELENIUM_IMPLICIT_WAIT=15
1 change: 1 addition & 0 deletions docker/env_files/app.dist
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ RABBITMQ_PORT=5672

SELENIUM_WAIT_SECONDS=60
SELENIUM_TIMEOUT_SECONDS=30
SELENIUM_IMPLICIT_WAIT=15
1 change: 1 addition & 0 deletions shopelectro/selenium/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def __init__(self, *, site_url, **kwargs):

self.site_url = site_url
self.wait = WebDriverWait(self, settings.SELENIUM_WAIT_SECONDS)
self.short_wait = WebDriverWait(self, settings.SELENIUM_WAIT_SECONDS // 4)

def __enter__(self):
return self
Expand Down
39 changes: 29 additions & 10 deletions shopelectro/selenium/elements/cart.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from contextlib import contextmanager

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support import expected_conditions as EC
Expand All @@ -22,20 +25,36 @@ def _hover(self):
(By.CLASS_NAME, 'js-cart-wrapper')
))

# @todo #920:15m Document the Cart.wait_changes.
# Cover corner cases with TimeoutException.

@contextmanager
def wait_changes(self):
def wait_changes(browser):
try:
return positions_before != self.positions()
except TimeoutException:
return False

positions_before = self.positions()
yield
self.driver.wait.until(wait_changes)

def positions(self) -> [elements.CartPosition]:
positions_count = len(self.driver.find_elements(
By.CLASS_NAME, 'basket-item'
))
try:
# use short_wait to avoid long pauses in case of the empty cart
positions_count = len(self.driver.short_wait.until(EC.presence_of_all_elements_located(
(By.CLASS_NAME, 'basket-item')
)))
except TimeoutException:
positions_count = 0

return [elements.CartPosition(self.driver, i) for i in range(positions_count)]

def remove(self, position: elements.CartPosition):
def wait_removing(browser):
return len(Cart(browser).positions()) < old_count

old_count = len(self.positions())
self._hover()
position.remove_from_cart()
self.driver.wait.until(wait_removing)
with self.wait_changes():
self._hover()
position.remove_from_cart()

def clear(self):
self._hover()
Expand Down
79 changes: 70 additions & 9 deletions shopelectro/selenium/elements/product.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import abc

from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC

from shopelectro.selenium.elements import Button, Unavailable
from shopelectro.selenium.driver import SiteDriver

from selenium.webdriver.common.by import By


class Product(abc.ABC):

def name(self):
raise Unavailable('determine the product name.')

def vendor_code(self):
raise Unavailable('determine the vendor code.')

def price(self):
raise Unavailable('determine the product price.')

Expand All @@ -26,17 +30,57 @@ def remove_from_cart(self):

class CatalogCard(Product):

def __init__(self, driver: SiteDriver, card_index: int):
def __init__(
self,
driver: SiteDriver,
*,
_index: int = None,
_id: int = None,
):
"""
Ctor.
:param int card_index: The index number of the product card at a category page
:param int _index: The index number of the product card at a category page
"""
self.driver = driver
self.xpath = f'//*[@id="products-wrapper"]/div[{card_index}]/div[2]/div[5]/'

if (_index is None and not _id) or (_index and _id):
raise ValueError('Provide either _index or _id to work with card.')
self._id = _id
self._index = _index

@classmethod
def with_id(
cls,
driver: SiteDriver,
id_: int,
):
return cls(driver, _id=id_)

@classmethod
def with_index(
cls,
driver: SiteDriver,
index: int,
):
return cls(driver, _index=index)

def _build_xpath(self, path=''):
product_xpath = '//*[@id="products-wrapper"]'

if self._id:
return f'{product_xpath}//*[@data-product-id="{self._id}"]/{path}'

# xpath indexes starts from 1
return f'{product_xpath}/div[{self._index + 1}]/{path}'

def vendor_code(self):
return self.driver.wait.until(EC.visibility_of_element_located(
(By.XPATH, self._build_xpath('div[2]/div[1]'))
)).text.split(' ')[1]

def add_to_cart(self):
Button(self.driver, (By.XPATH, f'{self.xpath}button')).click()
Button(self.driver, (By.XPATH, self._build_xpath('div[2]/div[5]/button'))).click()


class ProductCard(Product):
Expand All @@ -50,10 +94,27 @@ def add_to_cart(self):

class CartPosition(Product):

def __init__(self, driver: SiteDriver, pos_index: int):
def __init__(self, driver: SiteDriver, index: int):
self.driver = driver
# xpath indexes starts from 1
self.xpath = f'//ul[@id="basket-list"]/li[{pos_index + 1}]/'
self.xpath = f'//ul[@id="basket-list"]/li[{index + 1}]/'

def __hash__(self):
el = self._data_element()
return hash(
el.get_attribute('data-product-id')
+ '/'
+ el.get_attribute('data-product-count')
)

def __eq__(self, other: 'CartPosition'):
return hash(self) == hash(other)

def _data_element(self):
# use short_wait, because a position could be stale
return self.driver.short_wait.until(EC.presence_of_element_located(
(By.XPATH, f'{self.xpath}i')
))

def name(self):
raise Unavailable('determine the position name.')
Expand All @@ -62,7 +123,7 @@ def price(self):
raise Unavailable('determine the position price.')

def quantity(self):
raise Unavailable('determine the position quantity.')
return self._data_element().get_attribute('data-product-count')

def remove_from_cart(self):
Button(self.driver, (By.XPATH, f'{self.xpath}i')).click()
46 changes: 37 additions & 9 deletions shopelectro/selenium/pages/category.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import typing

from shopelectro.selenium.elements import CatalogCard
from shopelectro.selenium.pages import Page

from django.urls import reverse
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC

from shopelectro.selenium import elements
from shopelectro.selenium.pages import Page

# @todo #682:120m Implement and reuse shopelectro.selenium.CategoryPage for selenium tests.

Expand All @@ -18,11 +20,37 @@ def __init__(self, driver, slug):
def path(self):
return reverse('category', args=(self.slug,))

def product_cards(self) -> typing.List[CatalogCard]:
raise NotImplementedError
def product_cards(self) -> typing.List[elements.CatalogCard]:
products_count = len(self.driver.find_elements(
By.CLASS_NAME, 'product-card'
))
return [elements.CatalogCard.with_index(self.driver, i) for i in range(products_count)]

def find_card(self, id_: int) -> elements.CatalogCard:
return elements.CatalogCard.with_id(self.driver, id_)

def add_to_cart(self, products: typing.List[CatalogCard] = None):
default = [CatalogCard(self.driver, i) for i in range(1, 7)]
def load_more(self):
old_len = len(self.product_cards())
locator = (By.ID, 'btn-load-products')

if not self.driver.wait.until(EC.presence_of_element_located(
locator
)).is_displayed():
raise elements.Unavailable('load more')

elements.Button(self.driver, locator).click()

self.wait.until_not(
EC.text_to_be_present_in_element(
(By.CLASS_NAME, 'js-products-showed-count'),
str(old_len),
)
)

def add_to_cart(self, products: typing.List[elements.CatalogCard] = None):
default = [elements.CatalogCard.with_index(self.driver, i) for i in range(6)]
products = products or default
for product in products:
product.add_to_cart()

with self.cart().wait_changes():
for product in products:
product.add_to_cart()
10 changes: 2 additions & 8 deletions shopelectro/selenium/pages/product.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,5 @@ def path(self):
return reverse('product', args=(self.vendor_code,))

def add_to_cart(self):
def wait_adding(browser):
# @todo #808:60m Create a context manager for cart-related tests.
# It should wait position changes after completed inner block.
return len(elements.Cart(browser).positions()) > old_count

old_count = len(self.cart().positions())
elements.ProductCard(self.driver).add_to_cart()
self.driver.wait.until(wait_adding)
with self.cart().wait_changes():
elements.ProductCard(self.driver).add_to_cart()
1 change: 1 addition & 0 deletions shopelectro/settings/drone.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
SELENIUM_URL = os.environ.get('SELENIUM_URL', 'http://selenium:4444/wd/hub')
SELENIUM_WAIT_SECONDS = int(os.environ['SELENIUM_WAIT_SECONDS'])
SELENIUM_TIMEOUT_SECONDS = int(os.environ['SELENIUM_TIMEOUT_SECONDS'])
SELENIUM_IMPLICIT_WAIT = int(os.environ['SELENIUM_IMPLICIT_WAIT'])
4 changes: 1 addition & 3 deletions shopelectro/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,7 @@ def setUpClass(cls):
"""Instantiate browser instance."""
super().setUpClass()
cls.browser = SiteDriver(site_url=cls.live_server_url)
# @todo #371:15m Move selenium timeout to env var. stb2
# To be able to change it from drone without touching code.
cls.browser.implicitly_wait(30)
cls.browser.implicitly_wait(settings.SELENIUM_IMPLICIT_WAIT)
cls.browser.set_page_load_timeout(settings.SELENIUM_TIMEOUT_SECONDS)
# Fresh created browser failures on maximizing window.
# This bug is won't fixed by selenium guys https://goo.gl/6Ttguf
Expand Down
39 changes: 34 additions & 5 deletions shopelectro/tests/tests_js_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,8 @@ class YandexEcommerce(Ecommerce):

fixtures = ['dump.json']

# @todo #808:120m Test Yandex ecommerce goals.
# Here are goals left to test:
# - onProductAdd from catalog and order pages
# - onProductRemove from order page
# @todo #820:120m Test Yandex ecommerce add and remove goals from the order page.
# Get rid of code duplications.

def tearDown(self):
# delete the session to clear the cart
Expand Down Expand Up @@ -229,7 +227,38 @@ def test_add_from_product_page(self):
reached_goals = self.get_goals()
self.assertTrue(reached_goals)

reached = self.get_goal(reached_goals, 1)
reached = self.get_goal(reached_goals, 1) # Ignore CPDBear
self.assertIn('add', reached)
self.assertEqual(reached['currencyCode'], 'RUB')

reached_detail = reached['add']
self.assertEqual(
len(reached_detail['products']),
1,
)

self.assertEqual(
reached_detail['products'][0],
{
'id': product.id,
'name': product.name,
'brand': product.get_brand_name(),
'quantity': 1,
'category': product.category.name,
}
)

def test_add_from_category_page(self):
product = Product.objects.first()
page = selenium.CategoryPage(self.browser, product.category.page.slug)
page.load()
card = page.find_card(product.id)
page.add_to_cart([card])

reached_goals = self.get_goals()
self.assertTrue(reached_goals)

reached = self.get_goal(reached_goals)
self.assertIn('add', reached)
self.assertEqual(reached['currencyCode'], 'RUB')

Expand Down
2 changes: 1 addition & 1 deletion shopelectro/tests/tests_selenium_mobile.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def setUpClass(cls):
site_url=cls.live_server_url,
desired_capabilities=chrome_options.to_capabilities(),
)
cls.browser.implicitly_wait(10)
cls.browser.implicitly_wait(settings.SELENIUM_IMPLICIT_WAIT)

@property
def wait(self):
Expand Down

5 comments on commit 3aa1b47

@0pdd
Copy link
Collaborator

@0pdd 0pdd commented on 3aa1b47 Jul 5, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Puzzle 371-6673a075 disappeared from shopelectro/tests/helpers.py, that's why I closed #373. Please, remember that the puzzle was not necessarily removed in this particular commit. Maybe it happened earlier, but we discovered this fact only now.

@0pdd
Copy link
Collaborator

@0pdd 0pdd commented on 3aa1b47 Jul 5, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Puzzle 808-b0743766 disappeared from shopelectro/tests/tests_js_analytics.py, that's why I closed #920. Please, remember that the puzzle was not necessarily removed in this particular commit. Maybe it happened earlier, but we discovered this fact only now.

@0pdd
Copy link
Collaborator

@0pdd 0pdd commented on 3aa1b47 Jul 5, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Puzzle 808-a392ddf6 disappeared from shopelectro/selenium/pages/product.py, that's why I closed #921. Please, remember that the puzzle was not necessarily removed in this particular commit. Maybe it happened earlier, but we discovered this fact only now.

@0pdd
Copy link
Collaborator

@0pdd 0pdd commented on 3aa1b47 Jul 5, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Puzzle 920-cb7dcfc8 discovered in shopelectro/selenium/elements/cart.py and submitted as #938. Please, remember that the puzzle was not necessarily added in this particular commit. Maybe it was added earlier, but we discovered it only now.

@0pdd
Copy link
Collaborator

@0pdd 0pdd commented on 3aa1b47 Jul 5, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Puzzle 820-7b702e9a discovered in shopelectro/tests/tests_js_analytics.py and submitted as #939. Please, remember that the puzzle was not necessarily added in this particular commit. Maybe it was added earlier, but we discovered it only now.

Please sign in to comment.