Iterable Structures¶
Sometimes you’ll have some sort of structure that either doesn’t have universally consistent series of data, or it just has an immense number of items. For example, a table of data with many rows.
Just building out all the components for each possible item in the series would be either extremely difficult (if not impossible), or just plain tedious. Luckily, there’s a better way.
First, let’s say we have a table of cars, where each row is an individual car, and their make, model, year, and color are provided, each in their own column. In this table, you can select each row, and delete them, thus deleting the record of that car. There’s also a form just before this table, through which, new cars can be added to the table. Let’s also say that every time the page is loaded, the table is empty, so if we want to have any cars listed, we have to add them ourselves.
The HTML
Here’s some example HTML to represent this (let’s also assume there’s magic JavaScript that will just make this work flawlessly):
<form id="add-car-form" onsubmit="addCar()">
<select name="make" onchange="updateModels()" required>
<option value="" selected disabled hidden>--Choose a make--</option>
<option value="chevrolet">Chevrolet</option>
<option value="toyota">Toyota</option>
<option value="ford">Ford</option>
</select>
<select name="model" required disabled>
<option value="" selected disabled hidden>--Choose a model--</option>
</select>
<input name="year" required placeholder="year">
<select name="color" required>
<option value="" selected disabled hidden>--Choose a color--</option>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
</select>
<button id="add-button" type="submit">Add car</button>
</form>
<button id="delete-button" onclick="deleteSelectedCars()">Delete</button>
<table class="carTable">
<thead>
<tr>
<th></th>
<th>Make</th>
<th>Model</th>
<th>Year</th>
<th>Color</th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="checkbox" name="car" value="1"></td>
<td>Chevrolet</td>
<td>Malibu</td>
<td>1997</td>
<td>Red</td>
</tr>
<tr>
<td><input type="checkbox" name="car" value="2"></td>
<td>Toyota</td>
<td>Corola</td>
<td>2014</td>
<td>Green</td>
</tr>
</tbody>
</table>
The Tests
Now that we have the table’s HTML, we can get started on the code. To figure what our bottom-level code should be, let’s look at how we want the tests to look. Here’s one example of how these could look:
@pytest.fixture(scope="class", autouse=True)
def page(driver, url):
driver.get(url)
return CarTablePage(driver)
@pytest.fixture(scope="class")
def car():
return Car(CarMake.CHEVROLET, ChevroletModel.IMPALA, 1995, Color.RED)
class TestTableIsEmptyOnLoad:
def test_table_has_no_entries(self, page):
assert len(page.cars) == 0
class TestCarIsAdded:
@pytest.fixture(scope="class", autouse=True)
def add_car(self, car, page):
page.add_car(car)
def test_car_is_in_table(self, page, car):
assert car in page.cars
class TestCarIsRemoved:
@pytest.fixture(scope="class", autouse=True)
def add_car(self, car, page):
page.add_car(car)
@pytest.fixture(scope="class", autouse=True)
def remove_car(self, car, page, add_car):
page.remove_car(car)
def test_car_is_not_in_table(self, page, car):
assert car not in page.cars
That looks pretty straightforward and easy to read, but the question is how to achieve that. To find out, let’s keep working backwards.
The Page
We can start by looking at the page class itself. This serves as the top-level abstraction for how we interact with the page in terms of behavior, so it’s important that it provides us with a readable and useable API. It should have methods that fully describe what were doing, so anyone reading it can follow along:
class CarTablePage(Page):
add_car_form = AddCarForm()
car_table = CarTable()
def add_car(self, car: Car):
self.add_car_form.add_car(car)
def remove_car(self, car: Car):
self.car_table.remove_car(car)
@property
def cars(self) -> List[Car]:
return self.car_table.cars
The Add Form
The code is starting to take shape, and this is pretty self-explainatory, so let’s dig deeper, and look into how the cars get added:
class SelectComponent(PC):
@property
def _el(self) -> Select:
el = self._reference_node.find_element(*self._locator)
return Select(el)
def __set__(self, instance, value: Any):
self.driver = instance.driver
self._parent = instance
self._select(value)
class MakeSelect(SelectComponent):
_locator = (By.CSS_SELECTOR, "[name=make]")
def _select(self, value: CarMake):
self.select_by_value(value)
class ModelSelect(SelectComponent):
_locator = (By.CSS_SELECTOR, "[name=model]")
def _select(self, value: CarModel):
self.select_by_value(value)
class YearInput(PC):
_locator = (By.CSS_SELECTOR, "[name=year]")
class ColorSelect(SelectComponent):
_locator = (By.CSS_SELECTOR, "[name=color]")
def _select(self, value: Color):
self.select_by_value(value)
class AddCarButton(PC):
_locator = (By.CSS_SELECTOR, "#add-button")
def count_greater_than(
component: PC,
count: int,
**kwargs: dict
) -> Callable[[RemoteWebDriver], bool]:
"""Given a number, checks that the car message count in the list is greater."""
def callable(driver: RemoteWebDriver) -> bool:
return len(component._parent.cars) > count
return callable
class AddCarForm(PC):
_locator = (By.CSS_SELECTOR, "#add-car-form")
make = MakeSelect()
model = ModelSelect()
year = YearInput()
color = ColorInput()
add_car_button = AddCarButton()
_expected_conditions = {
"count_greater_than": count_greater_than,
}
def add_car(self, car: Car):
current_car_count = len(self._parent.cars)
self.make = car.make
self.model = car.model
self.year = car.year
self.color = car.color
self.add_car_button.click()
self.wait_until("count_greater_than", count=current_car_count)
Now it’s starting to get a bit more complicated, as it combines multiple advanced concepts. It first uses a generic component that overrides the normal __set__ and _el logic so that Select can be used while the API it provides remains consistent with other form control elements. Further down, it uses a custom wait function that has it rely on its parent (in this case, the page itself) to see if the number of shown cars has changed.
This last step where it waits for the change in car count is essential so that anything leveraging that add_car method doesn’t have to worry about any race conditions created by JavaScript that hasn’t had a chance to run (i.e. a change was made to the DOM, so the thing changing it should wait for the DOM change to complete before moving on).
The Table
This is only one half of the page, though, so let’s look at the other half and see what’s going on inside the table component itself:
class RowCheckbox(PC):
_find_from_parent = True
_locator = (By.CSS_SELECTOR, "td:nth-of-type(1) input")
class RowMake(PC):
_find_from_parent = True
_locator = (By.CSS_SELECTOR, "td:nth-of-type(2)")
class RowModel(PC):
_find_from_parent = True
_locator = (By.CSS_SELECTOR, "td:nth-of-type(3)")
class RowYear(PC):
_find_from_parent = True
_locator = (By.CSS_SELECTOR, "td:nth-of-type(4)")
class RowColor(PC):
_find_from_parent = True
_locator = (By.CSS_SELECTOR, "td:nth-of-type(5)")
class CarItem(PC):
_index = None
_find_from_parent = True
__locator = "tbody tr:nth-of-type({index})"
checkbox = RowCheckbox()
_make = RowMake()
_model = RowModel()
_year = RowYear()
_color = RowColor()
@property
def _locator(self) -> tuple:
return (By.CSS_SELECTOR, self.__locator.format(index=self._index + 1))
def __init__(self, index: int, parent: PC):
self._index = index
self._parent = parent
self.driver = self._parent.driver
@property
def id(self) -> int:
return int(self.checkbox.get_attribute("value"))
@property
def make(self) -> CarMake:
return CarMake[self._make.text.lower()]
@property
def model(self) -> CarModel:
return CarModel[self._model.text.lower()]
@property
def year(self) -> int:
return int(self._year.text)
@property
def color(self) -> Color:
return Color[self._color.text.lower()]
class DeleteButton(PC):
_locator = (By.CSS_SELECTOR, "#delete-button")
class CarTable(PC):
_locator = (By.CSS_SELECTOR, ".carTable")
_item_locator = (By.CSS_SELECTOR, "tbody tr")
delete_button = DeleteButton()
@property
def car_count(self) -> int:
return len(self.find_elements(*self._item_locator))
@property
def car_items(self) -> List[CarItem]:
return list(CarItem(i, self) for i in range(self.car_count))
@property
def cars(self) -> List[Car]:
cars = []
for car in self.car_items:
cars.append(Car(car.make, car.model, car.year, car.color, car.id))
return list(CarItem(i, self) for i in range(self.car_count))
def remove_car(self, car: Car):
self.car_items[self.car_items.index(car)].checkbox.click()
self.delete_button.click()
This does a small trick where instances of CarItem are given a reference to the driver, their parent table component, and an index for the row they represent. They aren’t hooked up like a normal descriptor-based component, but they don’t need to be as the driver and parent reference was passed down explicitely. All the parent table component needs to do is figure out how many items (i.e. rows) it has, and create that many instances of CarItem, giving each one the appropriate index (i.e. 0 to n), a reference to itself, and the driver. That’s all the information each instance needs to still function properly (this is also why the _locator is a property).
Down at the bottom, there’s also car_items and cars, each one providing something similar, but very different. Having car_items on its own gives us an easy means to access those components in the DOM, and giving them their own properties that have meaningful values allows us to get fancier.
The Car
With that in mind, let’s take a look at the final chunk of code, and see the custom data types and enumerators that make this whole operation tick:
class CarMake(Enum):
@property
def description(self):
return self.value.title()
CHEVROLET = "chevrolet"
TOYOTA = "toyota"
FORD = "ford"
class CarModel(Enum):
@property
def description(self):
return self.value.title()
class ChevroletModel(CarModel):
MALIBU = "malibu"
IMPALA = "impala"
class ToyotaModel(CarModel):
COROLA = "corola"
PRIUS = "prius"
class FordModel(CarModel):
FIESTA = "fiesta"
FOCUS = "focus"
class Color(Enum):
RED = "red"
GREEN = "green"
BLUE = "blue"
class Car:
def __init__(
self,
make: CarMake,
model: CarModel,
year: int,
color: Color,
id: int = None,
):
self._id = id
self.make = make
self.model = model
self.year = year
self.color = color
def __eq__(self, other):
if all(self._id is not None, other._id is not None):
return self._id == other._id
return all(
self.make == other.make,
self.model == other.model,
self.year == other.year,
self.color == other.color,
)
This lets us consider each car’s data independently of any implementation that uses this data by giving those implementations a means to store and work with the data in a common shape. We no longer have to worry about how a specific method will be expecting the information for a given car, how a method might return such information, or how to compare one car to another, because it’s all handled through this class, and the supporting classes are enumerators to help streamline the development process. They act as a common language for every piece to talk to the others with, and allow us to write such simple tests as the ones above.
The __eq__ in particular helps with several aspects of this example. It allows the comparisons with the instances of CarItem, which in turns allows for things like self.car_items.index(car), because Python leverages __eq__ for a lot of common operations.