From 7e080959ac7126c73e0ece2cef869a047fdd9e49 Mon Sep 17 00:00:00 2001 From: Yasir <95471323+yasirsavanur@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:54:15 +0100 Subject: [PATCH] feat: rebuild the automation framework --- .github/workflows/quality-gates.yml | 74 ++++ .gitignore | 9 + LICENSE | 21 + README.md | 179 +++++--- base/__init__.py | 0 base/basepage.py | 50 --- base/selenium_driver.py | 405 ------------------ base/webdriverfactory.py | 52 --- demo_app/app.js | 97 +++++ demo_app/cart.html | 18 + demo_app/checkout.html | 26 ++ demo_app/index.html | 45 ++ demo_app/inventory.html | 39 ++ demo_app/products.json | 5 + demo_app/styles.css | 270 ++++++++++++ locators/locators.json | 38 -- navigation/__init__.py | 0 navigation/homeNavigation.py | 16 - pages/__init__.py | 0 pages/loggedin_page.py | 20 - pages/login_page.py | 24 -- pyproject.toml | 53 +++ requirements.txt | 9 - src/automation_framework/__init__.py | 5 + src/automation_framework/api_client.py | 35 ++ src/automation_framework/config.py | 42 ++ src/automation_framework/data.py | 30 ++ src/automation_framework/driver_factory.py | 48 +++ src/automation_framework/pages/__init__.py | 8 + src/automation_framework/pages/base_page.py | 41 ++ src/automation_framework/pages/cart_page.py | 21 + .../pages/checkout_page.py | 32 ++ .../pages/inventory_page.py | 44 ++ src/automation_framework/pages/login_page.py | 27 ++ test_data/__init__.py | 0 test_data/testData.py | 8 - test_data/test_data.json | 6 - testrail.cfg | 7 - tests/__init__.py | 1 + tests/api/test_catalog_api.py | 28 ++ tests/conftest.py | 87 +++- tests/data/users.json | 20 + tests/support/__init__.py | 1 + tests/support/demo_server.py | 65 +++ tests/test_login.py | 36 -- tests/test_suite_demo.py | 12 - tests/ui/test_checkout.py | 44 ++ tests/ui/test_inventory.py | 30 ++ tests/ui/test_login.py | 29 ++ tests/unit/test_config.py | 52 +++ tests/unit/test_data.py | 34 ++ tests/unit/test_driver_factory.py | 73 ++++ utilities/__init__.py | 0 utilities/custom_logger.py | 22 - utilities/locators_read.py | 10 - utilities/read_excel.py | 13 - utilities/read_json.py | 8 - utilities/teststatus.py | 58 --- utilities/util.py | 117 ----- 59 files changed, 1548 insertions(+), 996 deletions(-) create mode 100644 .github/workflows/quality-gates.yml create mode 100644 .gitignore create mode 100644 LICENSE delete mode 100755 base/__init__.py delete mode 100755 base/basepage.py delete mode 100755 base/selenium_driver.py delete mode 100755 base/webdriverfactory.py create mode 100644 demo_app/app.js create mode 100644 demo_app/cart.html create mode 100644 demo_app/checkout.html create mode 100644 demo_app/index.html create mode 100644 demo_app/inventory.html create mode 100644 demo_app/products.json create mode 100644 demo_app/styles.css delete mode 100755 locators/locators.json delete mode 100644 navigation/__init__.py delete mode 100644 navigation/homeNavigation.py delete mode 100755 pages/__init__.py delete mode 100644 pages/loggedin_page.py delete mode 100644 pages/login_page.py create mode 100644 pyproject.toml delete mode 100644 requirements.txt create mode 100644 src/automation_framework/__init__.py create mode 100644 src/automation_framework/api_client.py create mode 100644 src/automation_framework/config.py create mode 100644 src/automation_framework/data.py create mode 100644 src/automation_framework/driver_factory.py create mode 100644 src/automation_framework/pages/__init__.py create mode 100644 src/automation_framework/pages/base_page.py create mode 100644 src/automation_framework/pages/cart_page.py create mode 100644 src/automation_framework/pages/checkout_page.py create mode 100644 src/automation_framework/pages/inventory_page.py create mode 100644 src/automation_framework/pages/login_page.py delete mode 100755 test_data/__init__.py delete mode 100644 test_data/testData.py delete mode 100644 test_data/test_data.json delete mode 100644 testrail.cfg mode change 100755 => 100644 tests/__init__.py create mode 100644 tests/api/test_catalog_api.py mode change 100755 => 100644 tests/conftest.py create mode 100644 tests/data/users.json create mode 100644 tests/support/__init__.py create mode 100644 tests/support/demo_server.py delete mode 100644 tests/test_login.py delete mode 100755 tests/test_suite_demo.py create mode 100644 tests/ui/test_checkout.py create mode 100644 tests/ui/test_inventory.py create mode 100644 tests/ui/test_login.py create mode 100644 tests/unit/test_config.py create mode 100644 tests/unit/test_data.py create mode 100644 tests/unit/test_driver_factory.py delete mode 100755 utilities/__init__.py delete mode 100755 utilities/custom_logger.py delete mode 100755 utilities/locators_read.py delete mode 100755 utilities/read_excel.py delete mode 100755 utilities/read_json.py delete mode 100755 utilities/teststatus.py delete mode 100755 utilities/util.py diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml new file mode 100644 index 0000000..632feec --- /dev/null +++ b/.github/workflows/quality-gates.yml @@ -0,0 +1,74 @@ +name: Quality gates + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + framework: + name: Framework / Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install project + run: python -m pip install -e ".[test]" + - name: Lint + run: ruff check . + - name: Run API and unit tests + run: >- + pytest -m "api or unit" + --junitxml=artifacts/framework.xml + - name: Upload framework reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: framework-python-${{ matrix.python-version }} + path: artifacts/ + if-no-files-found: ignore + + selenium: + name: Selenium / ${{ matrix.browser }} + needs: framework + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: [chrome, firefox] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install project + run: python -m pip install -e ".[test]" + - name: Run browser tests + run: >- + pytest + --browser=${{ matrix.browser }} + --cov=automation_framework + --cov-report=term-missing + --cov-report=xml:artifacts/coverage-${{ matrix.browser }}.xml + --junitxml=artifacts/ui-${{ matrix.browser }}.xml + --html=artifacts/ui-${{ matrix.browser }}.html + --self-contained-html + - name: Upload browser reports and failure evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: selenium-${{ matrix.browser }} + path: artifacts/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbe78c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +artifacts/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f0ec87b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Yasir Savanur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 951d195..eb5360e 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,147 @@ -# Python-Test-Automation-Framework +# Orbit QA — Python Test Automation Framework -# Description +[![Quality gates](https://github.com/yasirsavanur/Python-Test-Automation-Framework/actions/workflows/quality-gates.yml/badge.svg)](https://github.com/yasirsavanur/Python-Test-Automation-Framework/actions/workflows/quality-gates.yml) +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![Selenium 4](https://img.shields.io/badge/Selenium-4-43B02A?logo=selenium&logoColor=white)](https://www.selenium.dev/) -Test Automation Framework using selenium and Python with the below features: +A production-minded UI and API automation framework built with Python, pytest, and Selenium 4. It demonstrates maintainable page objects, data-driven testing, cross-browser execution, Grid support, failure diagnostics, contract testing, and CI quality gates—without depending on an unstable public demo site. -- Framework is based on page object model. -- Reporting using Allure report. -- Reading locators from JSON file. -- Reading test data from JSON file. -- Integrated with TestRail to update test cases status after each run. -- Framework can integrate with CodeShip. +The repository includes **Orbit QA Store**, a small deterministic commerce system used as the test target. Clone the project and the whole suite is ready to run locally. -# Install dependences +## What this project demonstrates -- Install the depended packages in `requirements.txt` using `pip install -r requirements.txt` +- Selenium 4 sessions for Chrome, Firefox, or a remote Grid +- explicit waits and stable `data-testid` selectors—no fixed sleeps or implicit waits +- focused page objects that model user intent instead of test mechanics +- pytest fixtures, custom CLI options, markers, and readable parametrized cases +- UI journeys and API contract tests against the same system under test +- automatic screenshot and page-source capture when a browser test fails +- self-contained HTML, JUnit XML, and coverage reports +- GitHub Actions matrices across Python 3.10/3.12 and Chrome/Firefox +- Ruff linting and an enforced 80% branch-coverage threshold -# Create new test case +## Framework shape -In order to create a new test case using the **Framework**, you have to follow the below steps: +```text +src/automation_framework/ +├── api_client.py # JSON API test client +├── config.py # validated runtime settings +├── driver_factory.py # local and remote WebDriver sessions +└── pages/ # intent-focused page objects +demo_app/ # deterministic UI + API test target +tests/ +├── api/ # service contract checks +├── data/ # versioned, non-secret test data +├── support/ # ephemeral demo server +├── ui/ # browser journeys +└── unit/ # fast framework checks +``` -- In **locators module**, create a new locator for the element you would like to use, as below: +The fixture layer owns infrastructure and lifecycle; tests own assertions; page objects own browser interactions. That separation keeps failures clear and makes a new environment or page straightforward to add. +## Quick start - [{ - "pageName": "HomePage", - "name": "link_login", - "locateUsing": "xpath", - "locator": "//a[contains(text(),'Log In')]" - }] +Prerequisites: Python 3.10+ and Chrome or Firefox. -- In **test data module**, add the test data needed for your test case, as below: +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +python -m pip install --upgrade pip +python -m pip install -e ".[test]" - { - "environment": "https://learn.letskodeit.com/", - "browser": "firefox", - "email": "test@email.com", - "password": "abcabc" - } +pytest -m smoke --browser chrome +``` -* If the element exist in more than one page (**Navigation element**), use **navigation module** to create a script for that navigation bar and add your navigation action to that element, as below: +Selenium Manager resolves the matching local driver, so no machine-specific driver path is required. Tests start the bundled application on an available local port and shut it down after the session. - def goToLoginPage(self): - self.elementClick(*self.locator(self.homePage_locators, 'link_login')) +## Useful test runs -* If the element exists in only one page, go to **page module** and create a new script for that page e.g: `login_page.py` and add all the actions in that page, as below: +```bash +# Fast feedback, no browser +pytest -m "unit or api" - def login(self, email, password): - self.sendKeys(email, *self.locator(self.loginPage_locators, 'input_email')) - self.sendKeys(password, *self.locator(self.loginPage_locators, 'input_password')) - self.elementClick(*self.locator(self.loginPage_locators, 'btn_login')) +# Full Chrome suite with a self-contained report +pytest --browser chrome --html=artifacts/report.html --self-contained-html -* Then, in **test module**, create a new script for your test case(s) e.g: `test_login.py` and add your test case, as below: +# Firefox regression suite with JUnit output +pytest -m regression --browser firefox --junitxml=artifacts/regression.xml - @allure.story('epic_1') # story of the test case - @allure.severity(allure.severity_level.MINOR) # severity of the test case - @pytestrail.case('C48') # test case id on test rail - def test_login_successfully(self): +# Watch the test execute +pytest -m smoke --browser chrome --headed - with allure.step('Navigate to login page'): # name of the test step - self.homeNavigation.goToLoginPage() - self.ts.markFinal(self.loginPage.isAt, "navigation to login page failed") # check if the navigation to login page occurs successfully +# Run against a compatible remote environment +pytest -m ui --browser chrome \ + --grid-url http://localhost:4444/wd/hub \ + --base-url https://your-accessible-test-environment.example +``` - with allure.step('Login'): # name of the test step - self.loginPage.login(email=td.testData("email"), password=td.testData("password")) - self.ts.markFinal(self.dashboardPage.isAt, "login failed") # check if login successfully +### Runtime options -**Notes:** +| Option | Default | Purpose | +|---|---:|---| +| `--browser` | `chrome` | Select `chrome` or `firefox` | +| `--headed` | off | Display a local browser instead of using headless mode | +| `--grid-url` | local | Send the session to Selenium Grid or a cloud provider | +| `--base-url` | bundled app | Target another compatible environment | +| `--timeout` | `10` | Set the explicit-wait timeout in seconds | +| `--artifacts-dir` | `artifacts` | Choose where failure evidence is written | -- use `@allure.story('[epic name]')` decorator before each test case to define the related epic / story. -- use `@allure.severity(allure.severity_level.[severity])` decorator before each test case to define the severity of the test case Minor/Major/Critical/Blocker. -- use `@pytestrail.case('[test case id on testrail]')` decorator before each test case to defione the related test case id on test rail to make the script update run status on test rail. +## Example test -# Run the test case +```python +@pytest.mark.ui +@pytest.mark.smoke +def test_user_can_complete_checkout(driver, base_url, framework_config): + login = LoginPage(driver, base_url, framework_config.timeout) + inventory = InventoryPage(driver, base_url, framework_config.timeout) -In order to run the test case after creation, use on of the below commands: + login.load() + login.sign_in("qa.engineer@example.com", "quality-first") + inventory.wait_until_loaded() + inventory.add_product("Grid Compass") -- To run the test case and create allure report but without update the status run on TestRail: + assert inventory.cart_count == 1 +``` -`py.test --alluredir=allure_report tests/test_login.py` +The complete journey lives in [`tests/ui/test_checkout.py`](tests/ui/test_checkout.py). -`allure serve allure_report` +## Failure diagnostics -- To run the test case, create allure report and update the status of run on TestRail: +If a browser test reaches its assertion and fails, the `driver` fixture writes two files to `artifacts/`: -`py.test --alluredir=allure_report tests/test_login.py --testrail` +- a PNG screenshot showing the rendered state +- the matching HTML source for DOM-level investigation -`allure serve allure_report` +CI uploads these alongside the HTML and JUnit reports even when the test job fails. -**Note:** +## Continuous integration -- There are other options of run that you can search for them, as running all the test cases for specific epic/story or with specific severity +The workflow separates fast framework feedback from browser compatibility: -# Integration with TestRail +| Job | Coverage | +|---|---| +| Framework | Ruff plus unit/API tests on Python 3.10 and 3.12 | +| Selenium / Chrome | complete suite, HTML/JUnit reports, branch coverage | +| Selenium / Firefox | complete suite, HTML/JUnit reports, branch coverage | -In order to setup the integration with TestRail, edit `testrail.cfg` with your testrail domain and credentials, as below: +This keeps the signal specific: a Python compatibility problem, a browser-specific problem, and a product regression appear as different checks. - [API] - url = https://[your testrail domain].testrail.io - email = [testrail email] - password = [testrail password] +## Extending it - [TESTRUN] - project_id = [project id] +1. Add a page object under `src/automation_framework/pages/` with selectors and user-level actions. +2. Put reusable, non-secret cases under `tests/data/` and load them with `load_cases`. +3. Mark tests by intent (`smoke`, `regression`, `ui`, `api`, or `unit`). +4. Keep credentials outside the repository and inject them through your CI secret store. +5. Add provider-specific capabilities to `DriverFactory._options()` when connecting a cloud Grid. -======================== +## Design choices + +- **A bundled test target:** public demo sites change or disappear; this repository remains repeatable and portfolio-friendly. +- **Explicit waits:** each synchronization point describes the condition the test actually needs. +- **Native Selenium Manager:** local setup stays portable and avoids checked-in browser binaries. +- **Small abstractions:** page objects remove repetition while Selenium exceptions retain useful stack traces. +- **No live TestRail coupling:** results are emitted in portable JUnit XML so CI or a test-management adapter can consume them without putting account configuration in the framework. + +## License + +MIT diff --git a/base/__init__.py b/base/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/base/basepage.py b/base/basepage.py deleted file mode 100755 index 4cdd769..0000000 --- a/base/basepage.py +++ /dev/null @@ -1,50 +0,0 @@ -from base.selenium_driver import SeleniumDriver -from traceback import print_stack -from utilities.util import Util -import utilities.read_json as RJ -import os - - -class BasePage(SeleniumDriver): - - def __init__(self, driver): - super(BasePage, self).__init__(driver) - self.driver = driver - self.util = Util() - - def pageLocators(self, page): - """ - read the locators of specific page - :param page: page - :return: list of all locators in specific page - """ - locatorsPath = os.path.abspath("./locators/locators.json") - locatorsJsonFile = RJ.readJson(locatorsPath) - pageLocators = [locator for locator in locatorsJsonFile if locator['pageName'] in page] - return pageLocators - - def locator(self, pageLocators, locatorName): - """ - get specific locator in specific page - :param pageLocators: specific page - :param locatorName: locator name - :return: locator and locator Type - """ - for locator in pageLocators: - if locatorName == locator['name']: - return locator['locator'], locator['locateUsing'] - - def verifyPageTitle(self, titleToVerify): - """ - Verify the page Title - - :param titleToVerify: Title on the page that needs to be verified - """ - try: - actualTitle = self.getTitle() - return self.util.verifyTextContains(actualTitle, titleToVerify) - except: - self.log.error("Failed to get page title") - print_stack() - return False - diff --git a/base/selenium_driver.py b/base/selenium_driver.py deleted file mode 100755 index 1fc9170..0000000 --- a/base/selenium_driver.py +++ /dev/null @@ -1,405 +0,0 @@ -from selenium.webdriver.common.by import By -from traceback import print_stack -from selenium.webdriver.support.select import Select -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.common.action_chains import ActionChains -from selenium.common.exceptions import * -import utilities.custom_logger as cl -import logging -import time -import os -import allure - - -class SeleniumDriver(): - - log = cl.customLogger(logging.DEBUG) - - def __init__(self, driver): - self.driver = driver - - def screenShot(self, resultMessage): - """ - Take a screenshot of the current open web page - """ - fileName = resultMessage + "." + str(round(time.time() *1000)) + ".png" - if len(fileName) >= 200: - fileName = str(round(time.time() *1000)) + ".png" - screenshotDirectory = "../screenshots/" - relativeFileName = screenshotDirectory + fileName - currentDirectory = os.path.dirname(__file__) - destinationFile = os.path.join(currentDirectory, relativeFileName) - destinationDirectory = os.path.join(currentDirectory, screenshotDirectory) - - try: - if not os.path.exists(destinationDirectory): - os.makedirs(destinationDirectory) - self.driver.save_screenshot(destinationFile) - allure.attach(self.driver.get_screenshot_as_png(), - name=fileName, - attachment_type=allure.attachment_type.PNG) - self.log.info("Screenshot save to directory: " + destinationFile) - except: - self.log.error("### Exception Occurred when taking screenshot") - print_stack() - - def getTitle(self): - return self.driver.title - - def getByType(self, locatorType): - locatorType = locatorType.lower() - if locatorType == "id": - return By.ID - elif locatorType == "name": - return By.NAME - elif locatorType == "xpath": - return By.XPATH - elif locatorType == "css": - return By.CSS_SELECTOR - elif locatorType == "class": - return By.CLASS_NAME - elif locatorType == "link": - return By.LINK_TEXT - else: - self.log.info("Locator type" + locatorType + "not correct/supported") - return False - - def dropdownSelectElement(self, locator, locatorType="id", selector="", selectorType="value"): - try: - element = self.getElement(locator, locatorType) - sel = Select(element) - if selectorType == "value": - sel.select_by_value(selector) - time.sleep(1) - elif selectorType == "index": - sel.select_by_index(selector) - time.sleep(1) - elif selectorType == "text": - sel.select_by_visible_text(selector) - time.sleep(1) - self.log.info("Element selected with selector: " + str(selector) + - " and selectorType: " + selectorType) - - except: - self.log.error("Element not selected with selector: " + str(selector) + - " and selectorType: " + selectorType) - print_stack() - - def getDropdownOptionsCount(self, locator, locatorType="id"): - ''' - get the number of options of drop down list - :return: number of Options of drop down list - ''' - options = None - try: - element = self.getElement(locator, locatorType) - sel = Select(element) - options = sel.options - self.log.info("Element found with locator: " + locator + - " and locatorType: " + locatorType) - except: - self.log.error("Element not found with locator: " + locator + - " and locatorType: " + locatorType) - - return options - - def getDropdownSelectedOptionText(self, locator, locatorType="id"): - ''' - get the text of selected option in drop down list - :return: the text of selected option in drop down list - ''' - selectedOption_text = None - try: - element = self.getElement(locator, locatorType) - sel = Select(element) - selectedOption_text = sel.first_selected_option.text - self.log.info("Return the selected option of drop down list with locator: " + locator + - " and locatorType: " + locatorType) - except: - self.log.error("Can not return the selected option of drop down list with locator: " + locator + - " and locatorType: " + locatorType) - - return selectedOption_text - - def getDropdownSelectedOptionValue(self, locator, locatorType="id"): - ''' - get the value of selected option in drop down list - :return: the value of selected option in drop down list - ''' - selectedOption_value = None - try: - element = self.getElement(locator, locatorType) - sel = Select(element) - selectedOption_value = sel.first_selected_option.get_attribute("value") - self.log.info("Return the selected option of drop down list with locator: " + locator + - " and locatorType: " + locatorType) - except: - self.log.error("Can not return the selected option of drop down list with locator: " + locator + - " and locatorType: " + locatorType) - - return selectedOption_value - - def getElement(self, locator, locatorType="id"): - element = None - try: - locatorType = locatorType.lower() - byType = self.getByType(locatorType) - element = self.driver.find_element(byType, locator) - self.log.info("Element found with locator: " + locator + - " and locatorType: " + locatorType) - except: - self.log.error("Element not found with locator: " + locator + - " and locatorType: " + locatorType) - return element - - def isElementSelected(self, locator, locatorType): - isSelected = None - try: - element = self.getElement(locator, locatorType) - isSelected = element.is_selected() - self.log.info("Element found with locator: " + locator + - " and locatorType: " + locatorType) - except: - self.log.error("Element not found with locator: " + locator + - " and locatorType: " + locatorType) - - return isSelected - - def getElementList(self, locator, locatorType="id"): - """ - Get list of elements - """ - element = None - try: - locatorType = locatorType.lower() - byType = self.getByType(locatorType) - element = self.driver.find_elements(byType, locator) - self.log.info("Element list found with locator: " + locator + - " and locatorType: " + locatorType) - except: - self.log.error("Element list not found with locator: " + locator + - " and locatorType: " + locatorType) - - return element - - - - def elementClick(self, locator="", locatorType="id", element=None): - """ - Either provide element or a combination of locator and locatorType - """ - - try: - if locator: - element = self.getElement(locator, locatorType) - element.click() - self.log.info("clicked on element with locator: " + locator + - " locatorType: " + locatorType) - except: - self.log.error("cannot click on the element with locator: " + locator + - " locatorType: " + locatorType) - print_stack() - - def elementHover(self, locator="", locatorType="id", element=None): - """ - Either provide element or a combination of locator and locatorType - """ - - try: - if locator: - element = self.getElement(locator, locatorType) - hover = ActionChains(self.driver).move_to_element(element) - hover.perform() - time.sleep(2) - self.log.info("hover to element with locator: " + locator + - " locatorType: " + locatorType) - except: - self.log.error("cannot hover to the element with locator: " + locator + - " locatorType: " + locatorType) - print_stack() - - def sendKeys(self, data, locator="", locatorType="id", element=None): - """ - Send keys to an element - Either provide element or a combination of locator and locatorType - """ - try: - if locator: - element = self.getElement(locator, locatorType) - element.send_keys(data) - self.log.info("send data on element with locator: " + locator + - " locatorType: " + locatorType) - except: - self.log.error("cannot send data on the element with locator: " + locator + - " locatorType: " + locatorType) - print_stack() - - def clearKeys(self, locator="", locatorType="id", element=None): - """ - Clear keys of an element - Either provide element or a combination of locator and locatorType - """ - try: - if locator: - element = self.getElement(locator, locatorType) - element.clear() - self.log.info("Clear data of element with locator: " + locator + - " locatorType: " + locatorType) - except: - self.log.error("cannot clear data of the element with locator: " + locator + - " locatorType: " + locatorType) - print_stack() - - def getText(self, locator="", locatorType="id", element=None, info=""): - """ - Get 'Text' on an element - Either provide element or a combination of locator and locatorType - """ - try: - if locator: - self.log.debug("In locator condition") - element = self.getElement(locator, locatorType) - self.log.debug("Before finding text") - text = element.text - self.log.debug("After finding element, size is: " + str(len(text))) - if len(text) == 0: - text = element.get_attribute("innerText") - if len(text) !=0: - self.log.info("Getting text on element :: " + info) - self.log.info("The text is :: '" + text + "'") - text = text.strip() - except: - self.log.error("Failed to get text on element " + info) - print_stack() - text = None - return text - - def isElementPresent(self, locator="", locatorType="id", element=None): - """ - Check if element is present - Either provide element or a combination of locator and locatorType - """ - try: - if locator: - element = self.getElement(locator, locatorType) - if element is not None: - self.log.info("Element found with locator: " + locator + - " and locatorType: " + locatorType) - return True - else: - self.log.error("Element not found with locator: " + locator + - " and locatorType: " + locatorType) - return False - except: - self.log.error("Element not found with locator: " + locator + - " and locatorType: " + locatorType) - return False - - def isElementDisplayed(self, locator="", locatorType="id", element=None): - """ - Check if element is displayed - Either provide element or a combination of locator and locatorType - """ - isDisplayed = False - try: - if locator: - element = self.getElement(locator, locatorType) - if element is not None: - isDisplayed = element.is_displayed() - self.log.info("Element is displayed with locator: " + locator + - " and locatorType: " + locatorType) - else: - self.log.error("Element is not displayed with locator: " + locator + - " and locatorType: " + locatorType) - return isDisplayed - except: - self.log.error("Element is not displayed with locator: " + locator + - " and locatorType: " + locatorType) - return False - - def elementPresenceCheck(self, locator="", locatorType="id"): - try: - locatorType = locatorType.lower() - byType = self.getByType(locatorType) - elementList = self.driver.find_elements(byType, locator) - if len(elementList) > 0: - self.log.info("Element Found") - return True - else: - self.log.info("Element not found") - return False - except: - self.log.info("Element not found") - return False - - def waitForElement(self, locator, locatorType = 'id', timeout = 10, pollFrequency = 0.5 ): - element = None - try: - self.log.info("Waiting for maximum :: " + str(timeout) + " :: seconds for element to be clickable") - - wait = WebDriverWait(self.driver, timeout, poll_frequency=pollFrequency, - ignored_exceptions=[NoSuchElementException, - ElementNotVisibleException, - ElementNotSelectableException]) - ByType = self.getByType(locatorType) - element = wait.until(EC.element_to_be_clickable((ByType,locator))) - - self.log.info("Element appeared on the web page") - - except: - self.log.info("Element not appeared on the web page") - print_stack() - - return element - - def webScroll(self, direction="up"): - if direction == "up": - # Scroll Up - self.driver.execute_script("window.scrollBy(0, -1000);") - if direction == "down": - # Scroll Down - self.driver.execute_script("window.scrollBy(0, 1000);") - - def getURL(self): - ''' - Get the current URL - :return: current URL - ''' - currentURL = self.driver.current_url - - return currentURL - - def pageBack(self): - ''' - page back the browser - ''' - self.driver.execute_script("window.history.go(-1)") - - def getAttributeValue(self, locator="", locatorType="id", element=None, attribute=""): - ''' - get attribute value - ''' - try: - if locator: - self.log.debug("In locator condition") - element = self.getElement(locator, locatorType) - attribute_value = element.get_attribute(attribute) - except: - self.log.error("Failed to get " + attribute + " in element with locator: " + - locator + " and locatorType: " + locatorType) - print_stack() - attribute_value = None - return attribute_value - - def refresh(self): - self.driver.get(self.driver.current_url) - - def page_has_loaded(self): - try: - WebDriverWait(self.driver, 1000, poll_frequency=0.5).until(lambda driver: self.driver.execute_script('return document.readyState == "complete";')) - WebDriverWait(self.driver, 1000, poll_frequency=0.5).until(lambda driver: self.driver.execute_script('return jQuery.active == 0')) - WebDriverWait(self.driver, 1000, poll_frequency=0.5).until(lambda driver: self.driver.execute_script('return typeof jQuery != "undefined"')) - WebDriverWait(self.driver, 1000, poll_frequency=0.5).until(lambda driver: self.driver.execute_script('return angular.element(document).injector().get("$http").pendingRequests.length === 0')) - except: - return False diff --git a/base/webdriverfactory.py b/base/webdriverfactory.py deleted file mode 100755 index 18a96e7..0000000 --- a/base/webdriverfactory.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -@package base - -WebDriver Factory class implementation -It creates a web-driver instance based on browser configurations - -""" -from selenium import webdriver -import os -import test_data.testData as td - - -class WebDriverFactory: - - def __init__(self): - """ - Inits WebDriverFactory class - :Returns None: - """ - self.browser = td.testData("browser") - self.baseUrl = td.testData("environment") - - def getWebDriverInstance(self): - """ - Get WebDriver Instance based on the browser configuration - - :return 'WebDriver Instance': - """ - - if self.browser == "firefox": - driver = webdriver.Firefox() - - elif self.browser == "chrome": - # Set Chrome driver - driverLocation = "/users/karimelkomy/PycharmProjects/lib/chromedriver" - os.environ["webdriver.chrome.driver"] = driverLocation - driver = webdriver.Chrome(driverLocation) - driver.set_window_size(1366, 768) - - else: - driver = webdriver.Firefox() - - # Setting Driver Implicit Time out for An Element - driver.implicitly_wait(15) - # Maximize the window - driver.maximize_window() - # Loading browser with App URL - driver.get(self.baseUrl) - - return driver - - diff --git a/demo_app/app.js b/demo_app/app.js new file mode 100644 index 0000000..36c812b --- /dev/null +++ b/demo_app/app.js @@ -0,0 +1,97 @@ +const VALID_USER = "qa.engineer@example.com"; +const VALID_PASSWORD = "quality-first"; +const CART_KEY = "orbit-qa-cart"; + +const readCart = () => JSON.parse(localStorage.getItem(CART_KEY) || "[]"); +const writeCart = (items) => localStorage.setItem(CART_KEY, JSON.stringify(items)); + +function updateCartCount() { + const count = document.querySelector("[data-testid='cart-count']"); + if (count) count.textContent = String(readCart().length); +} + +function configureLogin() { + const form = document.querySelector("[data-testid='login-form']"); + if (!form) return; + + form.addEventListener("submit", (event) => { + event.preventDefault(); + const email = document.querySelector("[data-testid='email']").value.trim(); + const password = document.querySelector("[data-testid='password']").value; + const error = document.querySelector("[data-testid='login-error']"); + + if (email === VALID_USER && password === VALID_PASSWORD) { + sessionStorage.setItem("orbit-qa-user", email); + localStorage.removeItem(CART_KEY); + window.location.assign("inventory.html"); + return; + } + + error.textContent = "We couldn't sign you in with those credentials."; + }); +} + +function configureInventory() { + const cards = [...document.querySelectorAll("[data-testid='product-card']")]; + if (!cards.length) return; + + updateCartCount(); + cards.forEach((card) => { + card.querySelector("[data-testid='add-product']").addEventListener("click", () => { + const cart = readCart(); + cart.push({ + name: card.dataset.productName, + price: Number(card.dataset.productPrice), + }); + writeCart(cart); + updateCartCount(); + + const notice = document.querySelector("[data-testid='notice']"); + notice.hidden = false; + notice.textContent = `${card.dataset.productName} added to cart`; + }); + }); + + document.querySelector("[data-testid='product-search']").addEventListener("input", (event) => { + const term = event.target.value.toLowerCase(); + cards.forEach((card) => { + card.hidden = !card.dataset.productName.toLowerCase().includes(term); + }); + }); +} + +function configureCart() { + const list = document.querySelector("[data-testid='cart-list']"); + if (!list) return; + + const cart = readCart(); + if (!cart.length) { + list.innerHTML = '

Your cart is empty.

'; + document.querySelector("[data-testid='checkout-link']").hidden = true; + return; + } + + list.innerHTML = cart.map((item) => ` +
+ ${item.name} + $${item.price.toFixed(2)} +
+ `).join(""); +} + +function configureCheckout() { + const form = document.querySelector("[data-testid='checkout-form']"); + if (!form) return; + + form.addEventListener("submit", (event) => { + event.preventDefault(); + form.hidden = true; + document.querySelector("[data-testid='success-panel']").hidden = false; + writeCart([]); + }); +} + +configureLogin(); +configureInventory(); +configureCart(); +configureCheckout(); diff --git a/demo_app/cart.html b/demo_app/cart.html new file mode 100644 index 0000000..7ef9f33 --- /dev/null +++ b/demo_app/cart.html @@ -0,0 +1,18 @@ + + + + + + Cart | Orbit QA Store + + + +
O Orbit QA Store
+
+ +
+ Continue to checkout +
+ + + diff --git a/demo_app/checkout.html b/demo_app/checkout.html new file mode 100644 index 0000000..4582909 --- /dev/null +++ b/demo_app/checkout.html @@ -0,0 +1,26 @@ + + + + + + Checkout | Orbit QA Store + + + +
O Orbit QA Store
+
+ +
+
+
+
+ +
+ +
+ + + diff --git a/demo_app/index.html b/demo_app/index.html new file mode 100644 index 0000000..af5e98e --- /dev/null +++ b/demo_app/index.html @@ -0,0 +1,45 @@ + + + + + + Sign in | Orbit QA Store + + + +
+ O Orbit QA Store + Deterministic test target +
+
+
+

Automation without flaky dependencies

+

Test the journey, not the internet.

+

A deliberately compact commerce flow built into the repository. It gives UI and API tests stable contracts while keeping every interaction realistic.

+
+ Stable data-testid selectors + Accessible forms + API contract +
+
+
+

Welcome back

+

Sign in to inspect the automation catalogue.

+
+
+ + +
+
+ + +
+ + +
+
Demo user
qa.engineer@example.com
quality-first
+
+
+ + + diff --git a/demo_app/inventory.html b/demo_app/inventory.html new file mode 100644 index 0000000..a172d8a --- /dev/null +++ b/demo_app/inventory.html @@ -0,0 +1,39 @@ + + + + + + Catalogue | Orbit QA Store + + + +
+ O Orbit QA Store + Cart 0 +
+
+ +
+ +
+
+
+
GC

Grid Compass

Cross-browser diagnostics for every remote session.

+ +
+
+
SS

Selector Studio

Durable locator patterns designed for maintainable tests.

+ +
+
+
TV

Trace Vault

Failure evidence packaged for quick root-cause analysis.

+ +
+
+
+ + + + diff --git a/demo_app/products.json b/demo_app/products.json new file mode 100644 index 0000000..d12ec45 --- /dev/null +++ b/demo_app/products.json @@ -0,0 +1,5 @@ +[ + {"id": "grid-compass", "name": "Grid Compass", "price": 49.0, "currency": "USD"}, + {"id": "selector-studio", "name": "Selector Studio", "price": 39.0, "currency": "USD"}, + {"id": "trace-vault", "name": "Trace Vault", "price": 59.0, "currency": "USD"} +] diff --git a/demo_app/styles.css b/demo_app/styles.css new file mode 100644 index 0000000..be3d5ae --- /dev/null +++ b/demo_app/styles.css @@ -0,0 +1,270 @@ +:root { + color-scheme: light; + --ink: #12233f; + --muted: #5f6f86; + --line: #dce5ef; + --surface: #ffffff; + --canvas: #f4f7fb; + --primary: #3957d8; + --primary-dark: #273fae; + --accent: #12a886; + --danger: #c53b54; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + background: var(--canvas); +} + +button, input { font: inherit; } + +a { color: inherit; } + +.shell { + width: min(1120px, calc(100% - 40px)); + margin: 0 auto; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 76px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 800; + letter-spacing: -.02em; + text-decoration: none; +} + +.brand-mark { + display: grid; + width: 34px; + height: 34px; + place-items: center; + color: white; + border-radius: 10px; + background: linear-gradient(145deg, var(--primary), #7a56e8); +} + +.login-layout { + display: grid; + grid-template-columns: 1.2fr .8fr; + min-height: calc(100vh - 76px); + align-items: center; + gap: 72px; + padding: 40px 0 88px; +} + +.eyebrow { + color: var(--primary); + font-size: .78rem; + font-weight: 800; + letter-spacing: .12em; + text-transform: uppercase; +} + +h1 { + max-width: 760px; + margin: 12px 0 18px; + font-size: clamp(2.2rem, 5vw, 4.7rem); + line-height: .98; + letter-spacing: -.055em; +} + +.lede { + max-width: 610px; + color: var(--muted); + font-size: 1.1rem; + line-height: 1.7; +} + +.signal-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 30px; +} + +.signal { + padding: 8px 12px; + color: #315b51; + font-size: .82rem; + font-weight: 700; + border: 1px solid #bce9dd; + border-radius: 999px; + background: #eafaf5; +} + +.panel, .product-card, .cart-item { + border: 1px solid var(--line); + border-radius: 18px; + background: var(--surface); + box-shadow: 0 18px 60px rgba(42, 58, 92, .08); +} + +.panel { padding: 32px; } + +.panel h2 { margin: 0 0 8px; font-size: 1.45rem; } + +.hint { margin: 0 0 24px; color: var(--muted); line-height: 1.55; } + +.field { display: grid; gap: 8px; margin: 18px 0; } + +.field label { font-size: .86rem; font-weight: 750; } + +.field input, .search { + width: 100%; + padding: 12px 14px; + color: var(--ink); + border: 1px solid #cbd7e5; + border-radius: 10px; + background: #fbfdff; + outline: none; +} + +.field input:focus, .search:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(57, 87, 216, .12); +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0 18px; + color: white; + font-weight: 800; + border: 0; + border-radius: 10px; + background: var(--primary); + cursor: pointer; + text-decoration: none; +} + +.button:hover { background: var(--primary-dark); } +.button.full { width: 100%; } + +.error { + min-height: 22px; + margin: 4px 0 12px; + color: var(--danger); + font-size: .88rem; + font-weight: 700; +} + +.credentials { + margin-top: 20px; + padding: 14px; + color: var(--muted); + font-size: .82rem; + line-height: 1.65; + border-radius: 10px; + background: #f2f5fa; +} + +.page-header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 20px; + padding: 48px 0 28px; +} + +.page-header h1 { margin: 8px 0 0; font-size: clamp(2rem, 4vw, 3.4rem); } + +.cart-link { + display: inline-flex; + gap: 8px; + align-items: center; + padding: 10px 14px; + font-weight: 800; + border: 1px solid var(--line); + border-radius: 10px; + background: white; + text-decoration: none; +} + +.cart-count { + display: grid; + min-width: 24px; + height: 24px; + place-items: center; + color: white; + font-size: .78rem; + border-radius: 999px; + background: var(--primary); +} + +.toolbar { width: min(420px, 100%); margin-bottom: 24px; } + +.product-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + padding-bottom: 80px; +} + +.product-card { display: grid; gap: 18px; padding: 24px; } +.product-card[hidden] { display: none; } + +.product-icon { + display: grid; + width: 54px; + height: 54px; + place-items: center; + color: white; + font-size: 1.35rem; + font-weight: 900; + border-radius: 14px; + background: linear-gradient(145deg, #324fd0, #17a181); +} + +.product-card h2 { margin: 0; } +.product-card p { min-height: 48px; margin: 0; color: var(--muted); line-height: 1.5; } + +.product-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.price { font-size: 1.12rem; font-weight: 850; } + +.notice { + position: fixed; + right: 24px; + bottom: 24px; + padding: 12px 16px; + color: white; + font-weight: 750; + border-radius: 10px; + background: var(--accent); + box-shadow: 0 12px 30px rgba(10, 80, 64, .25); +} + +.content-narrow { width: min(720px, 100%); padding-bottom: 80px; } +.cart-list { display: grid; gap: 12px; margin: 8px 0 24px; } +.cart-item { display: flex; justify-content: space-between; padding: 18px 20px; box-shadow: none; } +.empty { padding: 28px; color: var(--muted); text-align: center; border: 1px dashed #bcc9d9; border-radius: 14px; } + +.success { + padding: 36px; + text-align: center; + border: 1px solid #bce9dd; + border-radius: 18px; + background: #ecfaf6; +} + +.success h2 { margin: 10px 0; font-size: 2rem; } +.success-mark { color: var(--accent); font-size: 2rem; } + +@media (max-width: 780px) { + .login-layout { grid-template-columns: 1fr; gap: 36px; } + .product-grid { grid-template-columns: 1fr; } + .page-header { align-items: start; flex-direction: column; } +} diff --git a/locators/locators.json b/locators/locators.json deleted file mode 100755 index c8dbc09..0000000 --- a/locators/locators.json +++ /dev/null @@ -1,38 +0,0 @@ -[ - { - "pageName": "HomePage", - "name": "link_login", - "locateUsing": "xpath", - "locator": "//a[contains(text(),'Login')]" - }, - { - "pageName": "LoginPage", - "name": "header_login", - "locateUsing": "xpath", - "locator": "//h1[contains(text(),'Log In to Let\\'s Kode It')]]" - }, - { - "pageName": "LoginPage", - "name": "input_email", - "locateUsing": "xpath", - "locator": "//input[@id='user_email']" - }, - { - "pageName": "LoginPage", - "name": "input_password", - "locateUsing": "xpath", - "locator": "//input[@id='user_password']" - }, - { - "pageName": "LoginPage", - "name": "btn_login", - "locateUsing": "xpath", - "locator": "//input[@value='Log In']" - }, - { - "pageName": "LoggedInPage", - "name": "input_search", - "locateUsing": "xpath", - "locator": "//input[@id='search-courses']" - } -] diff --git a/navigation/__init__.py b/navigation/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/navigation/homeNavigation.py b/navigation/homeNavigation.py deleted file mode 100644 index 89b07d1..0000000 --- a/navigation/homeNavigation.py +++ /dev/null @@ -1,16 +0,0 @@ -import utilities.custom_logger as cl -import logging -from base.basepage import BasePage - - -class HomeNavigation(BasePage): - - log = cl.customLogger(logging.DEBUG) - - def __init__(self, driver): - super().__init__(driver) - self.driver = driver - self.homePage_locators = self.pageLocators('HomePage') - - def goToLoginPage(self): - self.elementClick(*self.locator(self.homePage_locators, 'link_login')) diff --git a/pages/__init__.py b/pages/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/pages/loggedin_page.py b/pages/loggedin_page.py deleted file mode 100644 index 78a94dc..0000000 --- a/pages/loggedin_page.py +++ /dev/null @@ -1,20 +0,0 @@ -import utilities.custom_logger as cl -import logging -from base.basepage import BasePage - - -class LoggedInPage(BasePage): - - log = cl.customLogger(logging.DEBUG) - - def __init__(self, driver): - super().__init__(driver) - self.driver = driver - self.loggedInPage_locators = self.pageLocators('LoggedInPage') - - @property - def isAt(self): - input_search = self.getElementList(*self.locator(self.loggedInPage_locators, 'input_search')) - if len(input_search) > 0: - return True - return False diff --git a/pages/login_page.py b/pages/login_page.py deleted file mode 100644 index 36ff531..0000000 --- a/pages/login_page.py +++ /dev/null @@ -1,24 +0,0 @@ -import utilities.custom_logger as cl -import logging -from base.basepage import BasePage - - -class LoginPage(BasePage): - - log = cl.customLogger(logging.DEBUG) - - def __init__(self, driver): - super().__init__(driver) - self.driver = driver - self.loginPage_locators = self.pageLocators('LoginPage') - - def isAt(self): - header_login = self.getElementList(*self.locator(self.loginPage_locators, 'header_login')) - if len(header_login) > 0: - return True - return False - - def login(self, email, password): - self.sendKeys(email, *self.locator(self.loginPage_locators, 'input_email')) - self.sendKeys(password, *self.locator(self.loginPage_locators, 'input_password')) - self.elementClick(*self.locator(self.loginPage_locators, 'btn_login')) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b56c3fd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "orbit-qa-framework" +version = "2.0.0" +description = "A production-minded Selenium and pytest automation framework" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [{name = "Yasir Savanur"}] +dependencies = [ + "selenium>=4.20,<5", +] + +[project.optional-dependencies] +test = [ + "pytest>=8,<10", + "pytest-cov>=5,<8", + "pytest-html>=4,<5", + "ruff>=0.5,<1", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +addopts = "-ra --strict-config --strict-markers" +testpaths = ["tests"] +markers = [ + "api: service-level contract tests", + "regression: broader behavioural coverage", + "smoke: critical user journeys", + "ui: browser-based Selenium tests", + "unit: isolated framework tests", +] + +[tool.coverage.run] +branch = true +source = ["automation_framework"] + +[tool.coverage.report] +show_missing = true +skip_covered = true +fail_under = 80 + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 374a843..0000000 --- a/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -pytest -selenium -ipython -openpyxl -allure-pytest -allure-python-commons -nose -pytest-testrail -urllib3==1.22 \ No newline at end of file diff --git a/src/automation_framework/__init__.py b/src/automation_framework/__init__.py new file mode 100644 index 0000000..3989e34 --- /dev/null +++ b/src/automation_framework/__init__.py @@ -0,0 +1,5 @@ +"""Reusable building blocks for browser and API test automation.""" + +from automation_framework.config import FrameworkConfig + +__all__ = ["FrameworkConfig"] diff --git a/src/automation_framework/api_client.py b/src/automation_framework/api_client.py new file mode 100644 index 0000000..2cd223e --- /dev/null +++ b/src/automation_framework/api_client.py @@ -0,0 +1,35 @@ +"""Dependency-light JSON client for service-level test assertions.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any +from urllib.parse import urljoin +from urllib.request import Request, urlopen + + +@dataclass(frozen=True, slots=True) +class ApiResponse: + status_code: int + body: Any + headers: dict[str, str] + + +class ApiClient: + """Make JSON GET requests against the system under test.""" + + def __init__(self, base_url: str, timeout: float = 5.0) -> None: + self.base_url = base_url.rstrip("/") + "/" + self.timeout = timeout + + def get(self, path: str) -> ApiResponse: + url = urljoin(self.base_url, path.lstrip("/")) + request = Request(url, headers={"Accept": "application/json"}) + with urlopen(request, timeout=self.timeout) as response: # noqa: S310 + payload = json.loads(response.read().decode("utf-8")) + return ApiResponse( + status_code=response.status, + body=payload, + headers=dict(response.headers.items()), + ) diff --git a/src/automation_framework/config.py b/src/automation_framework/config.py new file mode 100644 index 0000000..66852e8 --- /dev/null +++ b/src/automation_framework/config.py @@ -0,0 +1,42 @@ +"""Runtime configuration shared by pytest fixtures and driver creation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +SUPPORTED_BROWSERS = ("chrome", "firefox") + + +@dataclass(frozen=True, slots=True) +class FrameworkConfig: + """Validated settings for a single automation session.""" + + browser: str = "chrome" + headless: bool = True + grid_url: str | None = None + timeout: float = 10.0 + artifacts_dir: Path = Path("artifacts") + + def __post_init__(self) -> None: + browser = self.browser.lower() + if browser not in SUPPORTED_BROWSERS: + choices = ", ".join(SUPPORTED_BROWSERS) + raise ValueError(f"Unsupported browser '{self.browser}'. Choose one of: {choices}") + if self.timeout <= 0: + raise ValueError("timeout must be greater than zero") + object.__setattr__(self, "browser", browser) + object.__setattr__(self, "artifacts_dir", Path(self.artifacts_dir)) + + @classmethod + def from_pytest(cls, pytest_config: Any) -> FrameworkConfig: + """Build settings from the CLI options registered in ``tests/conftest.py``.""" + + return cls( + browser=pytest_config.getoption("--browser"), + headless=not pytest_config.getoption("--headed"), + grid_url=pytest_config.getoption("--grid-url") or None, + timeout=pytest_config.getoption("--timeout"), + artifacts_dir=Path(pytest_config.getoption("--artifacts-dir")), + ) diff --git a/src/automation_framework/data.py b/src/automation_framework/data.py new file mode 100644 index 0000000..af0fda1 --- /dev/null +++ b/src/automation_framework/data.py @@ -0,0 +1,30 @@ +"""Small, typed helpers for data-driven tests.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def load_json(path: str | Path) -> Any: + """Read JSON and add the source path to decoding errors.""" + + source = Path(path) + try: + with source.open(encoding="utf-8") as data_file: + return json.load(data_file) + except json.JSONDecodeError as error: + raise ValueError(f"Invalid JSON in {source}: {error.msg}") from error + + +def load_cases(path: str | Path, key: str) -> list[dict[str, Any]]: + """Load a named list of test cases from a JSON object.""" + + payload = load_json(path) + if not isinstance(payload, dict) or not isinstance(payload.get(key), list): + raise ValueError(f"Expected '{key}' to be a list in {Path(path)}") + cases = payload[key] + if not all(isinstance(case, dict) for case in cases): + raise ValueError(f"Every '{key}' entry in {Path(path)} must be an object") + return cases diff --git a/src/automation_framework/driver_factory.py b/src/automation_framework/driver_factory.py new file mode 100644 index 0000000..b0620f2 --- /dev/null +++ b/src/automation_framework/driver_factory.py @@ -0,0 +1,48 @@ +"""Selenium 4 WebDriver creation for local browsers or a remote Grid.""" + +from __future__ import annotations + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options as ChromeOptions +from selenium.webdriver.firefox.options import Options as FirefoxOptions +from selenium.webdriver.remote.webdriver import WebDriver + +from automation_framework.config import FrameworkConfig + + +class DriverFactory: + """Create consistently configured, modern WebDriver sessions.""" + + def __init__(self, config: FrameworkConfig) -> None: + self.config = config + + def create(self) -> WebDriver: + options = self._options() + if self.config.grid_url: + driver = webdriver.Remote( + command_executor=self.config.grid_url, + options=options, + ) + elif self.config.browser == "chrome": + driver = webdriver.Chrome(options=options) + else: + driver = webdriver.Firefox(options=options) + + driver.set_window_size(1440, 1000) + driver.set_page_load_timeout(30) + return driver + + def _options(self) -> ChromeOptions | FirefoxOptions: + if self.config.browser == "chrome": + options = ChromeOptions() + if self.config.headless: + options.add_argument("--headless=new") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--no-sandbox") + else: + options = FirefoxOptions() + if self.config.headless: + options.add_argument("-headless") + + options.set_capability("acceptInsecureCerts", True) + return options diff --git a/src/automation_framework/pages/__init__.py b/src/automation_framework/pages/__init__.py new file mode 100644 index 0000000..8ed0f70 --- /dev/null +++ b/src/automation_framework/pages/__init__.py @@ -0,0 +1,8 @@ +"""Page objects for the bundled Orbit QA Store demo.""" + +from automation_framework.pages.cart_page import CartPage +from automation_framework.pages.checkout_page import CheckoutPage +from automation_framework.pages.inventory_page import InventoryPage +from automation_framework.pages.login_page import LoginPage + +__all__ = ["CartPage", "CheckoutPage", "InventoryPage", "LoginPage"] diff --git a/src/automation_framework/pages/base_page.py b/src/automation_framework/pages/base_page.py new file mode 100644 index 0000000..8673454 --- /dev/null +++ b/src/automation_framework/pages/base_page.py @@ -0,0 +1,41 @@ +"""Shared explicit-wait actions used by all page objects.""" + +from __future__ import annotations + +from urllib.parse import urljoin + +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.remote.webelement import WebElement +from selenium.webdriver.support import expected_conditions as conditions +from selenium.webdriver.support.ui import WebDriverWait + +Locator = tuple[str, str] + + +class BasePage: + """Expose intent-focused interactions without hiding Selenium failures.""" + + def __init__(self, driver: WebDriver, base_url: str, timeout: float = 10.0) -> None: + self.driver = driver + self.base_url = base_url.rstrip("/") + "/" + self.wait = WebDriverWait(driver, timeout) + + def open(self, path: str) -> None: + self.driver.get(urljoin(self.base_url, path.lstrip("/"))) + + def visible(self, locator: Locator) -> WebElement: + return self.wait.until(conditions.visibility_of_element_located(locator)) + + def all_present(self, locator: Locator) -> list[WebElement]: + return self.wait.until(conditions.presence_of_all_elements_located(locator)) + + def click(self, locator: Locator) -> None: + self.wait.until(conditions.element_to_be_clickable(locator)).click() + + def fill(self, locator: Locator, value: str) -> None: + element = self.visible(locator) + element.clear() + element.send_keys(value) + + def text(self, locator: Locator) -> str: + return self.visible(locator).text.strip() diff --git a/src/automation_framework/pages/cart_page.py b/src/automation_framework/pages/cart_page.py new file mode 100644 index 0000000..196384d --- /dev/null +++ b/src/automation_framework/pages/cart_page.py @@ -0,0 +1,21 @@ +"""Shopping cart page behaviours.""" + +from selenium.webdriver.common.by import By + +from automation_framework.pages.base_page import BasePage + + +class CartPage(BasePage): + HEADING = (By.CSS_SELECTOR, "[data-testid='cart-heading']") + ITEM_NAME = (By.CSS_SELECTOR, "[data-testid='cart-item-name']") + CHECKOUT = (By.CSS_SELECTOR, "[data-testid='checkout-link']") + + def wait_until_loaded(self) -> None: + self.visible(self.HEADING) + + @property + def item_names(self) -> list[str]: + return [element.text.strip() for element in self.all_present(self.ITEM_NAME)] + + def begin_checkout(self) -> None: + self.click(self.CHECKOUT) diff --git a/src/automation_framework/pages/checkout_page.py b/src/automation_framework/pages/checkout_page.py new file mode 100644 index 0000000..a0f8e60 --- /dev/null +++ b/src/automation_framework/pages/checkout_page.py @@ -0,0 +1,32 @@ +"""Checkout page behaviours.""" + +from selenium.webdriver.common.by import By + +from automation_framework.pages.base_page import BasePage + + +class CheckoutPage(BasePage): + HEADING = (By.CSS_SELECTOR, "[data-testid='checkout-heading']") + FIRST_NAME = (By.CSS_SELECTOR, "[data-testid='first-name']") + LAST_NAME = (By.CSS_SELECTOR, "[data-testid='last-name']") + POSTCODE = (By.CSS_SELECTOR, "[data-testid='postcode']") + COMPLETE = (By.CSS_SELECTOR, "[data-testid='complete-order']") + SUCCESS = (By.CSS_SELECTOR, "[data-testid='success-heading']") + ORDER_NUMBER = (By.CSS_SELECTOR, "[data-testid='order-number']") + + def wait_until_loaded(self) -> None: + self.visible(self.HEADING) + + def complete_order(self, first_name: str, last_name: str, postcode: str) -> None: + self.fill(self.FIRST_NAME, first_name) + self.fill(self.LAST_NAME, last_name) + self.fill(self.POSTCODE, postcode) + self.click(self.COMPLETE) + + @property + def success_heading(self) -> str: + return self.text(self.SUCCESS) + + @property + def order_number(self) -> str: + return self.text(self.ORDER_NUMBER) diff --git a/src/automation_framework/pages/inventory_page.py b/src/automation_framework/pages/inventory_page.py new file mode 100644 index 0000000..9671ea2 --- /dev/null +++ b/src/automation_framework/pages/inventory_page.py @@ -0,0 +1,44 @@ +"""Inventory page behaviours.""" + +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as conditions + +from automation_framework.pages.base_page import BasePage + + +class InventoryPage(BasePage): + HEADING = (By.CSS_SELECTOR, "[data-testid='inventory-heading']") + PRODUCT_CARD = (By.CSS_SELECTOR, "[data-testid='product-card']") + CART_COUNT = (By.CSS_SELECTOR, "[data-testid='cart-count']") + CART_LINK = (By.CSS_SELECTOR, "[data-testid='cart-link']") + SEARCH = (By.CSS_SELECTOR, "[data-testid='product-search']") + + def wait_until_loaded(self) -> None: + self.visible(self.HEADING) + + def add_product(self, name: str) -> None: + def matching_button(_driver): + for card in self.driver.find_elements(*self.PRODUCT_CARD): + if card.get_attribute("data-product-name") == name: + return card.find_element(By.CSS_SELECTOR, "[data-testid='add-product']") + return False + + self.wait.until(matching_button).click() + + @property + def cart_count(self) -> int: + return int(self.text(self.CART_COUNT)) + + def open_cart(self) -> None: + self.click(self.CART_LINK) + + def search(self, term: str) -> None: + self.fill(self.SEARCH, term) + + @property + def visible_product_names(self) -> list[str]: + locator = (By.CSS_SELECTOR, "[data-testid='product-card']:not([hidden])") + self.wait.until(conditions.presence_of_element_located(locator)) + return [ + card.get_attribute("data-product-name") for card in self.driver.find_elements(*locator) + ] diff --git a/src/automation_framework/pages/login_page.py b/src/automation_framework/pages/login_page.py new file mode 100644 index 0000000..18b8f2e --- /dev/null +++ b/src/automation_framework/pages/login_page.py @@ -0,0 +1,27 @@ +"""Login page actions and assertions.""" + +from selenium.webdriver.common.by import By + +from automation_framework.pages.base_page import BasePage + + +class LoginPage(BasePage): + PATH = "index.html" + HEADING = (By.CSS_SELECTOR, "[data-testid='login-heading']") + EMAIL = (By.CSS_SELECTOR, "[data-testid='email']") + PASSWORD = (By.CSS_SELECTOR, "[data-testid='password']") + SUBMIT = (By.CSS_SELECTOR, "[data-testid='login-submit']") + ERROR = (By.CSS_SELECTOR, "[data-testid='login-error']") + + def load(self) -> None: + self.open(self.PATH) + self.visible(self.HEADING) + + def sign_in(self, email: str, password: str) -> None: + self.fill(self.EMAIL, email) + self.fill(self.PASSWORD, password) + self.click(self.SUBMIT) + + @property + def error_message(self) -> str: + return self.text(self.ERROR) diff --git a/test_data/__init__.py b/test_data/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/test_data/testData.py b/test_data/testData.py deleted file mode 100644 index 821fdd6..0000000 --- a/test_data/testData.py +++ /dev/null @@ -1,8 +0,0 @@ -import utilities.read_json as RJ -import os - - -def testData(attribute): - testDataPath = os.path.abspath("./test_data/test_data.json") - testDataJsonFile = RJ.readJson(testDataPath) - return testDataJsonFile[attribute] \ No newline at end of file diff --git a/test_data/test_data.json b/test_data/test_data.json deleted file mode 100644 index 850b032..0000000 --- a/test_data/test_data.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "environment": "https://learn.letskodeit.com/", - "browser": "firefox", - "email": "test@email.com", - "password": "abcabc" -} diff --git a/testrail.cfg b/testrail.cfg deleted file mode 100644 index 49fab6a..0000000 --- a/testrail.cfg +++ /dev/null @@ -1,7 +0,0 @@ -[API] -url = https://[your testrail domain].testrail.io -email = [testrail email] -password = [testrail password] - -[TESTRUN] -project_id = [project id] diff --git a/tests/__init__.py b/tests/__init__.py old mode 100755 new mode 100644 index e69de29..8557641 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Automation test suite.""" diff --git a/tests/api/test_catalog_api.py b/tests/api/test_catalog_api.py new file mode 100644 index 0000000..6d68594 --- /dev/null +++ b/tests/api/test_catalog_api.py @@ -0,0 +1,28 @@ +"""Contract coverage for the demo catalogue API.""" + +import pytest + +from automation_framework.api_client import ApiClient + + +@pytest.mark.api +@pytest.mark.smoke +def test_health_contract(api_client: ApiClient) -> None: + response = api_client.get("/api/health") + + assert response.status_code == 200 + assert response.body == {"status": "ok", "service": "orbit-qa-store"} + assert response.headers["Content-Type"].startswith("application/json") + + +@pytest.mark.api +@pytest.mark.regression +def test_product_catalog_contract(api_client: ApiClient) -> None: + response = api_client.get("/api/products") + products = response.body["products"] + + assert response.status_code == 200 + assert response.body["count"] == len(products) == 3 + assert len({product["id"] for product in products}) == len(products) + assert all(product["price"] > 0 for product in products) + assert {product["currency"] for product in products} == {"USD"} diff --git a/tests/conftest.py b/tests/conftest.py old mode 100755 new mode 100644 index 4a1a10e..76eecf4 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,25 +1,80 @@ +"""Shared pytest configuration, lifecycle fixtures, and failure evidence.""" + +from __future__ import annotations + +import re +from collections.abc import Generator +from pathlib import Path + import pytest -from base.webdriverfactory import WebDriverFactory +from selenium.webdriver.remote.webdriver import WebDriver + +from automation_framework.api_client import ApiClient +from automation_framework.config import SUPPORTED_BROWSERS, FrameworkConfig +from automation_framework.driver_factory import DriverFactory +from tests.support.demo_server import RunningDemoServer, start_demo_server + + +def pytest_addoption(parser: pytest.Parser) -> None: + group = parser.getgroup("orbit automation") + group.addoption("--browser", choices=SUPPORTED_BROWSERS, default="chrome") + group.addoption("--headed", action="store_true", help="Show the local browser window") + group.addoption("--grid-url", help="Remote Selenium Grid endpoint") + group.addoption("--base-url", help="Override the system-under-test URL") + group.addoption("--timeout", type=float, default=10.0, help="Explicit wait timeout") + group.addoption("--artifacts-dir", default="artifacts", help="Failure evidence directory") + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo): + outcome = yield + report = outcome.get_result() + setattr(item, f"report_{report.when}", report) + + +@pytest.fixture(scope="session") +def framework_config(pytestconfig: pytest.Config) -> FrameworkConfig: + return FrameworkConfig.from_pytest(pytestconfig) + + +@pytest.fixture(scope="session") +def demo_server() -> Generator[RunningDemoServer, None, None]: + root = Path(__file__).resolve().parents[1] / "demo_app" + server = start_demo_server(root) + yield server + server.close() + +@pytest.fixture(scope="session") +def base_url(pytestconfig: pytest.Config, request: pytest.FixtureRequest) -> str: + override = pytestconfig.getoption("--base-url") + if override: + return override.rstrip("/") + server = request.getfixturevalue("demo_server") + return server.url -@pytest.fixture() -def setUp(): - print("Running method level setUp") - yield - print("Running method level tearDown") +@pytest.fixture(scope="session") +def api_client(base_url: str) -> ApiClient: + return ApiClient(base_url) -@pytest.fixture(scope="class") -def oneTimeSetUp(request): - print("Running class setUp") - wdf = WebDriverFactory() - driver = wdf.getWebDriverInstance() - if request.cls is not None: - request.cls.driver = driver +@pytest.fixture +def driver( + request: pytest.FixtureRequest, + framework_config: FrameworkConfig, +) -> Generator[WebDriver, None, None]: + web_driver = DriverFactory(framework_config).create() + yield web_driver - yield driver + report = getattr(request.node, "report_call", None) + if report and report.failed: + _save_failure_evidence(web_driver, request.node.nodeid, framework_config.artifacts_dir) + web_driver.quit() - driver.quit() - print("Running class tearDown") +def _save_failure_evidence(driver: WebDriver, node_id: str, artifacts_dir: Path) -> None: + safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", node_id).strip("_") + artifacts_dir.mkdir(parents=True, exist_ok=True) + driver.save_screenshot(str(artifacts_dir / f"{safe_name}.png")) + (artifacts_dir / f"{safe_name}.html").write_text(driver.page_source, encoding="utf-8") diff --git a/tests/data/users.json b/tests/data/users.json new file mode 100644 index 0000000..589c052 --- /dev/null +++ b/tests/data/users.json @@ -0,0 +1,20 @@ +{ + "valid": { + "email": "qa.engineer@example.com", + "password": "quality-first" + }, + "invalid": [ + { + "id": "unknown-user", + "email": "unknown@example.com", + "password": "quality-first", + "message": "We couldn't sign you in with those credentials." + }, + { + "id": "wrong-password", + "email": "qa.engineer@example.com", + "password": "not-the-password", + "message": "We couldn't sign you in with those credentials." + } + ] +} diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000..f5c21cb --- /dev/null +++ b/tests/support/__init__.py @@ -0,0 +1 @@ +"""Test-only support for the deterministic demo system.""" diff --git a/tests/support/demo_server.py b/tests/support/demo_server.py new file mode 100644 index 0000000..d013505 --- /dev/null +++ b/tests/support/demo_server.py @@ -0,0 +1,65 @@ +"""Serve the bundled UI and API on an ephemeral local port.""" + +from __future__ import annotations + +import json +import threading +from dataclasses import dataclass +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlparse + + +class DemoRequestHandler(SimpleHTTPRequestHandler): + """Static file handler with two JSON API contracts.""" + + def log_message(self, _format: str, *_args: object) -> None: + return + + def do_GET(self) -> None: # noqa: N802 + path = urlparse(self.path).path + if path == "/api/health": + self._send_json({"status": "ok", "service": "orbit-qa-store"}) + return + if path == "/api/products": + products_path = Path(self.directory, "products.json") + products = json.loads(products_path.read_text(encoding="utf-8")) + self._send_json({"count": len(products), "products": products}) + return + super().do_GET() + + def _send_json(self, payload: object) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +@dataclass(slots=True) +class RunningDemoServer: + server: ThreadingHTTPServer + thread: threading.Thread + + @property + def url(self) -> str: + host, port = self.server.server_address + return f"http://{host}:{port}" + + def close(self) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + + +def start_demo_server(root: Path) -> RunningDemoServer: + """Start a background server and return its lifecycle handle.""" + + handler = partial(DemoRequestHandler, directory=str(root)) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + server.daemon_threads = True + thread = threading.Thread(target=server.serve_forever, name="orbit-demo-server", daemon=True) + thread.start() + return RunningDemoServer(server=server, thread=thread) diff --git a/tests/test_login.py b/tests/test_login.py deleted file mode 100644 index e3fce97..0000000 --- a/tests/test_login.py +++ /dev/null @@ -1,36 +0,0 @@ -from utilities.teststatus import TestStatus -from navigation.homeNavigation import HomeNavigation -from pages.login_page import LoginPage -from pages.loggedin_page import LoggedInPage -import test_data.testData as td -import unittest -import pytest -import sys -import allure -from pytest_testrail.plugin import pytestrail - -sys.path.insert(0, '../..') - - -@pytest.mark.usefixtures("oneTimeSetUp", "setUp") -class LoginTest(unittest.TestCase): - - @pytest.fixture(autouse=True) - def classSetup(self): - self.homeNavigation = HomeNavigation(self.driver) - self.loginPage = LoginPage(self.driver) - self.loggedInPage = LoggedInPage(self.driver) - self.ts = TestStatus(self.driver) - - # @pytest.mark.run(order=1) - @allure.story('epic_1') # epic/story of the test case - @allure.severity(allure.severity_level.MINOR) # severity of the test case - # @pytestrail.case('C48') # test case if on TestRail - def test_login_successfully(self): - with allure.step('Navigate to login page'): - self.homeNavigation.goToLoginPage() - self.ts.markFinal(self.loginPage.isAt, "navigation to login page failed") - - with allure.step('Login'): - self.loginPage.login(email=td.testData("email"), password=td.testData("password")) - self.ts.markFinal(self.loggedInPage.isAt, "login failed") diff --git a/tests/test_suite_demo.py b/tests/test_suite_demo.py deleted file mode 100755 index 7513564..0000000 --- a/tests/test_suite_demo.py +++ /dev/null @@ -1,12 +0,0 @@ -import unittest -from tests.test_login import LoginTest - -# Get all tests from the test classes -tc1 = unittest.TestLoader().loadTestsFromTestCase(LoginTest) - -# Create a test suite combining all test classes -smokeTest = unittest.TestSuite([tc1]) - -unittest.TextTestRunner(verbosity=2).run(smokeTest) - - diff --git a/tests/ui/test_checkout.py b/tests/ui/test_checkout.py new file mode 100644 index 0000000..0149492 --- /dev/null +++ b/tests/ui/test_checkout.py @@ -0,0 +1,44 @@ +"""Critical end-to-end purchase journey.""" + +from pathlib import Path + +import pytest +from selenium.webdriver.remote.webdriver import WebDriver + +from automation_framework.config import FrameworkConfig +from automation_framework.data import load_json +from automation_framework.pages import CartPage, CheckoutPage, InventoryPage, LoginPage + +USERS = load_json(Path(__file__).parents[1] / "data" / "users.json") + + +@pytest.mark.ui +@pytest.mark.smoke +def test_user_can_complete_checkout( + driver: WebDriver, + base_url: str, + framework_config: FrameworkConfig, +) -> None: + login = LoginPage(driver, base_url, framework_config.timeout) + inventory = InventoryPage(driver, base_url, framework_config.timeout) + cart = CartPage(driver, base_url, framework_config.timeout) + checkout = CheckoutPage(driver, base_url, framework_config.timeout) + + login.load() + login.sign_in(**USERS["valid"]) + inventory.wait_until_loaded() + inventory.add_product("Grid Compass") + inventory.add_product("Trace Vault") + + assert inventory.cart_count == 2 + + inventory.open_cart() + cart.wait_until_loaded() + assert cart.item_names == ["Grid Compass", "Trace Vault"] + + cart.begin_checkout() + checkout.wait_until_loaded() + checkout.complete_order("Ada", "Lovelace", "QA1 2ST") + + assert checkout.success_heading == "Order confirmed" + assert checkout.order_number == "QA-2026-001" diff --git a/tests/ui/test_inventory.py b/tests/ui/test_inventory.py new file mode 100644 index 0000000..9239c56 --- /dev/null +++ b/tests/ui/test_inventory.py @@ -0,0 +1,30 @@ +"""Focused catalogue behaviour coverage.""" + +from pathlib import Path + +import pytest +from selenium.webdriver.remote.webdriver import WebDriver + +from automation_framework.config import FrameworkConfig +from automation_framework.data import load_json +from automation_framework.pages import InventoryPage, LoginPage + +USERS = load_json(Path(__file__).parents[1] / "data" / "users.json") + + +@pytest.mark.ui +@pytest.mark.regression +def test_product_search_filters_the_catalogue( + driver: WebDriver, + base_url: str, + framework_config: FrameworkConfig, +) -> None: + login = LoginPage(driver, base_url, framework_config.timeout) + inventory = InventoryPage(driver, base_url, framework_config.timeout) + + login.load() + login.sign_in(**USERS["valid"]) + inventory.wait_until_loaded() + inventory.search("selector") + + assert inventory.visible_product_names == ["Selector Studio"] diff --git a/tests/ui/test_login.py b/tests/ui/test_login.py new file mode 100644 index 0000000..621ef2f --- /dev/null +++ b/tests/ui/test_login.py @@ -0,0 +1,29 @@ +"""Data-driven authentication coverage.""" + +from pathlib import Path + +import pytest +from selenium.webdriver.remote.webdriver import WebDriver + +from automation_framework.config import FrameworkConfig +from automation_framework.data import load_cases +from automation_framework.pages import LoginPage + +CASES = load_cases(Path(__file__).parents[1] / "data" / "users.json", "invalid") + + +@pytest.mark.ui +@pytest.mark.regression +@pytest.mark.parametrize("case", CASES, ids=[case["id"] for case in CASES]) +def test_invalid_login_explains_the_failure( + driver: WebDriver, + base_url: str, + framework_config: FrameworkConfig, + case: dict[str, str], +) -> None: + login = LoginPage(driver, base_url, framework_config.timeout) + + login.load() + login.sign_in(case["email"], case["password"]) + + assert login.error_message == case["message"] diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..987f7cf --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,52 @@ +"""Fast validation of framework runtime settings.""" + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from automation_framework.config import FrameworkConfig + + +@pytest.mark.unit +def test_config_normalises_browser_and_artifact_path() -> None: + config = FrameworkConfig(browser="FIREFOX", artifacts_dir="test-output", timeout=2.5) + + assert config.browser == "firefox" + assert config.artifacts_dir == Path("test-output") + assert config.timeout == 2.5 + + +@pytest.mark.unit +def test_config_reads_registered_pytest_options() -> None: + values = { + "--browser": "firefox", + "--headed": True, + "--grid-url": "http://grid.example/wd/hub", + "--timeout": 7.5, + "--artifacts-dir": "evidence", + } + pytest_config = SimpleNamespace(getoption=values.__getitem__) + + config = FrameworkConfig.from_pytest(pytest_config) + + assert config == FrameworkConfig( + browser="firefox", + headless=False, + grid_url="http://grid.example/wd/hub", + timeout=7.5, + artifacts_dir=Path("evidence"), + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"browser": "safari"}, "Unsupported browser"), + ({"timeout": 0}, "greater than zero"), + ], +) +def test_config_rejects_invalid_values(overrides: dict, message: str) -> None: + with pytest.raises(ValueError, match=message): + FrameworkConfig(**overrides) diff --git a/tests/unit/test_data.py b/tests/unit/test_data.py new file mode 100644 index 0000000..e57efee --- /dev/null +++ b/tests/unit/test_data.py @@ -0,0 +1,34 @@ +"""Tests for explicit errors in data-driven test inputs.""" + +from pathlib import Path + +import pytest + +from automation_framework.data import load_cases, load_json + + +@pytest.mark.unit +def test_load_cases_returns_named_case_list(tmp_path: Path) -> None: + source = tmp_path / "cases.json" + source.write_text('{"invalid": [{"id": "one"}]}', encoding="utf-8") + + assert load_cases(source, "invalid") == [{"id": "one"}] + + +@pytest.mark.unit +def test_load_json_explains_invalid_input(tmp_path: Path) -> None: + source = tmp_path / "broken.json" + source.write_text("{not-json", encoding="utf-8") + + with pytest.raises(ValueError, match=r"Invalid JSON in .*broken.json"): + load_json(source) + + +@pytest.mark.unit +@pytest.mark.parametrize("payload", ['{"cases": {}}', '{"cases": ["wrong"]}']) +def test_load_cases_requires_a_list_of_objects(tmp_path: Path, payload: str) -> None: + source = tmp_path / "cases.json" + source.write_text(payload, encoding="utf-8") + + with pytest.raises(ValueError, match="cases"): + load_cases(source, "cases") diff --git a/tests/unit/test_driver_factory.py b/tests/unit/test_driver_factory.py new file mode 100644 index 0000000..7234f53 --- /dev/null +++ b/tests/unit/test_driver_factory.py @@ -0,0 +1,73 @@ +"""Driver construction tests that do not start a real browser.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest +from selenium import webdriver + +from automation_framework.config import FrameworkConfig +from automation_framework.driver_factory import DriverFactory + + +@dataclass +class FakeDriver: + window_size: tuple[int, int] | None = None + page_load_timeout: int | None = None + + def set_window_size(self, width: int, height: int) -> None: + self.window_size = (width, height) + + def set_page_load_timeout(self, timeout: int) -> None: + self.page_load_timeout = timeout + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("browser", "constructor_name", "headless_argument"), + [ + ("chrome", "Chrome", "--headless=new"), + ("firefox", "Firefox", "-headless"), + ], +) +def test_local_driver_uses_browser_options( + monkeypatch: pytest.MonkeyPatch, + browser: str, + constructor_name: str, + headless_argument: str, +) -> None: + captured = {} + fake_driver = FakeDriver() + + def create_driver(*, options): + captured["options"] = options + return fake_driver + + monkeypatch.setattr(webdriver, constructor_name, create_driver) + + driver = DriverFactory(FrameworkConfig(browser=browser)).create() + + assert driver is fake_driver + assert headless_argument in captured["options"].arguments + assert fake_driver.window_size == (1440, 1000) + assert fake_driver.page_load_timeout == 30 + + +@pytest.mark.unit +def test_remote_driver_receives_grid_url_and_options(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {} + fake_driver = FakeDriver() + + def create_remote_driver(*, command_executor, options): + captured.update(command_executor=command_executor, options=options) + return fake_driver + + monkeypatch.setattr(webdriver, "Remote", create_remote_driver) + config = FrameworkConfig(browser="chrome", grid_url="http://grid.example/wd/hub") + + driver = DriverFactory(config).create() + + assert driver is fake_driver + assert captured["command_executor"] == config.grid_url + assert captured["options"].capabilities["acceptInsecureCerts"] is True diff --git a/utilities/__init__.py b/utilities/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/utilities/custom_logger.py b/utilities/custom_logger.py deleted file mode 100755 index 5cb1921..0000000 --- a/utilities/custom_logger.py +++ /dev/null @@ -1,22 +0,0 @@ -import inspect -import logging - - -def customLogger(logLevel=logging.DEBUG): - - # Gets the name of the class / method from where this method is called - loggerName = inspect.stack()[1][3] - logger = logging.getLogger(loggerName) - # By default, log all messages - logger.setLevel(logging.DEBUG) - - fileHandler = logging.FileHandler("automation.log", mode='a') - fileHandler.setLevel(logLevel) - - formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s: %(message)s', - datefmt='%m/%d/%Y %I:%M:%S %p') - - fileHandler.setFormatter(formatter) - logger.addHandler(fileHandler) - - return logger diff --git a/utilities/locators_read.py b/utilities/locators_read.py deleted file mode 100755 index 6be1673..0000000 --- a/utilities/locators_read.py +++ /dev/null @@ -1,10 +0,0 @@ -import json - - -def readJson(jsonFilePath): - with open(jsonFilePath) as f: - jsonFile = json.load(f) - - return jsonFile - - diff --git a/utilities/read_excel.py b/utilities/read_excel.py deleted file mode 100755 index 6c49cf9..0000000 --- a/utilities/read_excel.py +++ /dev/null @@ -1,13 +0,0 @@ -from openpyxl import load_workbook - - -class ReadFromExcel(): - - def __init__(self, fileName, sheetName): - self.workbook = load_workbook(fileName) - self.worksheet = self.workbook[sheetName] - - def readFromExcel(self, column, row): - cell = "{}{}".format(column, row) - cellValue = self.worksheet[cell].value - return cellValue diff --git a/utilities/read_json.py b/utilities/read_json.py deleted file mode 100755 index e5e2a99..0000000 --- a/utilities/read_json.py +++ /dev/null @@ -1,8 +0,0 @@ -import json - - -def readJson(jsonFilePath): - with open(jsonFilePath) as f: - jsonFile = json.load(f) - - return jsonFile \ No newline at end of file diff --git a/utilities/teststatus.py b/utilities/teststatus.py deleted file mode 100755 index 3d4d868..0000000 --- a/utilities/teststatus.py +++ /dev/null @@ -1,58 +0,0 @@ -from base.selenium_driver import SeleniumDriver -import utilities.custom_logger as cl -import logging -from traceback import print_stack - - -class TestStatus(SeleniumDriver): - - log = cl.customLogger(logging.DEBUG) - - def __init__(self, driver): - """ - Inits CheckPoint class - - """ - super(TestStatus, self).__init__(driver) - self.resultList = [] - - def setResult(self, result, resultMessage): - try: - if result is not None: - if result: - self.resultList.append("PASS") - self.log.info("### VERIFICATION SUCCESSFUL :: " + resultMessage) - else: - self.resultList.append("FAIL") - self.log.error("### VERIFICATION FAILED :: " + resultMessage) - self.screenShot(resultMessage) - else: - self.resultList.append("FAIL") - self.log.error("### VERIFICATION FAILED :: " + resultMessage) - self.screenShot(resultMessage) - except: - self.resultList.append("FAIL") - self.log.error("### Exception Occurred !!!") - self.screenShot(resultMessage) - print_stack() - - def mark(self, result, resultMessage): - """ - Mark the result of the verification point in a test case - """ - self.setResult(result, resultMessage) - - def markFinal(self, result, resultMessage): - """ - Mark the final result of the verification point in a test case - This needs to be called at least once in a test case - This should be final test status of the test case - """ - self.setResult(result, resultMessage) - if "FAIL" in self.resultList: - self.resultList.clear() - assert False, resultMessage - - else: - self.resultList.clear() - assert True diff --git a/utilities/util.py b/utilities/util.py deleted file mode 100755 index ea9cbb7..0000000 --- a/utilities/util.py +++ /dev/null @@ -1,117 +0,0 @@ -import time -import traceback -import random -import string -import utilities.custom_logger as cl -import logging - - -class Util(object): - - log = cl.customLogger(logging.DEBUG) - - def sleep(self, sec, info=""): - """ - Put the program to wait for the specified amount of time - """ - if info is not None: - self.log.info("Wait :: " + str(sec) + " seconds for " + info) - try: - time.sleep(sec) - except: - traceback.print_stack() - - def getAlphaNumeric(self, length, type='letters'): - """ - Get random string of characters - :param length: Length of string, number of characters string should have - :param type: Type of character string should have. Default is letters - Provide lower/upper/digits for different types - """ - alpha_num = '' - if type == 'lower': - case = string.ascii_lowercase - elif type == 'upper': - case = string.ascii_uppercase - elif type == 'digits': - case = string.digits - elif type == 'mix': - case = string.ascii_letters + string.digits - else: - case = string.ascii_letters - return alpha_num.join(random.choice(case) for i in range(length)) - - def getUniqueName(self, charCount=10): - """ - Get a unique name - """ - return self.getAlphaNumeric(charCount, 'lower') - - def getUniqueNameList(self, listSize=5, itemLength=None): - """ - Get a list of valid names - - :param listSize: Number of names. Default is 5 names in a list - :param itemLength: It should be a list containing number of items equal to the list size - This determines the length of each item in the list -> [1, 2, 3, 4, 5] - """ - nameList = [] - for i in range(0, listSize): - nameList.append(self.getUniqueName(itemLength[i])) - return nameList - - def verifyListMatch(self, expectedList, actualList): - """ - Verify two list matches - - :param expectedList: Expected List - :param actualList: Actual List - """ - return set(expectedList) == set(actualList) - - def verifyListContains(self, expectedList, actualList): - """ - Verify actual list contains elements of expected list - - :param expectedList: Expected List - :param actualList: Actual List - """ - length = len(expectedList) - for i in range(0, length): - if expectedList[i] not in actualList: - return False - else: - return True - - def verifyTextContains(self, actualText, expectedText): - """ - verify actual text contains expected text string - - :param actualText: Actual Text - :param expectedText: Expected Text - """ - self.log.info("Actual Text From Application Web UI --> :: " + actualText) - self.log.info("Expected Text From Application Web UI --> :: " + expectedText) - - if expectedText.lower() in actualText.lower(): - self.log.info("### VERIFICATIONS CONTAINS !!!") - return True - else: - self.log.error("### VERIFICATIONS DOES NOT CONTAINS !!!") - - def verifyTextMatch(self, actualText, expectedText): - """ - verify text match - - :param actualText: Actual Text - :param expectedText: Expected Text - """ - self.log.info("Actual Text From Application Web UI --> :: " + actualText) - self.log.info("Expected Text From Application Web UI --> :: " + expectedText) - if expectedText.lower() == actualText.lower(): - self.log.info("### VERIFICATIONS MATCHED !!!") - return True - else: - self.log.error("### VERIFICATIONS DOES NOT MATCHED !!!") - -