Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/test-automation.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions features/api/api_client.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion features/api_tests.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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 <delay> seconds
Then the response status code should be 200
Expand Down
94 changes: 88 additions & 6 deletions features/environment.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -80,4 +125,41 @@ def after_all(context):
os.rename(
'test_reports/report.html',
f'test_reports/report_{timestamp}.html'
)
)

# 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)}")
6 changes: 3 additions & 3 deletions features/environment_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions features/steps/api_steps.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 0 additions & 6 deletions features/steps/checkout_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 0 additions & 2 deletions features/steps/inventory_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
45 changes: 34 additions & 11 deletions page_objects/base_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.")
4 changes: 4 additions & 0 deletions page_objects/cart_page.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
4 changes: 4 additions & 0 deletions page_objects/login_page.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 6 additions & 2 deletions utilities/driver_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
raise ValueError(f"Browser {browser_name} is not supported")