/selene-fork

Fork of User-oriented Web UI browser tests in Python

Primary LanguagePythonMIT LicenseMIT

Selene - User-oriented Web UI browser tests in Python (Selenide port)

Pre-release Version tests codecov Free MIT License

Downloads Project Template Code style: black

Join telegram chat https://t.me/selene_py Присоединяйся к чату https://t.me/selene_py_ru

Sign up for a course https://autotest.how/sdet-start Запишись на курс https://autotest.how/sdet-start-ru Реєструйся на курс https://autotest.how/sdet-start-uk

Main features:

  • User-oriented API for Selenium Webdriver (code like speak common English)
  • Ajax support (Smart implicit waiting and retry mechanism)
  • PageObjects support (all elements are lazy-evaluated objects)
  • Automatic driver management (no need to install and setup driver for quick local execution)

Selene was inspired by Selenide from Java world.

Tests with Selene can be built either in a simple straightforward "selenide" style or with PageObjects composed from Widgets i.e. reusable element components.

Versions

  • Latest recommended version to use is 2.0.0b17+
    • it's a completely new version of selene, with improved API and speed
    • supports Python 3.7+
    • bundled with Selenium 4.1+
    • it's incompatible with 1.x
    • current master branch is pointed to 2.x
    • yet in alpha/beta stage, refining API, improving "migratability" and testing
    • most active Selene users already upgraded to 2.0 alpha/beta and have been using it in production during last 2 years
    • the only risk is API changes, some commands are in progress of deprecation and renaming
  • Latest version marked as stable is: 1.0.2
    • its sources and corresponding README version can be found at 1.x branch.
    • supports python 2.7, 3.5, 3.6, 3.7

THIS README DESCRIBES THE USAGE OF THE PRE-RELEASE version of Selene. For older docs look at 1.x branch.

Migration guide

From 1.0.2 to 2.0.0b<LATEST>:

  • upgrade to Python 3.7+
  • update selene to 2.0.0b<LATEST>
    • find&replace the collection.first() method from .first() to .first
    • ensure all conditions like text('foo') are used via be.* or have.* syntax
      • example:
        • find&replace all
          • (text('foo')) to (have.text('foo'))
          • (visible) to (be.visible)
        • smarter find&replace (with some manual refactoring)
          • .should(x, timeout=y) to .with_(timeout=y).should(x)
          • .should_not(be.*) to .should(be.not_.*) or .should(be.*.not_)
          • .should_not(have.*) to .should(have.no.*) or .should(have.*.not_)
          • .should_each(condition) to .should(condition.each)
        • and add corresponding imports: from selene import be, have
    • fix another broken imports if available
    • run tests, read deprecation warnings, and refactor to new style recommended in warning messages

Prerequisites

Python 3.7+

Given pyenv installed, installing needed version of Python is pretty simple:

$ pyenv install 3.7.3
$ pyenv global 3.7.3
$ python -V
Python 3.7.3

Installation

via poetry + pyenv (recommended)

GIVEN poetry and pyenv installed ...

AND

poetry new my-tests-with-selene
cd my-tests-with-selene
pyenv local 3.7.3

WHEN latest pre-release recommended version:

poetry add selene --allow-prereleases

WHEN latest stable version:

poetry add selene

THEN

poetry install

via pip

Latest recommended pre-release alpha version:

pip install selene --pre

Latest stable version:

pip install selene

from sources

GIVEN webdriver and webdriver_manager are already installed

THEN

git clone https://github.com/yashaka/selene.git
python setup.py install

or using pip:

pip install git+https://github.com/yashaka/selene.git

Usage

Quick Start

Simply...

from selene import browser, by, be, have

browser.open('https://google.com/ncr')
browser.element(by.name('q')).should(be.blank)\
    .type('selenium').press_enter()
browser.all('#rso>div').should(have.size_greater_than(5))\
    .first.should(have.text('Selenium automates browsers'))

OR with custom setup

from selene import browser, by, be, have

browser.config.driver_name = 'firefox'
browser.config.base_url = 'https://google.com'
browser.config.timeout = 2
# browser.config.* = ...

browser.open('/ncr')
browser.element(by.name('q')).should(be.blank) \
    .type('selenium').press_enter()
browser.all('#rso>div').should(have.size_greater_than(5)) \
    .first.should(have.text('Selenium automates browsers'))

OR more Selenide from java style:

from selene import browser, by, be, have
from selene.support.shared import config
from selene.support.shared.jquery_style import s, ss


config.browser_name = 'firefox'
config.base_url = 'https://google.com'
config.timeout = 2
# config.* = ...

browser.open('/ncr')
s(by.name('q')).should(be.blank) \
    .type('selenium').press_enter()
ss('#rso>div').should(have.size_greater_than(5)) \
    .first.should(have.text('Selenium automates browsers'))

Core Api

# Given:
from selenium.webdriver import Chrome

# AND chromedriver executable available in $PATH

# WHEN:
from selene import Browser, Config

browser = Browser(
    Config(
        driver=Chrome(),
        base_url='https://google.com',
        timeout=2,
    )
)

# AND:
browser.open('/ncr')

# AND:
# browser.element('//*[@name="q"]')).type('selenium').press_enter()
# OR...
# browser.element('[name=q]')).type('selenium').press_enter()
# OR...
from selene import by
# browser.element(by.name('q')).type('selenium').press_enter()
# OR...for total readability
query = browser.element(by.name('q'))
# actual search doesn't start on calling browser.element above, 
# i.e. the element is "lazy"... or in other words it serves as locator         
# Below, on calling actual first action, 
#     ⬇ the actual webelement is located first time
query.type('selenium').press_enter()       
#                      ⬆
#                  and here it's located again, i.e. the element is "dynamic"

# AND in case we need to filter collection of items 
#     by some condition like visibility:

from selene import be
results = browser.all('#rso>div').by(be.visible)

# THEN we can assert some condition:
from selene import have
# results.should(have.size_greater_than(5))
# results.first.should(have.text('Selenium automates browsers'))
# OR...
results.should(have.size_greater_than(5))\
    .first.should(have.text('Selenium automates browsers'))

# FINALLY the browser can be quit:
browser.quit()
# but it's not mandatory, because by default Selenes kills all drivers on exit
# that can be disabled by:
browser.config.hold_driver_at_exit = True

Automatic Driver and Browser Management

Instead of:

from selenium.webdriver import Chrome
from selene import Browser, Config

browser = Browser(
    Config(
        driver=Chrome(),
        base_url='https://google.com',
        timeout=2
    )
)

browser.open('/ncr')

You can simply use the browser instance predefined for you in selene module:

from selene import browser

browser.config.base_url = 'https://google.com'
browser.config.timeout = 2

browser.open('/ncr')

So you don't need to create you driver instance manually. It will be created for you automatically.

Yet, if you need some special case, like working with remote driver, etc., you can still use shared browser object with additional configuration:

from selenium import webdriver
from selene import browser

options = webdriver.ChromeOptions()
options.add_argument('--headless')
options.add_argument('--no-sandbox')
options.add_argument('--disable-gpu')
options.add_argument('--disable-notifications')
options.add_argument('--disable-extensions')
options.add_argument('--disable-infobars')
options.add_argument('--enable-automation')
options.add_argument('--disable-dev-shm-usage')
options.add_argument('--disable-setuid-sandbox')
browser.config.driver_options = options
browser.config.driver_remote_url = 'http://localhost:4444/wd/hub', 
browser.config.base_url = 'https://google.com'
browser.config.timeout = 2

browser.open('/ncr')
...

But if you like to create the driver on your own, you can do it too:

from selenium import webdriver
from selene import browser

options = webdriver.ChromeOptions()
options.add_argument('--headless')
# ... other arguments
browser.config.driver = webdriver.Remote(
  'http://localhost:4444/wd/hub', 
  options=options
)
# Once you start to build and set the driver on your own,
# probably you are going to fully manage it life cycle,
# thus, consider disabling the automatic driver reset on browser.open
# if driver was crashed or quit:
browser.config._reset_not_alive_driver_on_get_url = False
# And consider disabling the automatic driver quit on exit:
browser.config.hold_driver_at_exit = True
# Other common options will still be useful:
browser.config.base_url = 'https://google.com'
browser.config.timeout = 2

browser.open('/ncr')
...

# Finally, you can quit the driver manually:
browser.quit()

Advanced API

Sometimes you might need some extra things to reach your specific goals... Here go examples of Selene's command, query, custom conditions, .matching(condition) and .wait_until(condition)...

from selene import browser, have

...

###################################################
# Maybe you need some advanced actions on elements,
# e.g. for workaround something through js:

from selene import command

browser.element('#not-in-view').perform(command.js.scroll_into_view)

...

###################################################
# Probably you think that will need something like:

from selene import query

...

def my_int_from(text):
    return int(text.split(' ')[0])

product_text = browser.element('#price-label').get(query.text)
# ... to assert something not standard:
price = my_int_from(product_text)
assert price > 100

# But such version is very unstable in dynamic web world...
# Usually it's...
# either better to implement your custom condition:

from selene.core.condition import Condition
from selene.core.conditions import ElementCondition
from selene.core.entity import Element


def have_in_text_the_int_number_more_than(number) -> Condition[Element]:
    def fn(element: Element) -> None:
        text = element.get(query.text)
        parsed_number = my_int_from(text)
        if not parsed_number > number:
            raise AssertionError(
                f'actual text was: {text}'
                f'with parsed int number: {parsed_number}'
            )
    return ElementCondition(
        f'has in text the int number more than: {number}', 
        fn
    )


browser.element('#price-label').should(
    have_in_text_the_int_number_more_than(100)
)
'''
# You even can create your own project_package/selene_extensions/have.py
# with the following content:

from selene.support.conditioins.have import *

def int_number_more_than(number) -> Condition[Element]:
    def fn(element: Element) -> None:
        text = element.get(query.text)
        parsed_number = my_int_from(text)
        if not parsed_number > number:
            raise AssertionError(
                f'actual text was: {text}'
                f'with parsed int number: {parsed_number}'
            )
    return ElementCondition(
        f'has in text the int number more than: {number}', 
        fn
    )
    
# And then in your test:

from project_package.selene_extensions import have

browser.element('#price-label').should(have.text('Price: ') \
    .should(have.int_number_more_than(100))

# i.e. using it same style as in selene,
# with also access to all original selene conditions
'''

# Such condition-based alternative to the original `assert price > 100` is less fragile,
# because Python's `assert` does not have "implicit waiting",
# while Selene's `should` command does have ;)

# Furthermore, the good test is when you totally control your test data, 
# and the code like below:

product = browser.element('#to-remember-for-future')

product_text_before = product.get(query.text)
price_before = my_int_from(product_text_before)

... # some test steps

product_text_after = product.get(query.text)
price_after = my_int_from(product_text_after)

assert price_after > price_before

# – normally, should be refactored to something like:

product = browser.element('#to-remember-for-future')

product.should(have.text('100$'))

... # some test steps

product.should(have.text('125$'))


###############################################
# You might also think you need something like:

from selene import query

if browser.element('#i-might-say-yes-or-no').get(query.text) == 'yes':
    ...  # do something...

# Or:

from selene import query

if browser.all('.option').get(query.size) >= 2:
    ...  # do something...

# – maybe, one day, you really find a use case:)

# But for above cases, probably easier would be:

if browser.element('#i-might-say-yes-or-no').wait_until(have.text('yes')):
    ...  # do something

...

if browser.all('.i-will-appear').wait_until(have.size_greater_than_or_equal(2)):
    ...  # do something

# Or, by using non-waiting versions, if "you are in a rush:)":

if browser.element('#i-might-say-yes-or-no').matching(have.text('yes')):
    ...  # do something

...

if browser.all('.i-will-appear').matching(have.size_greater_than_or_equal(2)):
    ... # do something

Tutorials

TBD

More Examples

TBD

Contribution

see CONTRIBUTING.md

Release Workflow

see Release workflow

Changelog

see CHANGELOG.md