diff --git a/.github/workflows/test-automation.yml b/.github/workflows/test-automation.yml new file mode 100644 index 0000000..f755655 --- /dev/null +++ b/.github/workflows/test-automation.yml @@ -0,0 +1,64 @@ +name: Test Automation + +on: + push: + branches: [ main, master, deployment/pipeline ] + pull_request: + branches: [ main, master ] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set Up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Dependencies + run: | + python -m venv .venv + source .venv/bin/activate + pip install --upgrade pip + pip install -r requirements.txt + + - name: Run Behave Tests + run: | + source .venv/bin/activate + behave || true # Prevent pipeline failure on test failures + + - name: Install Allure + run: | + curl -sLo allure-2.24.0.tgz https://github.com/allure-framework/allure2/releases/download/2.24.0/allure-2.24.0.tgz + tar -zxvf allure-2.24.0.tgz + sudo mv allure-2.24.0 /opt/allure + sudo ln -s /opt/allure/bin/allure /usr/local/bin/allure + + - name: Load test report history + uses: actions/checkout@v3 + if: always() + continue-on-error: true + with: + ref: gh-pages + path: gh-pages + + - name: Build test report + uses: simple-elf/allure-report-action@v1.7 + if: always() + with: + gh_pages: gh-pages + allure_history: allure-history + allure_results: reports/allure-report + + - name: Publish test report + uses: peaceiris/actions-gh-pages@v3 + if: always() + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: allure-history diff --git a/features/api/api_client.py b/features/api/api_client.py index c3a5573..d860dc5 100644 --- a/features/api/api_client.py +++ b/features/api/api_client.py @@ -1,3 +1,7 @@ +# © 2025 Serhii Suzanskyi +# Open-source and awesome! Use it, modify it, share it—just don’t break it. +# See LICENSE for details. + import requests import json import os diff --git a/features/api_tests.feature b/features/api_tests.feature index e54db31..a40c45f 100644 --- a/features/api_tests.feature +++ b/features/api_tests.feature @@ -76,7 +76,7 @@ Feature: ReqRes API Users Endpoint And the response should contain "error" field #last AC I'm already tired but still enthusiastic - @delayed + @delayed Scenario Outline: Get users list with different delay times When I send GET request to "users" with delay of seconds Then the response status code should be 200 diff --git a/features/environment.py b/features/environment.py index d3ee506..50d154a 100644 --- a/features/environment.py +++ b/features/environment.py @@ -1,14 +1,28 @@ +import os +import shutil from datetime import datetime +import allure +from allure_commons.types import AttachmentType +from behave.model_core import Status from behave.parser import parse_file +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.chrome.service import Service -from utilities.driver_factory import DriverFactory -from utilities.api_client import APIClient from config.users import Users -from behave.model import Scenario -from behave.model import Table -import os +from utilities.api_client import APIClient +from utilities.driver_factory import DriverFactory + +def create_chrome_options(): + chrome_options = Options() + # Add required options + chrome_options.add_argument('--no-sandbox') + chrome_options.add_argument('--headless') # Run in headless mode for CI + chrome_options.add_argument('--disable-dev-shm-usage') + chrome_options.add_argument('--disable-gpu') + return chrome_options def before_all(context): # Load configuration @@ -39,6 +53,14 @@ def before_all(context): with open(feature_path, "w", encoding="utf-8") as file: file.write(updated_content) + # Create reports and screenshots directories + for dir_path in ['test_reports', 'test_reports/screenshots']: + if not os.path.exists(dir_path): + os.makedirs(dir_path) + + # Store the user data directory path for cleanup + context.user_data_dir = None + def before_scenario(context, scenario): # Initialize WebDriver only for scenarios tagged with @ui @@ -72,6 +94,29 @@ def before_feature(context, feature): parsed_feature = parse_file(feature_file_path) feature.__dict__.update(parsed_feature.__dict__) # Force update in place + if 'api' in feature.tags: + # Skip browser setup for API features + return + + # Set up Chrome options and user data directory + chrome_options = create_chrome_options() + + # Initialize the WebDriver + service = Service() + context.driver = webdriver.Chrome(service=service, options=chrome_options) + context.driver.implicitly_wait(10) + + +def after_feature(context, feature): + if hasattr(context, 'driver'): + context.driver.quit() + + # Clean up the user data directory + if context.user_data_dir and os.path.exists(context.user_data_dir): + try: + shutil.rmtree(context.user_data_dir) + except Exception as e: + print(f"Failed to remove user data directory: {e}") def after_all(context): # Optionally rename the report with timestamp @@ -80,4 +125,41 @@ def after_all(context): os.rename( 'test_reports/report.html', f'test_reports/report_{timestamp}.html' - ) \ No newline at end of file + ) + + # Clean up any remaining user data directories + if hasattr(context, 'user_data_dir') and context.user_data_dir: + try: + shutil.rmtree(context.user_data_dir) + except Exception as e: + print(f"Failed to remove user data directory: {e}") + +def after_step(context, step): + if step.status == Status.failed and hasattr(context, 'driver'): + # Get scenario and step names + scenario_name = ''.join(e for e in context.scenario.name if e.isalnum() or e == '_') + step_name = ''.join(e for e in step.name if e.isalnum() or e == '_') + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + + try: + # Take screenshot + screenshot_name = f"{scenario_name}_{step_name}_{timestamp}.png" + screenshot_path = os.path.join('test_reports/screenshots', screenshot_name) + context.driver.save_screenshot(screenshot_path) + + # Attach screenshot to Allure report + allure.attach( + context.driver.get_screenshot_as_png(), + name="Screenshot", + attachment_type=AttachmentType.PNG + ) + + # Attach page source to Allure report + allure.attach( + context.driver.page_source, + name="Page Source", + attachment_type=AttachmentType.HTML + ) + + except Exception as e: + print(f"Failed to take screenshot: {str(e)}") \ No newline at end of file diff --git a/features/environment_base.py b/features/environment_base.py index 73c9bd3..947ceac 100644 --- a/features/environment_base.py +++ b/features/environment_base.py @@ -2,13 +2,13 @@ # Open-source and awesome! Use it, modify it, share it—just don’t break it. # See LICENSE for details. -from selenium.webdriver.support.wait import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC -from selenium.common.exceptions import TimeoutException import logging import os from datetime import datetime +from selenium.webdriver.support.wait import WebDriverWait + + class TestBase: def __init__(self, driver): self.driver = driver diff --git a/features/steps/api_steps.py b/features/steps/api_steps.py index 558495b..8de90d7 100644 --- a/features/steps/api_steps.py +++ b/features/steps/api_steps.py @@ -1,3 +1,7 @@ +# © 2025 Serhii Suzanskyi +# Open-source and awesome! Use it, modify it, share it—just don’t break it. +# See LICENSE for details. + from behave import when, then from features.api.api_client import ApiClient import json diff --git a/features/steps/checkout_steps.py b/features/steps/checkout_steps.py index c0f6483..b298161 100644 --- a/features/steps/checkout_steps.py +++ b/features/steps/checkout_steps.py @@ -25,12 +25,6 @@ def step_impl(context): @then('the order summary should show') def step_impl(context): - """ - Verify order summary from table: - | Item total | $39.98 | - | Tax | $3.20 | - | Total | $43.18 | - """ expected_values = {row[0]: Decimal(row[1].replace('$', '')) for row in context.table} actual_values = { diff --git a/features/steps/inventory_steps.py b/features/steps/inventory_steps.py index e84717e..9dbe569 100644 --- a/features/steps/inventory_steps.py +++ b/features/steps/inventory_steps.py @@ -17,7 +17,6 @@ def step_impl(context): assert context.inventory_page.is_on_inventory_page(), \ f"Expected to be on the Inventory page, but currently on: {context.driver.current_url}" - @then('I should see {count:d} products listed') def step_impl(context, count): actual_count = context.inventory_page.get_product_count() @@ -60,7 +59,6 @@ def step_impl(context, product_name): context.inventory_page.add_to_cart(product_data["id"]) - @then('the cart badge should show "{count}"') def step_impl(context, count): actual_count = context.inventory_page.get_cart_badge_count() diff --git a/page_objects/base_page.py b/page_objects/base_page.py index 8175ab5..ab57309 100644 --- a/page_objects/base_page.py +++ b/page_objects/base_page.py @@ -4,6 +4,7 @@ from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC +from selenium.common.exceptions import TimeoutException class BasePage: def __init__(self, driver): @@ -12,23 +13,45 @@ def __init__(self, driver): def wait_for_page_load(self): """Waits until the document is fully loaded (ready state = 'complete').""" - self.wait.until(lambda d: d.execute_script("return document.readyState") == "complete") + try: + self.wait.until(lambda d: d.execute_script("return document.readyState") == "complete") + except TimeoutException: + raise AssertionError("❌ Page did not load properly! Possible product defect.") def find_element(self, locator): - return self.wait.until(EC.presence_of_element_located(locator)) + """Finds a single element and asserts that it exists.""" + try: + element = self.wait.until(EC.presence_of_element_located(locator)) + assert element is not None, f"❌ Element {locator} not found! Possible product defect." + return element + except TimeoutException: + raise AssertionError(f"❌ Element {locator} NOT found within time! Possible product defect.") def find_elements(self, locator): - return self.wait.until(EC.presence_of_all_elements_located(locator)) + """Finds multiple elements and asserts that at least one exists.""" + try: + elements = self.wait.until(EC.presence_of_all_elements_located(locator)) + assert len(elements) > 0, f"❌ No elements found for {locator}! Possible product defect." + return elements + except TimeoutException: + raise AssertionError(f"❌ Elements {locator} NOT found within time! Possible product defect.") def click(self, locator): + """Waits for an element to be clickable before clicking.""" self.wait_for_page_load() - element = self.wait.until(EC.presence_of_element_located(locator)) - self.driver.execute_script("arguments[0].click();", element) + try: + element = self.wait.until(EC.element_to_be_clickable(locator)) + assert element.is_displayed() and element.is_enabled(), f"❌ Element {locator} is NOT clickable! Possible product defect." + self.driver.execute_script("arguments[0].click();", element) + except TimeoutException: + raise AssertionError(f"❌ Element {locator} is NOT clickable within time! Possible product defect.") def input_text(self, locator, text): - element = self.wait.until(EC.presence_of_element_located(locator)) - element.clear() - element.send_keys(text) - - - + """Waits for an element to be visible and editable before sending text.""" + try: + element = self.wait.until(EC.presence_of_element_located(locator)) + assert element.is_displayed() and element.is_enabled(), f"❌ Element {locator} is NOT editable! Possible product defect." + element.clear() + element.send_keys(text) + except TimeoutException: + raise AssertionError(f"❌ Unable to input text into {locator}! Possible product defect.") diff --git a/page_objects/cart_page.py b/page_objects/cart_page.py index dbc872c..37e873e 100644 --- a/page_objects/cart_page.py +++ b/page_objects/cart_page.py @@ -1,3 +1,7 @@ +# © 2025 Serhii Suzanskyi +# Open-source and awesome! Use it, modify it, share it—just don’t break it. +# See LICENSE for details. + from selenium.webdriver.common.by import By from .base_page import BasePage diff --git a/page_objects/login_page.py b/page_objects/login_page.py index 02e1fb8..8531d4a 100644 --- a/page_objects/login_page.py +++ b/page_objects/login_page.py @@ -1,3 +1,7 @@ +# © 2025 Serhii Suzanskyi +# Open-source and awesome! Use it, modify it, share it—just don’t break it. +# See LICENSE for details. + from selenium.webdriver.common.by import By from .base_page import BasePage diff --git a/utilities/driver_factory.py b/utilities/driver_factory.py index 52dfb6a..0600be8 100644 --- a/utilities/driver_factory.py +++ b/utilities/driver_factory.py @@ -7,22 +7,26 @@ from webdriver_manager.chrome import ChromeDriverManager from webdriver_manager.firefox import GeckoDriverManager + class DriverFactory: @staticmethod def get_driver(browser_name): if browser_name.lower() == "chrome": options = webdriver.ChromeOptions() - options.add_argument("--start-maximized") # Open in maximized mode + options.add_argument("--headless=new") # Run in headless mode options.add_argument("--disable-gpu") # Disable GPU (fixes some rendering issues) options.add_argument("--no-sandbox") # Bypass OS security model (for Docker/Linux) options.add_argument("--disable-dev-shm-usage") # Prevent crashes in Docker/Linux + options.add_argument("--remote-debugging-port=9222") # Avoid session issues + options.add_argument("--user-data-dir=/tmp/chrome-user-data") # Unique session directory options.add_experimental_option("excludeSwitches", ["enable-automation"]) # Avoid detection options.add_experimental_option("useAutomationExtension", False) # Disable automation extension return webdriver.Chrome( service=Service(ChromeDriverManager().install()), options=options ) + elif browser_name.lower() == "firefox": return webdriver.Firefox(service=Service(GeckoDriverManager().install())) else: - raise ValueError(f"Browser {browser_name} is not supported") \ No newline at end of file + raise ValueError(f"Browser {browser_name} is not supported")