Add page objects and test framework scaffolding for Selenium-based test automation. Includes initial test suites, element locators, and configuration setup.

This commit is contained in:
Jan 2026-01-04 17:40:48 +01:00
parent 63e1574d2f
commit b473e34809
10 changed files with 661 additions and 0 deletions

101
test/conftest.py Normal file
View file

@ -0,0 +1,101 @@
# conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import WebDriverWait
from pathlib import Path
import openpyxl
@pytest.fixture(scope="session")
def browser_options():
options = Options()
options.add_argument("--start-maximized")
# options.add_argument("--headless") # Optional für CI/CD
return options
@pytest.fixture(scope="function")
def driver(browser_options):
"""Erstellt einen neuen Browser pro Test"""
driver = webdriver.Chrome(options=browser_options)
driver.implicitly_wait(10)
yield driver
driver.quit()
@pytest.fixture(scope="function")
def wait(driver):
"""WebDriverWait Instanz"""
return WebDriverWait(driver, 20)
@pytest.fixture
def base_url():
"""Basis-URL deiner Anwendung"""
return "http://localhost:5173"
def pytest_generate_tests(metafunc):
"""Parametrisiert Tests basierend auf Excel-Dateien im testdata Ordner"""
if "testcase_file" in metafunc.fixturenames:
testdata_dir = Path("testcases")
excel_files = list(testdata_dir.glob("*.xlsx"))
# IDs für bessere Testausgabe
ids = [f.stem for f in excel_files]
metafunc.parametrize("testcase_file", excel_files, ids=ids)
@pytest.fixture
def test_data(testcase_file):
"""Lädt Testdaten aus Excel-Datei"""
wb = openpyxl.load_workbook(testcase_file)
# Eingabedaten aus "in" Sheet
input_sheet = wb["in"]
input_data = {}
groups = {}
for row in input_sheet.iter_rows(values_only=True):
if row[0]: # Feldname in Spalte A
if '_' in row[0]:
[remainder, suffix] = row[0].rsplit('_',1)
if suffix.isdigit():
[prefix, name] = remainder.split('_',1)
if prefix not in groups:
groups[prefix] = {}
if groups[prefix].get(suffix) is None:
groups[prefix][suffix] = {}
groups[prefix][suffix][name] = row[1]
else:
input_data[row[0]] = row[1]
else:
input_data[row[0]] = row[1] # Wert in Spalte B
input_data.update(groups)
# Erwartete Ausgaben aus "out" Sheet
output_sheet = wb["out"]
expected_data = {}
expected_groups = {}
for row in input_sheet.iter_rows(values_only=True):
if row[0]: # Feldname in Spalte A
if '_' in row[0]:
[remainder, suffix] = row[0].rsplit('_',1)
if suffix.isdigit():
[prefix, name] = remainder.split('_',1)
if prefix not in expected_groups:
expected_groups[prefix] = {}
if expected_groups[prefix].get(suffix) is None:
expected_groups[prefix][suffix] = {}
expected_groups[prefix][suffix][name] = row[1]
else:
expected_data[row[0]] = row[1]
else:
expected_data[row[0]] = row[1] # Wert in Spalte B
expected_data.update(expected_groups)
return {
"input": input_data,
"expected": expected_data,
"filename": testcase_file.name
}

46
test/pages/assistant.py Normal file
View file

@ -0,0 +1,46 @@
import time
from selenium.webdriver.common.by import By
from conftest import driver
from pages.base_page import BasePage
class Assistant(BasePage):
"""Page Object für den Assistant"""
# Mapping von Excel-Feldnamen zu Locators
FIELD_MAPPING = {
"PART_NUMBER": (By.CSS_SELECTOR, "textarea[name='partNumbers']"),
"ANALYZE_BUTTON": (By.CSS_SELECTOR, ".part-number-modal-action > .btn--primary"),
"SUPPLIER_NAME": (By.CSS_SELECTOR, ".supplier-headers-searchbar-container .search-input"),
"LOAD_FROM_PREVIOUS": (By.CSS_SELECTOR, ".start-calculation-footer-container .checkbox-item"),
"CREATE_CALCULATION_BUTTON": (By.CSS_SELECTOR, ".start-calculation-footer-container .btn--secondary"),
"DELETE_SUPPLIER_BUTTON": (By.CSS_SELECTOR, ".supplier-headers ~ .item-list .item-list-element .icon-btn-container .icon-btn"),
}
def search_part_numbers(self, data_dict):
"""Füllt das Formular mit Daten aus dem Excel"""
self.fill_input(*self.FIELD_MAPPING["PART_NUMBER"], data_dict["PART_NUMBER"])
self.click_button(*self.FIELD_MAPPING["ANALYZE_BUTTON"])
def delete_preselected_suppliers(self):
while True:
try:
button = self.wait_for_clickable(
*self.FIELD_MAPPING["DELETE_SUPPLIER_BUTTON"],
timeout=1
)
button.click()
time.sleep(0.2)
except:
# Keine Buttons mehr vorhanden
break
def select_supplier(self, data_dict):
self.search_and_select_autosuggest(*self.FIELD_MAPPING["SUPPLIER_NAME"], data_dict["SUPPLIER_NAME"])
def create_calculation(self, data_dict):
self.set_checkbox(*self.FIELD_MAPPING["LOAD_FROM_PREVIOUS"], data_dict["LOAD_FROM_PREVIOUS"])
self.click_button(*self.FIELD_MAPPING["CREATE_CALCULATION_BUTTON"])

147
test/pages/base_page.py Normal file
View file

@ -0,0 +1,147 @@
# pages/base_page.py
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from selenium.common.exceptions import TimeoutException
import time
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class BasePage:
"""Basis-Klasse für alle Page Objects"""
def __init__(self, driver, wait):
self.driver = driver
self.wait = wait
def wait_for_spa_navigation(self, expected_route_part, timeout=2):
"""Wartet bis SPA zur erwarteten Route navigiert hat"""
WebDriverWait(self.driver, timeout).until(
lambda d: expected_route_part in d.current_url
)
# Zusätzlich auf Vue-Rendering warten
time.sleep(0.5)
def wait_for_element(self, by, value, timeout=2):
"""Wartet auf ein Element"""
start_time = time.time()
logger.info(f"Waiting for element: {by}={value}")
result = WebDriverWait(self.driver, timeout).until(
EC.presence_of_element_located((by, value))
)
elapsed = time.time() - start_time
logger.info(f"Found element after {elapsed:.2f}s")
return result
def wait_for_clickable(self, by, value, timeout=2):
"""Wartet bis Element klickbar ist"""
return WebDriverWait(self.driver, timeout).until(
EC.element_to_be_clickable((by, value))
)
def fill_input(self, by, value, text, check_existence=False, timeout=2):
if check_existence:
# Prüfe ob Element existiert
try:
element = WebDriverWait(self.driver, timeout).until(
EC.presence_of_element_located((by, value))
)
logger.info(f"Element exists, filling...")
except TimeoutException:
logger.warning(f"Element does not exist, skipping (check_existence=True)")
return False
else:
# Normaler Modus - erwarte dass Element existiert
element = self.wait_for_element(by, value, timeout)
# Element existiert - jetzt füllen
element.clear()
element.send_keys(text)
logger.info(f"Filled input with: {text}")
return True
def click_button(self, by, value):
"""Klickt einen Button"""
start_time = time.time()
logger.info(f"Clicking button: {by}={value}")
button = self.wait_for_clickable(by, value)
button.click()
elapsed = time.time() - start_time
logger.info(f"Clicked after {elapsed:.2f}s")
def set_checkbox(self, by, value, checked, timeout=2):
label = self.wait_for_clickable(by, value, timeout)
checkbox_input = label.find_element(By.CSS_SELECTOR, "input[type='checkbox']")
is_checked = checkbox_input.is_selected()
if is_checked != checked:
label.click()
time.sleep(0.3)
def select_dropdown_option(self, by, value, option_text, timeout=10):
dropdown_button = self.wait_for_element(by, value, timeout=timeout)
try:
current_value = dropdown_button.find_element(
By.CSS_SELECTOR,
"span.dropdown-trigger-text"
).text
if current_value == option_text:
logger.info(f"Dropdown already has value: {option_text}")
return
except:
pass # Falls kein Text gefunden wurde, öffne das Dropdown
dropdown_button.click()
logger.info("Opened dropdown")
menu = WebDriverWait(self.driver, timeout).until(
EC.visibility_of_element_located((By.CSS_SELECTOR, "ul.dropdown-menu"))
)
logger.info("Dropdown menu visible")
option_xpath = f"//li[contains(@class, 'dropdown-option')][normalize-space(text())='{option_text}']"
option = WebDriverWait(self.driver, timeout).until(
EC.element_to_be_clickable((By.XPATH, option_xpath))
)
logger.info(f"Clicking option: {option_text}")
option.click()
time.sleep(0.2)
def search_and_select_autosuggest(self, by_or_selector, value_or_search_text,
search_text=None,
suggestion_selector=".suggestion-item",
timeout=2):
if search_text is not None:
# Fall: (By.CSS_SELECTOR, ".selector", "search_text")
search_input = self.wait_for_element(by_or_selector, value_or_search_text, timeout)
text_to_search = search_text
else:
# Fall: (".selector", "search_text")
search_input = self.wait_for_element(By.CSS_SELECTOR, by_or_selector, timeout)
text_to_search = value_or_search_text
search_input.clear()
search_input.send_keys(text_to_search)
time.sleep(1)
suggestion = WebDriverWait(self.driver, timeout).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, suggestion_selector))
)
suggestion.click()
time.sleep(0.5)

View file

@ -0,0 +1,157 @@
# pages/calculation_page.py
from selenium.webdriver.common.by import By
from pages.base_page import BasePage
import logging
logger = logging.getLogger(__name__)
class CalculationPage(BasePage):
"""Page Object für die Berechnungsformulare"""
# WICHTIG: Verwende data-v-* Attribute NUR wenn sie WIRKLICH stabil sind
# Besser: Positionsbasierte Selektoren mit aussagekräftigen Parent-Elementen
FIELD_MAPPING = {
# Material-Sektion (erste Box)
"HS_CODE": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][1]//div[contains(@class, 'caption-column')][text()='HS code']"
"/following-sibling::div//input[@class='input-field']"
),
"TARIFF_RATE": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][1]//div[contains(@class, 'caption-column')][contains(text(), 'Tariff rate')]"
"/following-sibling::div//input[@class='input-field']"
),
# Price-Sektion (zweite Box)
"PRICE": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][2]//div[contains(@class, 'caption-column')][text()='MEK_A [EUR]']"
"/following-sibling::div//input[@class='input-field']"
),
"OVERSEA_SHARE": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][2]//div[contains(@class, 'caption-column')][contains(text(), 'Oversea share')]"
"/following-sibling::div//input[@class='input-field']"
),
# Handling Unit-Sektion (dritte Box)
"LENGTH": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU length']"
"/following-sibling::div//input[@class='input-field']"
),
"WIDTH": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU width']"
"/following-sibling::div//input[@class='input-field']"
),
"HEIGHT": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU height']"
"/following-sibling::div//input[@class='input-field']"
),
"WEIGHT": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU weight']"
"/following-sibling::div//input[@class='input-field']"
),
"PIECES_UNIT": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Pieces per HU']"
"/following-sibling::div//input[@class='input-field']"
),
# Dropdowns
"DIMENSION_UNIT": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Dimension unit']"
"/following-sibling::div//button[contains(@class, 'dropdown-trigger')]"
),
"WEIGHT_UNIT": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Weight unit']"
"/following-sibling::div//button[contains(@class, 'dropdown-trigger')]"
),
# Checkboxen
"FBA_FEE": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][2]"
"//label[contains(@class, 'checkbox-item')]"
"[.//span[contains(@class, 'checkbox-label')][normalize-space(text())='']]"
),
"MIXED": (
By.XPATH,
"//label[contains(@class, 'checkbox-item')]"
"[.//span[contains(@class, 'checkbox-label')][text()='Mixable']]"
),
"STACKED": (
By.XPATH,
"//label[contains(@class, 'checkbox-item')]"
"[.//span[contains(@class, 'checkbox-label')][text()='Stackable']]"
),
}
DEST_FIELD_MAPPING = {
"NAME": (By.XPATH, "//input[@placeholder='Add new Destination ...']"),
"QUANTITY": (By.XPATH,
"//div[contains(@class, 'destination-edit-column-caption') and contains(text(), 'Annual quantity')]/following-sibling::div[1]//input[@class='input-field']")
"ROUTING": (By.XPATH,
"//input[@type='radio' and @name='model' and @value='routing']"),
"D2D": (By.XPATH,
"//input[@type='radio' and @name='model' and @value='d2d']"),
"ROUTE": (By.XPATH,
"//div[@class='destination-route-container']//div[contains(@class, 'destination-route-inner-container')][.//span[contains(text(), 'Ireland Su')] and .//span[contains(text(), 'WH ULHA')] and .//span[contains(text(), 'AB')]]")
"HANDLING_TAB": (By.XPATH, "//button[@class='tab-header' and text()='Handling & Repackaging']"),
"CUSTOM_HANDLING": (By.XPATH,
"//div[@class='destination-edit-handling-cost']//label[@class='checkbox-item']/input[@type='checkbox']"),
"REPACKING": (By.XPATH,
"//div[@class='destination-edit-column-caption' and contains(text(), 'Repackaging cost')]/following-sibling::div[@class='destination-edit-column-data'][1]//input[@class='input-field']"),
"HANDLING": (By.XPATH,
"//div[@class='destination-edit-column-caption' and contains(text(), 'Handling cost')]/following-sibling::div[@class='destination-edit-column-data'][1]//input[@class='input-field']"),
"DISPOSAL": (By.XPATH,
"//div[@class='destination-edit-column-caption' and contains(text(), 'Disposal cost')]/following-sibling::div[@class='destination-edit-column-data'][1]//input[@class='input-field']"),
}
# Buttons
CALCULATE_CLOSE_BUTTON = (By.XPATH, "//button[contains(., 'Calculate & close')]")
CLOSE_BUTTON = (By.XPATH, "//button[contains(., 'Close') and not(contains(., 'Calculate'))]")
def fill_form(self, data_dict):
"""Füllt das Formular mit Daten aus dem Excel"""
for field_name, locator in self.FIELD_MAPPING.items():
value = data_dict[field_name]
logger.info(f"Filling field: {field_name} = {value}")
try:
if field_name in ["FBA_FEE", "STACKED", "MIXED"]:
self.set_checkbox(*locator, str(value) == 'True')
elif field_name in ["DIMENSION_UNIT", "WEIGHT_UNIT"]:
self.select_dropdown_option(*locator, str(value))
else:
self.fill_input(*locator, str(value), check_existence=field_name in ["HS_CODE", "TARIFF_RATE"])
except Exception as e:
logger.error(f"Failed to fill field {field_name}: {e}")
self.driver.save_screenshot(f"failed_field_{field_name}.png")
raise Exception(f"Could not fill field '{field_name}': {e}") from e
def add_destination(self, data_dict):
self.search_and_select_autosuggest(*self.DEST_FIELD_MAPPING["NAME"], data_dict["NAME"])
def fill_destination(self, data_dict):
self.wait_for_element(*self.DEST_FIELD_MAPPING["QUANTITY"])
pass
def click_calculate_and_close(self):
"""Klickt auf 'Calculate & close' Button"""
self.click_button(*self.CALCULATE_CLOSE_BUTTON)
def click_close(self):
"""Klickt auf 'Close' Button"""
self.click_button(*self.CLOSE_BUTTON)

53
test/pages/dev_page.py Normal file
View file

@ -0,0 +1,53 @@
# pages/results_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
import time
class DevPage:
MODAL_YES_BUTTON = (By.CSS_SELECTOR, "div.modal-dialog-actions button.btn--primary")
MODAL_NO_BUTTON = (By.CSS_SELECTOR, "div.modal-dialog-actions button.btn--secondary")
def __init__(self, driver, wait):
self.driver = driver
self.wait = wait
def dev_login(self, base_url, user_name="John"):
"""
Simuliert Login über /dev Seite
Args:
base_url: Basis-URL der Anwendung
user_name: Vorname des Users (z.B. "John", "Sarah", "Mike")
"""
# Navigiere zur Dev-Seite
self.driver.get(f"{base_url}/dev")
# Warte bis Tabelle geladen ist
self.wait.until(
EC.presence_of_element_located((By.CSS_SELECTOR, "table.data-table"))
)
# Finde die User-Row anhand des Vornamens
# Die Tabelle hat Spalten: First name | Last name | E-Mail | ...
rows = self.driver.find_elements(By.CSS_SELECTOR, "table.data-table tbody tr.table-row")
for row in rows:
cells = row.find_elements(By.TAG_NAME, "td")
if cells and user_name in cells[0].text: # cells[0] ist "First name"
# Klicke auf die Row
self.wait.until(EC.element_to_be_clickable(row))
row.click()
break
else:
raise Exception(f"User '{user_name}' nicht in der Dev-User-Tabelle gefunden")
# Warte auf Modal und klicke "Yes"
yes_button = self.wait.until(
EC.element_to_be_clickable(self.MODAL_YES_BUTTON)
)
yes_button.click()
# Warte bis Modal geschlossen ist
self.wait.until(
EC.invisibility_of_element_located((By.CSS_SELECTOR, "div.modal-container"))
)

29
test/pages/navigation.py Normal file
View file

@ -0,0 +1,29 @@
# pages/navigation.py
from selenium.webdriver.common.by import By
from pages.assistant import Assistant
from pages.base_page import BasePage
from pages.calculation_page import CalculationPage
from pages.results_page import ResultsPage
class Navigation(BasePage):
"""Handhabt die SPA-Navigation"""
# Locators für Navigationselemente
MENU_BUTTON = (By.CSS_SELECTOR, "button.menu-toggle")
NEW_CALCULATION_LINK = (By.CSS_SELECTOR, "a[href*='/assistant']")
RESULTS_LINK = (By.CSS_SELECTOR, "a[href*='/results']")
def start_calculation(self, base_url):
"""Navigiert zur Berechnungsseite"""
self.driver.get(base_url+"/assistant")
self.wait_for_spa_navigation("/assistant")
return Assistant(self.driver, self.wait)
def navigate_to_results(self):
"""Navigiert zur Ergebnisseite"""
self.click_button(*self.RESULTS_LINK)
self.wait_for_spa_navigation("/results")
return ResultsPage(self.driver, self.wait)

View file

@ -0,0 +1,35 @@
# pages/results_page.py
from selenium.webdriver.common.by import By
from pages.base_page import BasePage
class ResultsPage(BasePage):
"""Page Object für die Ergebnisseite"""
# Mapping von Excel-Feldnamen zu Locators
RESULT_MAPPING = {
"gesamtkosten": (By.ID, "result-total-cost"),
"zollgebuehr": (By.ID, "result-customs-fee"),
"transportkosten": (By.ID, "result-transport-cost"),
"mehrwertsteuer": (By.ID, "result-vat"),
# Weitere Ergebnisfelder...
}
def wait_for_results(self):
"""Wartet bis Ergebnisse geladen sind"""
self.wait_for_element(By.CSS_SELECTOR, ".results-container.loaded")
def read_results(self):
"""Liest alle Ergebniswerte aus"""
self.wait_for_results()
results = {}
for field_name, locator in self.RESULT_MAPPING.items():
try:
element = self.wait_for_element(*locator)
results[field_name] = element.text
except Exception as e:
print(f"Fehler beim Lesen von '{field_name}': {e}")
results[field_name] = None
return results

18
test/pytest.ini Normal file
View file

@ -0,0 +1,18 @@
[pytest]
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)s] %(message)s
log_cli_date_format = %H:%M:%S
testpaths = .
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--tb=short
--capture=no
--html=reports/report.html
--self-contained-html
markers =
smoke: Smoke tests
regression: Regression tests

5
test/requirements.txt Normal file
View file

@ -0,0 +1,5 @@
pytest==9.0.2
selenium==4.39.0
openpyxl==3.1.5
pytest-html==4.1.1
webdriver-manager==4.0.2

70
test/test_calculation.py Normal file
View file

@ -0,0 +1,70 @@
# tests/test_calculation.py
import pytest
from pages.dev_page import DevPage
from pages.navigation import Navigation
from pages.calculation_page import CalculationPage
from pages.results_page import ResultsPage
class TestCalculation:
"""Testklasse für Berechnungslogik"""
def test_calculation_workflow(self, driver, wait, base_url, test_data):
"""
Haupttest: Führt einen kompletten Berechnungsdurchlauf durch
Wird automatisch für jede Excel-Datei parametrisiert
"""
login_page = DevPage(driver, wait)
login_page.dev_login(base_url, user_name="John")
# Setup assistant
nav = Navigation(driver, wait)
assistant = nav.start_calculation(base_url)
assistant.search_part_numbers(test_data["input"])
assistant.delete_preselected_suppliers()
assistant.select_supplier(test_data["input"])
assistant.create_calculation(test_data["input"])
# Fill calculation page
calc_page = CalculationPage(driver, wait)
calc_page.fill_form(test_data["input"])
for destination in test_data["input"]["DESTINATION"].values():
calc_page.add_destination(destination)
calc_page.fill_destination(destination)
# Ergebnisse lesen
results_page = ResultsPage(driver, wait)
actual_results = results_page.read_results()
# Assertions: Vergleich mit erwarteten Werten aus "out" Sheet
expected = test_data["expected"]
for field_name, expected_value in expected.items():
actual_value = actual_results.get(field_name)
# Numerische Werte mit Toleranz vergleichen
if self._is_numeric(expected_value):
expected_num = float(expected_value)
actual_num = float(actual_value.replace("", "").replace(",", ".").strip())
assert pytest.approx(expected_num, rel=0.01) == actual_num, \
f"Feld '{field_name}': Erwartet {expected_num}, erhalten {actual_num}"
else:
# String-Vergleich
assert str(actual_value).strip() == str(expected_value).strip(), \
f"Feld '{field_name}': Erwartet '{expected_value}', erhalten '{actual_value}'"
print(f"✓ Test erfolgreich für {test_data['filename']}")
@staticmethod
def _is_numeric(value):
"""Prüft ob Wert numerisch ist"""
try:
float(str(value).replace(",", "."))
return True
except (ValueError, AttributeError):
return False