yashaka / selene

User-oriented Web UI browser tests in Python
https://yashaka.github.io/selene/
MIT License
695 stars 151 forks source link

consider pydantic style of PageObjects | opinionated POM implementation in Selene #505

Open yashaka opened 9 months ago

yashaka commented 9 months ago

Example:

class ReactContinuousSlider(selene.PageModel):
    config = selene.PageModel.Config(url='https://mui.com/material-ui/react-slider/#ContinuousSlider')
    container = selene.PageModel.Element('#ContinuousSlider+*')
    thumb = container.element('.MuiSlider-thumb')
    thumb_input = thumb.element('input')
    volume_up = container.element('[data-testid=VolumeUpIcon]')
    volume_down = container.element('[data-testid=VolumeDownIcon]')
    rail = container.element('.MuiSlider-rail')

reactSlider = ReactContinuousSlider(browser).open()

reactSlider.thumb.perform(command.drag_and_drop_to(reactSlider.volume_up))
reactSlider.thumb_input.should(have.value('100'))

reactSlider.thumb.perform(command.drag_and_drop_to(reactSlider.volume_down))
reactSlider.thumb_input.should(have.value('0'))

reactSlider.thumb.perform(command.drag_and_drop_to(reactSlider.rail))
reactSlider.thumb_input.should(have.value('50'))

as a shortcut to

class ReactContinuousSlider:
    def __init__(self, browser: selene.Browser | None):
        self.browser = browser if browser else selene.browser
        self.container = self.browser.element('#ContinuousSlider+*')
        self.thumb = self.container.element('.MuiSlider-thumb')
        self.thumb_input = self.thumb.element('input')
        self.volume_up = self.container.element('[data-testid=VolumeUpIcon]')
        self.volume_down = self.container.element('[data-testid=VolumeDownIcon]')
        self.rail = self.container.element('.MuiSlider-rail')

    def open(self):
        self.browser.open('https://mui.com/material-ui/react-slider/#ContinuousSlider')
        return self

reactSlider = ReactContinuousSlider(browser).open()

reactSlider.thumb.perform(command.drag_and_drop_to(reactSlider.volume_up))
reactSlider.thumb_input.should(have.value('100'))

reactSlider.thumb.perform(command.drag_and_drop_to(reactSlider.volume_down))
reactSlider.thumb_input.should(have.value('0'))

reactSlider.thumb.perform(command.drag_and_drop_to(reactSlider.rail))
reactSlider.thumb_input.should(have.value('50'))

consider also simplifying container = selene.PageModel.Element('#ContinuousSlider+*') to container = selene.Element('#ContinuousSlider+*')

P.S. related to #439

aleksandr-kotlyar commented 9 months ago

Pay attention to difference between init and class attributes https://stackoverflow.com/questions/46720838/python-init-vs-class-attributes As far as I understand it: If you will need by somehow more than one instance of class, then it will rewrite all attributes of the first instance.

yashaka commented 9 months ago

Pay attention to difference between init and class attributes https://stackoverflow.com/questions/46720838/python-init-vs-class-attributes As far as I understand it: If you will need by somehow more than one instance of class, then it will rewrite all attributes of the first instance.

It will not, because they are not class attributes. In both python dataclasses and pydantic-based classes – the attributes that you define on the class level – are not class attributes – they are instance attributes. This is the main idea of such type of DSL.

yashaka commented 3 months ago

Hm, seems like we even not need full pydantic style of complete class kitchen DSL-based implementation. With simple python descriptors we already can achieve the following analog of the previous code example:

class ReactContinuousSlider:
    thumb = Element('.MuiSlider-thumb')
    thumb_input = thumb.element('input')
    volume_up = Element('[data-testid=VolumeUpIcon]')
    volume_down = Element('[data-testid=VolumeDownIcon]')
    rail = Element('.MuiSlider-rail')

    def __init__(self, element: Element | None = None):
        self.context = element or browser.element('#ContinuousSlider+*')

Where each Element object passed in context of "descriptor" will check fo existance of context self attribute, and if yes - use it as a root instead of a browser, otherwise – user selene shared browser.

yashaka commented 3 months ago

While self.open can be still implemented explicitely ;) No need to build a complete POM DSL around it...

yashaka commented 3 months ago

First experiments results:)

image

Looks promising :)

yashaka commented 3 months ago

Hm, looks as good enough for POC...

image
import pytest

from selene import browser, have, be, command, query
from selene.support._pom import element, all_

class DataGridMIT:
    grid = element('[role=grid]')

    header = grid.element('.MuiDataGrid-columnHeaders')
    toggle_all_checkbox = header.element('.PrivateSwitchBase-input')

    column_headers = grid.all('[role=columnheader]')

    footer = Element('.MuiDataGrid-footerContainer')
    selected_rows_count = footer.element('.MuiDataGrid-selectedRowCount')
    pagination = footer.element('.MuiTablePagination-root')
    pagination_rows_displayed = pagination.element('.MuiTablePagination-displayedRows')
    page_to_right = pagination.element('[data-testid=KeyboardArrowRightIcon]')
    page_to_left = pagination.element('[data-testid=KeyboardArrowLeftIcon]')

    content = grid.element('[role=rowgroup]')
    rows = content.all('[role=row]')
    _cells_selector = '[role=gridcell]'
    cells = content.all(_cells_selector)
    editing_cell_input = content.element('.MuiDataGrid-cell--editing').element('input')

    def __init__(self, context):
        self.context = context

    def cells_of_row(self, number, /):
        return self.rows[number - 1].all(self._cells_selector)

    def cell(self, *, row, column_data_field=None, column=None):
        if column:
            column_data_field = self.column_headers.element_by(
                have.exact_text(column)
            ).get(query.attribute('data-field'))

        return self.cells_of_row(row).element_by(
            have.attribute('data-field').value(column_data_field)
        )

    def set_cell(self, *, row, column_data_field=None, column=None, to_text):
        self.cell(
            row=row, column_data_field=column_data_field, column=column
        ).double_click()
        self.editing_cell_input.perform(command.select_all).type(to_text).press_enter()

@pytest.mark.parametrize(
    'characters',
    [
        DataGridMIT(
            browser.with_(timeout=2.0).element('#DataGridDemo+* .MuiDataGrid-root')
        ),
    ],
)
def test_material_ui__react_x_data_grid_mit(characters):
    browser.driver.refresh()

    # WHEN
    browser.open('https://mui.com/x/react-data-grid/#DataGridDemo')

    # THEN
    # - check headers
    characters.column_headers.should(have.size(6))
    characters.column_headers.should(
        have._exact_texts_like(
            {...}, 'ID', 'First name', 'Last name', 'Age', 'Full name'
        )
    )

    # - pagination works
    characters.pagination_rows_displayed.should(have.exact_text('1–5 of 9'))
    characters.page_to_right.click()
    characters.pagination_rows_displayed.should(have.exact_text('6–9 of 9'))
    characters.page_to_left.click()
    characters.pagination_rows_displayed.should(have.exact_text('1–5 of 9'))

    # - toggle all works to select all rows
    characters.selected_rows_count.should(be.not_.visible)
    characters.toggle_all_checkbox.should(be.not_.checked)
    characters.toggle_all_checkbox.click()
    characters.toggle_all_checkbox.should(be.checked)
    characters.selected_rows_count.should(have.exact_text('9 rows selected'))
    characters.toggle_all_checkbox.click()
    characters.toggle_all_checkbox.should(be.not_.checked)
    characters.selected_rows_count.should(be.not_.visible)

    # - check rows
    characters.rows.should(have.size(5))
    characters.cells_of_row(1).should(
        have._exact_texts_like({...}, {...}, 'Jon', 'Snow', '14', 'Jon Snow')
    )

    # - sorting works
    # TODO: implement

    # - filtering works
    # TODO: implement

    # - hiding works
    # TODO: implement

    # - a cell can be edited
    characters.set_cell(row=1, column_data_field='firstName', to_text='John')
    characters.cells_of_row(1).should(
        have._exact_texts_like({...}, {...}, 'John', 'Snow', '14', 'John Snow')
    )
    characters.set_cell(row=1, column='First name', to_text='Jon')
    characters.cells_of_row(1).should(
        have._exact_texts_like({...}, {...}, 'Jon', 'Snow', '14', 'Jon Snow')
    )