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:
parent
63e1574d2f
commit
b473e34809
10 changed files with 661 additions and 0 deletions
101
test/conftest.py
Normal file
101
test/conftest.py
Normal 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
46
test/pages/assistant.py
Normal 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
147
test/pages/base_page.py
Normal 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)
|
||||||
157
test/pages/calculation_page.py
Normal file
157
test/pages/calculation_page.py
Normal 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
53
test/pages/dev_page.py
Normal 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
29
test/pages/navigation.py
Normal 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)
|
||||||
35
test/pages/results_page.py
Normal file
35
test/pages/results_page.py
Normal 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
18
test/pytest.ini
Normal 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
5
test/requirements.txt
Normal 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
70
test/test_calculation.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue