# Selenium Python Page Object Model Template - Hướng Dẫn Đầy Đủ

## 📁 Cấu Trúc Project

```
selenium-pom-template/
│
├── 📄 README.md                    # Hướng dẫn cài đặt và sử dụng
├── 📄 requirements.txt             # Danh sách thư viện cần cài
├── 📄 pytest.ini                   # Cấu hình pytest
├── 📄 conftest.py                  # Fixtures dùng chung cho toàn project
│
├── 📁 pages/                       # Chứa các Page Object classes
│   ├── 📄 __init__.py
│   ├── 📄 base_page.py             # Class cha với các method chung
│   └── 📄 login_page.py            # Page Object cho trang đăng nhập
│
├── 📁 tests/                       # Chứa các test cases
│   ├── 📄 __init__.py
│   └── 📄 test_login.py            # Test cases cho chức năng đăng nhập
│
├── 📁 utils/                       # Chứa các hàm tiện ích
│   ├── 📄 __init__.py
│   ├── 📄 helpers.py               # Các hàm helper (screenshot, etc.)
│   └── 📄 config.py                # Cấu hình URL, timeout, credentials
│
├── 📁 screenshots/                 # Lưu screenshot khi test fail
│   └── 📄 .gitkeep
│
├── 📁 reports/                     # Lưu báo cáo test
│   └── 📄 .gitkeep
│
└── 📁 test_data/                   # Dữ liệu test
    └── 📄 test_users.json          # Dữ liệu users cho test
```

---

## 📄 README.md

```markdown
# 🧪 Selenium Python - Page Object Model Template

Template automation testing với Selenium Python theo mô hình Page Object Model (POM).
Được thiết kế cho tester mới bắt đầu học automation testing.

## 📋 Yêu Cầu Hệ Thống

- Python 3.8 trở lên
- Google Chrome (phiên bản mới nhất)
- pip (công cụ quản lý package của Python)

## 🚀 Hướng Dẫn Cài Đặt

### Bước 1: Clone hoặc tải project về máy
```bash
git clone <repository-url>
cd selenium-pom-template
```

### Bước 2: Tạo môi trường ảo (Virtual Environment)
Tại sao cần virtual environment? Để tránh xung đột giữa các dự án khác nhau.

```bash
# Windows
python -m venv venv
venv\Scripts\activate

# macOS/Linux
python3 -m venv venv
source venv/bin/activate
```

### Bước 3: Cài đặt các thư viện cần thiết
```bash
pip install -r requirements.txt
```

### Bước 4: Cấu hình URL và thông tin đăng nhập
Mở file `utils/config.py` và chỉnh sửa các thông tin sau:
- `BASE_URL`: URL của ứng dụng cần test
- `VALID_USERNAME`: Tên đăng nhập hợp lệ
- `VALID_PASSWORD`: Mật khẩu hợp lệ

## ▶️ Chạy Tests

### Chạy tất cả tests
```bash
pytest
```

### Chạy một file test cụ thể
```bash
pytest tests/test_login.py
```

### Chạy một test case cụ thể
```bash
pytest tests/test_login.py::TestLogin::test_login_success
```

### Chạy với báo cáo HTML
```bash
pytest --html=reports/report.html --self-contained-html
```

### Chạy với thông tin chi tiết
```bash
pytest -v
```

### Chạy ở chế độ headless (không mở browser)
```bash
pytest --headless
```

## 📊 Xem Báo Cáo
Sau khi chạy tests với `--html`, mở file `reports/report.html` bằng trình duyệt.

## 🗂️ Giải Thích Cấu Trúc

| Thư mục/File | Mục đích |
|---|---|
| `pages/` | Chứa các class đại diện cho từng trang web |
| `tests/` | Chứa các test cases |
| `utils/` | Chứa các hàm tiện ích dùng chung |
| `conftest.py` | Cấu hình fixtures cho pytest |
| `screenshots/` | Tự động lưu screenshot khi test thất bại |
| `reports/` | Lưu báo cáo HTML sau khi chạy test |

## 💡 Page Object Model là gì?

POM là một design pattern trong automation testing:
- Mỗi trang web = 1 class Python
- Các element trên trang = attributes của class
- Các hành động trên trang = methods của class

**Lợi ích:**
- Code dễ đọc, dễ hiểu
- Dễ bảo trì khi UI thay đổi (chỉ sửa 1 chỗ)
- Tái sử dụng code hiệu quả

## 🆘 Xử Lý Lỗi Thường Gặp

### Lỗi: ChromeDriver not found
```bash
pip install --upgrade webdriver-manager
```

### Lỗi: Element not found
- Kiểm tra lại selector (ID, CSS, XPath)
- Tăng thời gian chờ trong `config.py`

### Lỗi: Permission denied (macOS/Linux)
```bash
chmod +x venv/bin/activate
```
```

---

## 📄 requirements.txt

```txt
# Selenium - Thư viện chính để điều khiển browser
selenium==4.18.1

# Pytest - Framework để chạy và quản lý test cases
pytest==8.0.2

# Pytest-HTML - Tạo báo cáo test dạng HTML đẹp
pytest-html==4.1.1

# WebDriver Manager - Tự động tải và quản lý ChromeDriver
webdriver-manager==4.0.1

# Allure Pytest - Tạo báo cáo test chuyên nghiệp hơn (tuỳ chọn)
# allure-pytest==2.13.3

# Faker - Tạo dữ liệu test ngẫu nhiên
Faker==24.0.0

# Python-dotenv - Đọc biến môi trường từ file .env (bảo mật)
python-dotenv==1.0.1
```

---

## 📄 pytest.ini

```ini
[pytest]
# Cấu hình cơ bản cho pytest

# Thư mục chứa test files
testpaths = tests

# Pattern để pytest tìm file test (bắt đầu bằng "test_")
python_files = test_*.py

# Pattern để pytest tìm class test (bắt đầu bằng "Test")
python_classes = Test*

# Pattern để pytest tìm hàm test (bắt đầu bằng "test_")
python_functions = test_*

# Thêm các options mặc định khi chạy pytest
# -v: hiển thị tên từng test
# -s: hiển thị print() trong code
# --tb=short: hiển thị traceback ngắn gọn khi lỗi
addopts = -v -s --tb=short

# Đăng ký các markers tùy chỉnh để phân loại tests
markers =
    smoke: Test cơ bản, chạy nhanh để kiểm tra tính năng chính
    regression: Test toàn diện cho tất cả tính năng
    login: Tests liên quan đến chức năng đăng nhập
    negative: Tests với dữ liệu không hợp lệ (test trường hợp lỗi)
```

---

## 📄 conftest.py

```python
# ============================================================
# FILE: conftest.py
# MỤC ĐÍCH: Cấu hình các fixtures dùng chung cho toàn bộ project
#
# conftest.py là file đặc biệt của pytest - nó được tự động
# nhận diện và load khi chạy tests. Các fixtures định nghĩa
# ở đây có thể dùng trong bất kỳ test file nào mà không cần
# phải import.
# ============================================================

import pytest
import os
from datetime import datetime
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from webdriver_manager.chrome import ChromeDriverManager

# Import cấu hình từ file config
from utils.config import Config
from utils.helpers import take_screenshot


def pytest_addoption(parser):
    """
    Hàm này cho phép thêm options tùy chỉnh vào lệnh pytest.
    Ví dụ: pytest --headless để chạy không hiện browser
    """
    parser.addoption(
        "--headless",                           # Tên option
        action="store_true",                    # Là flag (True/False)
        default=False,                          # Mặc định: False (có hiện browser)
        help="Chạy browser ở chế độ headless (ẩn browser)"
    )
    parser.addoption(
        "--browser",
        action="store",
        default="chrome",
        help="Chọn loại browser: chrome, firefox (mặc định: chrome)"
    )


@pytest.fixture(scope="session")
def config(request):
    """
    Fixture cung cấp cấu hình cho toàn bộ test session.

    scope="session" nghĩa là fixture này chỉ chạy 1 lần
    cho toàn bộ session test (dùng chung cho tất cả tests).

    Yields: Config object chứa các cấu hình
    """
    # Lấy giá trị của --headless option từ command line
    headless = request.config.getoption("--headless")
    return Config(headless=headless)


@pytest.fixture(scope="function")
def driver(config):
    """
    Fixture quan trọng nhất: khởi tạo và quản lý WebDriver.

    scope="function" nghĩa là mỗi test function sẽ có
    một browser riêng → đảm bảo các tests độc lập nhau.

    Args:
        config: Fixture config được inject tự động bởi pytest

    Yields:
        driver: WebDriver object để điều khiển browser
    """
    # ── Bước 1: Cấu hình Chrome Options ──────────────────────
    chrome_options = Options()

    if config.headless:
        # Headless mode: chạy Chrome ở chế độ ẩn (không hiện UI)
        # Hữu ích khi chạy trên CI/CD server không có màn hình
        chrome_options.add_argument("--headless=new")  # "--new" mới hơn
        chrome_options.add_argument("--no-sandbox")
        chrome_options.add_argument("--disable-dev-shm-usage")

    # Kích thước cửa sổ browser: full HD để test responsive
    chrome_options.add_argument("--window-size=1920,1080")

    # Tắt thông báo "Chrome is being controlled by automated software"
    chrome_options.add_experimental_option(
        "excludeSwitches", ["enable-automation"]
    )

    # Tắt log không cần thiết để console sạch hơn
    chrome_options.add_argument("--log-level=3")

    # ── Bước 2: Khởi tạo ChromeDriver ────────────────────────
    # ChromeDriverManager().install() tự động tải ChromeDriver
    # phù hợp với phiên bản Chrome đang cài trên máy
    service = Service(ChromeDriverManager().install())
    driver = webdriver.Chrome(service=service, options=chrome_options)

    # ── Bước 3: Cấu hình driver ───────────────────────────────
    # Implicit wait: driver chờ tối đa X giây khi tìm element
    driver.implicitly_wait(Config.IMPLICIT_WAIT)

    # Maximize window để nhìn rõ hơn khi debug
    if not config.headless:
        driver.maximize_window()

    # ── Bước 4: Yield driver cho test sử dụng ─────────────────
    # "yield" giống "return" nhưng code sau yield sẽ chạy
    # sau khi test hoàn thành (dùng để cleanup)
    yield driver

    # ── Bước 5: Cleanup - Đóng browser sau khi test xong ──────
    # Phần này chạy dù test pass hay fail
    driver.quit()
    print("\n✅ Browser đã được đóng sau khi test hoàn thành")


@pytest.fixture(scope="function", autouse=True)
def test_setup_teardown(request, driver):
    """
    Fixture tự động chạy trước và sau MỖI test function.

    autouse=True: pytest tự động áp dụng fixture này
    cho tất cả tests mà không cần khai báo.

    Args:
        request: pytest request object, chứa thông tin về test
        driver: WebDriver fixture
    """
    # ── SETUP: Chạy trước mỗi test ────────────────────────────
    test_name = request.node.name  # Lấy tên hàm test
    print(f"\n{'='*60}")
    print(f"🚀 BẮT ĐẦU TEST: {test_name}")
    print(f"⏰ Thời gian: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"{'='*60}")

    # Chạy test (yield là điểm mà test thực sự chạy)
    yield

    # ── TEARDOWN: Chạy sau mỗi test ───────────────────────────
    # Kiểm tra kết quả test
    if request.node.rep_call.failed if hasattr(request.node, 'rep_call') else False:
        # Test FAIL: Chụp screenshot để debug
        print(f"\n❌ TEST THẤT BẠI: {test_name}")
        screenshot_path = take_screenshot(driver, test_name)
        print(f"📸 Screenshot đã lưu: {screenshot_path}")
    else:
        print(f"\n✅ TEST THÀNH CÔNG: {test_name}")

    print(f"{'='*60}\n")


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    """
    Hook của pytest để lấy kết quả test.

    hookimpl: Đăng ký hook với pytest
    tryfirst: Chạy hook này trước các hooks khác
    hookwrapper: Cho phép chạy code trước và sau hook gốc

    Mục đích: Lưu kết quả test vào request.node để
    fixture test_setup_teardown có thể đọc được.
    """
    outcome = yield  # Chạy test và lấy kết quả

    # Lưu kết quả vào node để fixtures khác có thể truy cập
    rep = outcome.get_result()
    setattr(item, f"rep_{rep.when}", rep)
```

---

## 📄 utils/config.py

```python
# ============================================================
# FILE: utils/config.py
# MỤC ĐÍCH: Lưu trữ tất cả cấu hình của project ở một chỗ
#
# Lý do tập trung cấu hình:
# - Dễ thay đổi khi chuyển môi trường (dev/staging/production)
# - Không hard-code giá trị rải rác trong code
# - Bảo mật hơn (có thể đọc từ .env file)
# ============================================================

import os
from dotenv import load_dotenv

# Load biến môi trường từ file .env (nếu có)
# File .env được dùng để lưu thông tin nhạy cảm như password
# mà không muốn commit lên Git
load_dotenv()


class Config:
    """
    Class chứa tất cả cấu hình của project.

    Cách sử dụng:
        from utils.config import Config
        url = Config.BASE_URL
        timeout = Config.EXPLICIT_WAIT
    """

    # ── URL Cấu Hình ──────────────────────────────────────────
    # Đây là URL của ứng dụng cần test
    # Có thể đọc từ biến môi trường hoặc dùng giá trị mặc định

    # Demo Banking App (thay bằng URL thực của bạn)
    BASE_URL = os.getenv("BASE_URL", "https://demo.guru99.com/V4/")

    # Hoặc dùng với ứng dụng e-commerce demo:
    # BASE_URL = os.getenv("BASE_URL", "https://www.saucedemo.com/")

    # ── Thông Tin Đăng Nhập Cho Tests ─────────────────────────
    # CẢNH BÁO: Trong thực tế, đừng hard-code password!
    # Hãy lưu trong file .env và đọc qua os.getenv()

    # Tài khoản hợp lệ
    VALID_USERNAME = os.getenv("VALID_USERNAME", "mngr123456")
    VALID_PASSWORD = os.getenv("VALID_PASSWORD", "password123")

    # Tài khoản bị khóa (để test locked account)
    LOCKED_USERNAME = os.getenv("LOCKED_USERNAME", "locked_user")
    LOCKED_PASSWORD = os.getenv("LOCKED_PASSWORD", "secret_sauce")

    # ── Timeout Settings ──────────────────────────────────────
    # Thời gian chờ tối đa khi tìm element (giây)
    # Nếu element không xuất hiện sau thời gian này → báo lỗi

    # Implicit Wait: áp dụng cho TẤT CẢ các lần tìm element
    IMPLICIT_WAIT = 10

    # Explicit Wait: áp dụng cho từng trường hợp cụ thể
    EXPLICIT_WAIT = 15

    # Page Load Timeout: thời gian chờ trang load
    PAGE_LOAD_TIMEOUT = 30

    # ── Đường Dẫn File/Folder ─────────────────────────────────
    # Lấy đường dẫn thư mục gốc của project
    # os.path.dirname(__file__) = thư mục chứa config.py (utils/)
    # os.path.dirname(...) một lần nữa = thư mục gốc project
    BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

    # Thư mục lưu screenshots
    SCREENSHOT_DIR = os.path.join(BASE_DIR, "screenshots")

    # Thư mục lưu reports
    REPORT_DIR = os.path.join(BASE_DIR, "reports")

    # ── URL Của Từng Trang ────────────────────────────────────
    LOGIN_URL = f"{BASE_URL}"  # Trang đăng nhập = trang chủ
    DASHBOARD_URL = f"{BASE_URL}index.php"  # Trang sau khi đăng nhập

    # ── Thông Báo Lỗi Mong Đợi ───────────────────────────────
    # Định nghĩa các message lỗi để verify trong tests

    # Thông báo khi đăng nhập sai
    INVALID_CREDENTIAL_MSG = "User or Password is not valid"

    # Thông báo khi để trống username
    EMPTY_USERNAME_MSG = "User-ID must not be blank"

    # Thông báo khi để trống password
    EMPTY_PASSWORD_MSG = "Password must not be blank"

    # Thông báo khi tài khoản bị khóa
    LOCKED_ACCOUNT_MSG = "Your account has been locked"

    def __init__(self, headless=False):
        """
        Constructor để tạo instance Config với các settings động.

        Args:
            headless (bool): Có chạy ở chế độ headless không
        """
        self.headless = headless

    @classmethod
    def get_screenshot_dir(cls):
        """
        Tạo thư mục screenshots nếu chưa tồn tại.

        Returns:
            str: Đường dẫn đến thư mục screenshots
        """
        os.makedirs(cls.SCREENSHOT_DIR, exist_ok=True)
        return cls.SCREENSHOT_DIR
```

---

## 📄 utils/helpers.py

```python
# ============================================================
# FILE: utils/helpers.py
# MỤC ĐÍCH: Chứa các hàm tiện ích dùng chung trong project
#
# Đây là nơi đặt các hàm không thuộc về trang web cụ thể
# nhưng được dùng đi dùng lại trong nhiều chỗ.
# ============================================================

import os
import json
import time
from datetime import datetime
from selenium.webdriver.remote.webdriver import WebDriver

# Import cấu hình
from utils.config import Config


def take_screenshot(driver: WebDriver, test_name: str = "screenshot") -> str:
    """
    Chụp màn hình browser và lưu vào thư mục screenshots.

    Hàm này rất hữu ích khi:
    - Test bị fail → chụp ảnh để biết lỗi xảy ra ở đâu
    - Debug: xem browser đang hiển thị gì

    Args:
        driver (WebDriver): Selenium WebDriver đang chạy
        test_name (str): Tên của test (dùng làm tên file)

    Returns:
        str: Đường dẫn đầy đủ đến file screenshot

    Cách dùng:
        # Trong test file
        path = take_screenshot(driver, "test_login_fail")
        print(f"Screenshot lưu tại: {path}")
    """
    # Bước 1: Đảm bảo thư mục screenshots tồn tại
    screenshot_dir = Config.get_screenshot_dir()

    # Bước 2: Tạo tên file với timestamp để tránh trùng lặp
    # Ví dụ: test_login_fail_2024-01-15_14-30-25.png
    timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")

    # Loại bỏ ký tự đặc biệt trong tên test (không hợp lệ cho tên file)
    # Ví dụ: "test[param]" → "test_param_"
    safe_test_name = "".join(
        c if c.isalnum() or c in ('-', '_') else '_'
        for c in test_name
    )

    file_name = f"{safe_test_name}_{timestamp}.png"
    file_path = os.path.join(screenshot_dir, file_name)

    # Bước 3: Chụp và lưu screenshot
    try:
        driver.save_screenshot(file_path)
        print(f"📸 Screenshot đã lưu: {file_path}")
        return file_path
    except Exception as e:
        # Nếu không chụp được, in lỗi nhưng không crash test
        print(f"⚠️ Không thể chụp screenshot: {str(e)}")
        return ""


def take_element_screenshot(driver: WebDriver, element, file_name: str) -> str:
    """
    Chụp ảnh một element cụ thể (không phải toàn màn hình).

    Hữu ích khi muốn chụp ảnh một form, button, hoặc error message.

    Args:
        driver (WebDriver): Selenium WebDriver
        element: WebElement cần chụp ảnh
        file_name (str): Tên file (không cần đuôi .png)

    Returns:
        str: Đường dẫn đến file screenshot
    """
    screenshot_dir = Config.get_screenshot_dir()
    timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    file_path = os.path.join(screenshot_dir, f"{file_name}_{timestamp}.png")

    try:
        element.screenshot(file_path)
        return file_path
    except Exception as e:
        print(f"⚠️ Không thể chụp element screenshot: {str(e)}")
        return ""


def load_test_data(file_name: str) -> dict:
    """
    Đọc dữ liệu test từ file JSON.

    Tại sao dùng JSON cho test data?
    - Dễ đọc và chỉnh sửa
    - Tách biệt data với code
    - Dễ dàng test nhiều bộ dữ liệu (data-driven testing)

    Args:
        file_name (str): Tên file JSON trong thư mục test_data/
                         Ví dụ: "test_users.json"

    Returns:
        dict: Dữ liệu đã parse từ JSON

    Cách dùng:
        data = load_test_data("test_users.json")
        username = data["valid_user"]["username"]
    """
    # Tìm đường dẫn đến thư mục test_data
    test_data_dir = os.path.join(Config.BASE_DIR, "test_data")
    file_path = os.path.join(test_data_dir, file_name)

    # Kiểm tra file có tồn tại không
    if not os.path.exists(file_path):
        raise FileNotFoundError(
            f"❌ Không tìm thấy file test data: {file_path}\n"
            f"Hãy tạo file tại: {file_path}"
        )

    # Đọc và parse JSON
    with open(file_path, 'r', encoding='utf-8') as f:
        data = json.load(f)

    return data


def format_test_name(test_name: str) -> str:
    """
    Format tên test để hiển thị đẹp hơn trong báo cáo.

    Ví dụ:
        "test_login_with_valid_credentials" → "Login With Valid Credentials"

    Args:
        test_name (str): Tên hàm test

    Returns:
        str: Tên đã được format
    """
    # Xóa prefix "test_" nếu có
    if test_name.startswith("test_"):
        test_name = test_name[5:]

    # Thay "_" bằng dấu cách và viết hoa chữ cái đầu
    return test_name.replace("_", " ").title()


def wait_for_seconds(seconds: float, reason: str = "") -> None:
    """
    Dừng chương trình một khoảng thời gian (hard wait).

    ⚠️ CẢNH BÁO: Hạn chế dùng hàm này!
    Thay vào đó hãy dùng Explicit Wait trong base_page.py
    Hard wait làm test chậm và không ổn định.

    Chỉ dùng khi:
    - Cần đợi animation kết thúc
    - Cần đợi background job hoàn thành
    - Debugging

    Args:
        seconds (float): Số giây cần dừng
        reason (str): Lý do dừng (để log cho dễ hiểu)
    """
    if reason:
        print(f"⏳ Đợi {seconds}s: {reason}")
    time.sleep(seconds)


def get_current_timestamp() -> str:
    """
    Lấy timestamp hiện tại theo định dạng chuẩn.

    Returns:
        str: Timestamp dạng "2024-01-15 14:30:25"
    """
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def assert_equal_with_message(actual, expected, field_name: str = "Giá trị") -> None:
    """
    So sánh hai giá trị với thông báo lỗi rõ ràng bằng tiếng Việt.

    Thay vì: assert actual == expected
    Dùng:    assert_equal_with_message(actual, expected, "Tiêu đề trang")

    Khi fail sẽ hiện: "Tiêu đề trang không khớp: mong đợi 'X' nhưng thực tế là 'Y'"

    Args:
        actual: Giá trị thực tế
        expected: Giá trị mong đợi
        field_name (str): Tên trường đang kiểm tra
    """
    assert actual == expected, (
        f"\n❌ {field_name} không khớp!\n"
        f"   Mong đợi : '{expected}'\n"
        f"   Thực tế  : '{actual}'"
    )


def assert_contains_with_message(text: str, substring: str, field_name: str = "Text") -> None:
    """
    Kiểm tra một chuỗi có chứa chuỗi con hay không.

    Args:
        text (str): Chuỗi cần kiểm tra
        substring (str): Chuỗi con cần tìm
        field_name (str): Tên trường đang kiểm tra
    """
    assert substring in text, (
        f"\n❌ {field_name} không chứa text mong đợi!\n"
        f"   Text cần chứa: '{substring}'\n"
        f"   Text thực tế : '{text}'"
    )
```

---

## 📄 pages/base_page.py

```python
# ============================================================
# FILE: pages/base_page.py
# MỤC ĐÍCH: Class cha chứa các method chung cho tất cả pages
#
# Đây là nền tảng của Page Object Model.
# Tất cả Page classes khác (LoginPage, HomePage, ...) đều
# kế thừa (inherit) từ BasePage này.
#
# Nguyên tắc: Đừng lặp lại code (DRY - Don't Repeat Yourself)
# Thay vì viết "find element + click" ở mọi trang,
# ta viết một lần ở đây và tái sử dụng ở mọi nơi.
# ============================================================

from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.action_chains import ActionChains
from selenium.common.exceptions import (
    TimeoutException,
    NoSuchElementException,
    ElementNotInteractableException,
    StaleElementReferenceException
)

from utils.config import Config
from utils.helpers import take_screenshot


class BasePage:
    """
    Class cha cho tất cả Page Object classes.

    Cách sử dụng:
        class LoginPage(BasePage):     # LoginPage kế thừa BasePage
            def __init__(self, driver):
                super().__init__(driver)  # Gọi constructor của BasePage

    Lợi ích:
        - Tập trung xử lý lỗi ở một chỗ
        - Logging thống nhất
        - Tái sử dụng code hiệu quả
    """

    def __init__(self, driver: WebDriver):
        """
        Constructor: Khởi tạo BasePage với WebDriver.

        Args:
            driver (WebDriver): Selenium WebDriver instance
        """
        self.driver = driver

        # WebDriverWait: Công cụ chờ element xuất hiện một cách thông minh
        # Thay vì time.sleep(5), wait sẽ kiểm tra liên tục và tiếp tục
        # ngay khi element xuất hiện → test nhanh hơn và ổn định hơn
        self.wait = WebDriverWait(
            driver,
            Config.EXPLICIT_WAIT,      # Chờ tối đa X giây
            poll_frequency=0.5,        # Kiểm tra mỗi 0.5 giây
            # Bỏ qua các exception này trong lúc chờ
            ignored_exceptions=[
                NoSuchElementException,
                StaleElementReferenceException
            ]
        )

    # ──────────────────────────────────────────────────────────
    # PHẦN 1: CÁC METHOD TÌM KIẾM ELEMENT
    # ──────────────────────────────────────────────────────────

    def find_element(self, locator: tuple) -> WebElement:
        """
        Tìm một element trên trang và CHỜ cho đến khi nó xuất hiện.

        Đây là method quan trọng nhất trong BasePage.
        Sử dụng Explicit Wait để đảm bảo element đã sẵn sàng.

        Args:
            locator (tuple): Cặp (By.XXX, "selector")
                Ví dụ: (By.ID, "username")
                       (By.CSS_SELECTOR, ".login-btn")
                       (By.XPATH, "//input[@name='email']")

        Returns:
            WebElement: Element đã tìm thấy

        Raises:
            TimeoutException: Nếu không tìm thấy element sau thời gian chờ

        Cách dùng:
            username_field = self.find_element((By.ID, "username"))
        """
        try:
            # EC.presence_of_element_located: Chờ element có mặt trong DOM
            # (element có thể chưa visible, nhưng đã tồn tại trong HTML)
            element = self.wait.until(
                EC.presence_of_element_located(locator)
            )
            return element

        except TimeoutException:
            # Chụp screenshot để debug
            take_screenshot(self.driver, f"element_not_found_{locator[1]}")
            raise TimeoutException(
                f"\n❌ Không tìm thấy element!\n"
                f"   Locator: {locator[0]} = '{locator[1]}'\n"
                f"   Trang hiện tại: {self.driver.current_url}\n"
                f"   Hãy kiểm tra lại selector có đúng không?"
            )

    def find_visible_element(self, locator: tuple) -> WebElement:
        """
        Tìm element và chờ cho đến khi nó VISIBLE (có thể nhìn thấy).

        Khác với find_element(): find_element chỉ cần element trong DOM,
        còn find_visible_element yêu cầu element phải visible trên màn hình.

        Dùng khi: Element đã trong DOM nhưng bị ẩn (display:none, opacity:0)

        Args:
            locator (tuple): Cặp (By.XXX, "selector")

        Returns:
            WebElement: Element đã visible
        """
        try:
            element = self.wait.until(
                EC.visibility_of_element_located(locator)
            )
            return element
        except TimeoutException:
            take_screenshot(self.driver, f"element_not_visible_{locator[1]}")
            raise TimeoutException(
                f"\n❌ Element không visible!\n"
                f"   Locator: {locator[0]} = '{locator[1]}'\n"
                f"   Trang: {self.driver.current_url}"
            )

    def find_clickable_element(self, locator: tuple) -> WebElement:
        """
        Tìm element và chờ cho đến khi nó có thể CLICK được.

        Dùng khi: Element visible nhưng bị disabled (button disabled)

        Args:
            locator (tuple): Cặp (By.XXX, "selector")

        Returns:
            WebElement: Element có thể click
        """
        try:
            element = self.wait.until(
                EC.element_to_be_clickable(locator)
            )
            return element
        except TimeoutException:
            take_screenshot(self.driver, f"element_not_clickable_{locator[1]}")
            raise TimeoutException(
                f"\n❌ Element không thể click!\n"
                f"   Locator: {locator[0]} = '{locator[1]}'\n"
                f"   Element có thể đang bị disabled."
            )

    def find_elements(self, locator: tuple) -> list:
        """
        Tìm NHIỀU elements cùng một lúc.

        Khác với find_element (trả về 1 element),
        find_elements trả về LIST các elements.

        Dùng khi: Cần lấy danh sách items, rows trong table, etc.

        Args:
            locator (tuple): Cặp (By.XXX, "selector")

        Returns:
            list: Danh sách WebElements (có thể rỗng nếu không tìm thấy)
        """
        try:
            # Chờ ít nhất 1 element xuất hiện
            self.wait.until(
                EC.presence_of_all_elements_located(locator)
            )
            return self.driver.find_elements(*locator)
        except TimeoutException:
            # Trả về list rỗng thay vì raise exception
            print(f"⚠️ Không tìm thấy elements: {locator}")
            return []

    # ──────────────────────────────────────────────────────────
    # PHẦN 2: CÁC METHOD TƯƠNG TÁC VỚI ELEMENT
    # ──────────────────────────────────────────────────────────

    def click(self, locator: tuple) -> None:
        """
        Click vào một element.

        Method này an toàn hơn element.click() thông thường vì:
        - Chờ element clickable trước khi click
        - Tự động retry nếu gặp StaleElementReferenceException
        - Log thông tin để debug

        Args:
            locator (tuple): Cặp (By.XXX, "selector")

        Cách dùng:
            self.click((By.ID, "login-button"))
        """
        try:
            element = self.find_clickable_element(locator)
            element.click()
            print(f"🖱️ Đã click: {locator[1]}")

        except StaleElementReferenceException:
            # Element bị "stale" = trang đã reload, element cũ không còn valid
            # Giải pháp: tìm lại element và click
            print(f"⚠️ Element bị stale, thử click lại: {locator[1]}")
            element = self.find_clickable_element(locator)
            element.click()

        except ElementNotInteractableException:
            # Element không thể click theo cách thông thường
            # Giải pháp: dùng JavaScript để click
            print(f"⚠️ Dùng JS click cho element: {locator[1]}")
            element = self.find_element(locator)
            self.driver.execute_script("arguments[0].click();", element)

    def enter_text(self, locator: tuple, text: str, clear_first: bool = True) -> None:
        """
        Nhập text vào input field.

        Args:
            locator (tuple): Cặp (By.XXX, "selector")
            text (str): Text cần nhập
            clear_first (bool): Có xóa nội dung cũ trước khi nhập không
                                Mặc định True để tránh append vào text cũ

        Cách dùng:
            self.enter_text((By.ID, "username"), "admin@example.com")
            self.enter_text((By.ID, "search"), "keyword", clear_first=False)
        """
        element = self.find_visible_element(locator)

        if clear_first:
            # Xóa nội dung cũ bằng 3 cách (đảm bảo sạch hoàn toàn)
            element.clear()                          # Cách 1: clear() cơ bản
            element.send_keys(Keys.CONTROL + "a")    # Cách 2: Ctrl+A (chọn hết)
            element.send_keys(Keys.DELETE)           # Cách 3: Delete

        element.send_keys(text)
        print(f"⌨️ Đã nhập text vào {locator[1]}: '{text}'")

    def get_text(self, locator: tuple) -> str:
        """
        Lấy nội dung text của một element.

        Dùng để verify text trên trang, ví dụ:
        - Kiểm tra thông báo lỗi
        - Kiểm tra tiêu đề trang
        - Kiểm tra giá trị trong bảng

        Args:
            locator (tuple): Cặp (By.XXX, "selector")

        Returns:
            str: Text content của element (đã strip whitespace)

        Cách dùng:
            error_msg = self.get_text((By.CLASS_NAME, "error-message"))
            assert "Invalid password" in error_msg
        """
        element = self.find_visible_element(locator)
        text = element.text.strip()  # strip() xóa khoảng trắng thừa
        print(f"📖 Lấy text từ {locator[1]}: '{text}'")
        return text

    def get_attribute(self, locator: tuple, attribute: str) -> str:
        """
        Lấy giá trị của một attribute HTML của element.

        Dùng khi cần lấy:
        - value của input field: get_attribute(locator, "value")
        - href của link: get_attribute(locator, "href")
        - class của element: get_attribute(locator, "class")
        - placeholder: get_attribute(locator, "placeholder")

        Args:
            locator (tuple): Cặp (By.XXX, "selector")
            attribute (str): Tên attribute cần lấy

        Returns:
            str: Giá trị của attribute
        """
        element = self.find_element(locator)
        value = element.get_attribute(attribute)
        print(f"🔍 Attribute '{attribute}' của {locator[1]}: '{value}'")
        return value

    def clear_field(self, locator: tuple) -> None:
        """
        Xóa nội dung của một input field.

        Args:
            locator (tuple): Cặp (By.XXX, "selector")
        """
        element = self.find_visible_element(locator)
        element.clear()
        print(f"🗑️ Đã xóa nội dung: {locator[1]}")

    def press_enter(self, locator: tuple) -> None:
        """
        Nhấn phím Enter trên một element.

        Dùng khi: Submit form bằng Enter thay vì click button

        Args:
            locator (tuple): Cặp (By.XXX, "selector")
        """
        element = self.find_element(locator)
        element.send_keys(Keys.ENTER)
        print(f"↵ Đã nhấn Enter trên: {locator[1]}")

    # ──────────────────────────────────────────────────────────
    # PHẦN 3: CÁC METHOD KIỂM TRA TRẠNG THÁI
    # ──────────────────────────────────────────────────────────

    def is_element_visible(self, locator: tuple, timeout: int = 5) -> bool:
        """
        Kiểm tra xem một element có đang visible không.

        Trả về True/False thay vì raise exception.
        Hữu ích trong các điều kiện if/else trong test.

        Args:
            locator (tuple): Cặp (By.XXX, "selector")
            timeout (int): Thời gian chờ tối đa (giây), mặc định 5s

        Returns:
            bool: True nếu visible, False nếu không

        Cách dùng:
            if self.is_element_visible((By.ID, "error-msg")):
                print("Có lỗi hiển thị!")
        """
        try:
            # Tạo wait mới với timeout ngắn hơn
            short_wait = WebDriverWait(self.driver, timeout)
            short_wait.until(EC.visibility_of_element_located(locator))
            return True
        except TimeoutException:
            return False

    def is_element_present(self, locator: tuple, timeout: int = 5) -> bool:
        """
        Kiểm tra xem element có tồn tại trong DOM không (không cần visible).

        Args:
            locator (tuple): Cặp (By.XXX, "selector")
            timeout (int): Thời gian chờ tối đa

        Returns:
            bool: True nếu tồn tại, False nếu không
        """
        try:
            short_wait = WebDriverWait(self.driver, timeout)
            short_wait.until(EC.presence_of_element_located(locator))
            return True
        except TimeoutException:
            return False

    def is_element_enabled(self, locator: tuple) -> bool:
        """
        Kiểm tra xem element có đang enabled (không bị disabled) không.

        Dùng để kiểm tra button, input field có thể tương tác không.

        Args:
            locator (tuple): Cặp (By.XXX, "selector")

        Returns:
            bool: True nếu enabled, False nếu disabled
        """
        element = self.find_element(locator)
        return element.is_enabled()

    def is_checkbox_selected(self, locator: tuple) -> bool:
        """
        Kiểm tra checkbox/radio button có được chọn không.

        Args:
            locator (tuple): Cặp (By.XXX, "selector")

        Returns:
            bool: True nếu được chọn, False nếu chưa
        """
        element = self.find_element(locator)
        return element.is_selected()

    # ──────────────────────────────────────────────────────────
    # PHẦN 4: CÁC METHOD ĐIỀU HƯỚNG (NAVIGATION)
    # ──────────────────────────────────────────────────────────

    def navigate_to(self, url: str) -> None:
        """
        Điều hướng browser đến một URL cụ thể.

        Args:
            url (str): URL cần điều hướng đến
        """
        self.driver.get(url)
        print(f"🌐 Đã điều hướng đến: {url}")

    def get_current_url(self) -> str:
        """
        Lấy URL của trang hiện tại.

        Returns:
            str: URL hiện tại

        Dùng để verify: assert "dashboard" in self.get_current_url()
        """
        url = self.driver.current_url
        print(f"📍 URL hiện tại: {url}")
        return url

    def get_page_title(self) -> str:
        """
        Lấy tiêu đề của trang hiện tại (thẻ <title>).

        Returns:
            str: Tiêu đề trang
        """
        title = self.driver.title
        print(f"📋 Tiêu đề trang: {title}")
        return title

    def go_back(self) -> None:
        """Nhấn nút Back của browser."""
        self.driver.back()
        print("⬅️ Đã nhấn Back")

    def refresh_page(self) -> None:
        """Reload trang hiện tại."""
        self.driver.refresh()
        print("🔄 Đã refresh trang")

    # ──────────────────────────────────────────────────────────
    # PHẦN 5: CÁC METHOD XỬ LÝ ALERT/POPUP
    # ──────────────────────────────────────────────────────────

    def accept_alert(self) -> str:
        """
        Chấp nhận (OK) alert popup.

        Returns:
            str: Text trong alert (để verify nếu cần)
        """
        try:
            # Chờ alert xuất hiện
            self.wait.until(EC.alert_is_present())
            alert = self.driver.switch_to.alert
            alert_text = alert.text
            print(f"📢 Alert text: '{alert_text}'")
            alert.accept()  # Click OK
            print("✅ Đã accept alert")
            return alert_text
        except TimeoutException:
            print("⚠️ Không có alert nào xuất hiện")
            return ""

    def dismiss_alert(self) -> str:
        """
        Hủy (Cancel) alert popup.

        Returns:
            str: Text trong alert
        """
        try:
            self.wait.until(EC.alert_is_present())
            alert = self.driver.switch_to.alert
            alert_text = alert.text
            alert.dismiss()  # Click Cancel
            print(f"❌ Đã dismiss alert: '{alert_text}'")
            return alert_text
        except TimeoutException:
            print("⚠️ Không có alert nào để dismiss")
            return ""

    # ──────────────────────────────────────────────────────────
    # PHẦN 6: CÁC METHOD TIỆN ÍCH KHÁC
    # ──────────────────────────────────────────────────────────

    def scroll_to_element(self, locator: tuple) -> None:
        """
        Cuộn trang đến vị trí của element.

        Dùng khi element bị khuất (off-screen) và cần
        scroll đến để click hoặc verify.

        Args:
            locator (tuple): Cặp (By.XXX, "selector")
        """
        element = self.find_element(locator)
        # scrollIntoView: JavaScript method cuộn element vào tầm nhìn
        self.driver.execute_script(
            "arguments[0].scrollIntoView({behavior: 'smooth', block: 'center'});",
            element
        )
        print(f"📜 Đã scroll đến: {locator[1]}")

    def hover_over_element(self, locator: tuple) -> None:
        """
        Di chuyển chuột đến element (hover).

        Dùng để mở dropdown menu, tooltip, etc.

        Args:
            locator (tuple): Cặp (By.XXX, "selector")
        """
        element = self.find_element(locator)
        actions = ActionChains(self.driver)
        actions.move_to_element(element).perform()
        print(f"🖱️ Đã hover over: {locator[1]}")

    def wait_for_url_contains(self, partial_url: str, timeout: int = 15) -> bool:
        """
        Chờ URL thay đổi và chứa chuỗi cụ thể.

        Dùng để verify redirect sau khi đăng nhập, submit form.

        Args:
            partial_url (str): Phần URL cần kiểm tra
            timeout (int): Thời gian chờ tối đa

        Returns:
            bool: True nếu URL chứa chuỗi đó trong thời gian chờ

        Cách dùng:
            assert self.wait_for_url_contains("dashboard")
        """
        try:
            wait = WebDriverWait(self.driver, timeout)
            wait.until(EC.url_contains(partial_url))
            return True
        except TimeoutException:
            print(f"⚠️ URL không chứa '{partial_url}' sau {timeout}s")
            print(f"   URL hiện tại: {self.driver.current_url}")
            return False

    def wait_for_element_disappear(self, locator: tuple, timeout: int = 10) -> bool:
        """
        Chờ cho đến khi element biến mất (không còn visible).

        Dùng khi: Chờ loading spinner biến mất, modal đóng lại.

        Args:
            locator (tuple): Cặp (By.XXX, "selector")
            timeout (int): Thời gian chờ tối đa

        Returns:
            bool: True nếu element đã biến mất
        """
        try:
            wait = WebDriverWait(self.driver, timeout)
            wait.until(EC.invisibility_of_element_located(locator))
            return True
        except TimeoutException:
            print(f"⚠️ Element vẫn visible sau {timeout}s: {locator[1]}")
            return False

    def take_screenshot(self, name: str = "screenshot") -> str:
        """
        Chụp màn hình từ bên trong Page class.

        Args:
            name (str): Tên file screenshot

        Returns:
            str: Đường dẫn file screenshot
        """
        return take_screenshot(self.driver, name)
```

---

## 📄 pages/login_page.py

```python
# ============================================================
# FILE: pages/login_page.py
# MỤC ĐÍCH: Page Object cho trang đăng nhập
#
# File này đại diện cho trang Login của ứng dụng.
# Mọi thao tác liên quan đến trang Login đều ở đây:
# - Locators (cách tìm các element)
# - Methods (các hành động có thể thực hiện)
#
# Lợi ích của cách làm này:
# - Nếu UI thay đổi (ví dụ: đổi ID của input), chỉ sửa ở đây
# - Test files không cần biết selector nào, chỉ gọi method
# ============================================================

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

# Kế thừa tất cả methods từ BasePage
from pages.base_page import BasePage
from utils.config import Config


class LoginPage(BasePage):
    """
    Page Object cho trang đăng nhập.

    Trang này phục vụ cả Banking App và E-commerce App.
    (Bạn có thể tạo LoginPage riêng cho từng loại app)

    Cách sử dụng trong test:
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("user@example.com", "password")
        assert login_page.is_login_successful()
    """

    # ──────────────────────────────────────────────────────────
    # LOCATORS - Cách tìm các element trên trang
    #
    # BEST PRACTICE: Đặt tất cả locators ở đây, không viết
    # selector trực tiếp trong methods.
    #
    # Tại sao dùng tuple? Để truyền vào find_element(locator)
    # Cú pháp: (By.XXX, "giá trị selector")
    # ──────────────────────────────────────────────────────────

    # ── Input Fields ──────────────────────────────────────────
    # Trường nhập tên đăng nhập
    # By.NAME: tìm theo attribute name="uid"
    USERNAME_INPUT = (By.NAME, "uid")

    # Trường nhập mật khẩu
    PASSWORD_INPUT = (By.NAME, "password")

    # ── Buttons ───────────────────────────────────────────────
    # Nút đăng nhập
    # By.NAME: tìm theo attribute name="btnLogin"
    LOGIN_BUTTON = (By.NAME, "btnLogin")

    # Nút reset/xóa form
    RESET_BUTTON = (By.NAME, "btnReset")

    # ── Error/Success Messages ────────────────────────────────
    # Thông báo lỗi khi đăng nhập sai
    # By.CSS_SELECTOR: tìm theo CSS class
    ERROR_MESSAGE = (By.CSS_SELECTOR, ".error-message")

    # Thông báo lỗi khi để trống username
    USERNAME_ERROR = (By.ID, "message23")

    # Thông báo lỗi khi để trống password
    PASSWORD_ERROR = (By.ID, "message18")

    # Thông báo tài khoản bị khóa
    LOCKED_MESSAGE = (By.CSS_SELECTOR, ".locked-message")

    # ── Trang sau khi đăng nhập (Dashboard/Home) ──────────────
    # Element chỉ xuất hiện khi đã đăng nhập thành công
    # Dùng để verify đăng nhập thành công
    WELCOME_MESSAGE = (By.CSS_SELECTOR, "td[colspan='2']")

    # Tiêu đề trang dashboard
    DASHBOARD_TITLE = (By.CSS_SELECTOR, ".heading3")

    # ── Forgot Password Link ──────────────────────────────────
    FORGOT_PASSWORD_LINK = (By.LINK_TEXT, "forgot password?")

    # ── Logo/Brand trên trang Login ───────────────────────────
    COMPANY_LOGO = (By.CSS_SELECTOR, ".logo")

    # ══════════════════════════════════════════════════════════
    # LOCATORS CHO E-COMMERCE (Saucedemo.com)
    # Nếu test e-commerce app, dùng các locators này
    # ══════════════════════════════════════════════════════════

    # USERNAME_INPUT = (By.ID, "user-name")
    # PASSWORD_INPUT = (By.ID, "password")
    # LOGIN_BUTTON = (By.ID, "login-button")
    # ERROR_MESSAGE = (By.CSS_SELECTOR, "[data-test='error']")

    # ──────────────────────────────────────────────────────────
    # METHODS - Các hành động trên trang Login
    # ──────────────────────────────────────────────────────────

    def __init__(self, driver: WebDriver):
        """
        Constructor: Khởi tạo LoginPage.

        super().__init__(driver) gọi constructor của BasePage
        để khởi tạo self.driver và self.wait

        Args:
            driver (WebDriver): Selenium WebDriver
        """
        super().__init__(driver)  # Gọi BasePage.__init__(driver)

    def open(self) -> "LoginPage":
        """
        Mở trang đăng nhập.

        Returns:
            LoginPage: Trả về self để hỗ trợ method chaining
            Ví dụ: login_page.open().enter_username("admin")

        Cách dùng:
            login_page.open()
            # hoặc method chaining:
            login_page.open().enter_username("admin").enter_password("pass")
        """
        self.navigate_to(Config.BASE_URL)
        print(f"📂 Đã mở trang đăng nhập: {Config.BASE_URL}")
        return self  # Trả về self để method chaining

    def enter_username(self, username: str) -> "LoginPage":
        """
        Nhập tên đăng nhập vào trường username.

        Args:
            username (str): Tên đăng nhập cần nhập

        Returns:
            LoginPage: self (cho method chaining)
        """
        self.enter_text(self.USERNAME_INPUT, username)
        return self

    def enter_password(self, password: str) -> "LoginPage":
        """
        Nhập mật khẩu vào trường password.

        Args:
            password (str): Mật khẩu cần nhập

        Returns:
            LoginPage: self (cho method chaining)
        """
        self.enter_text(self.PASSWORD_INPUT, password)
        return self

    def click_login_button(self) -> "LoginPage":
        """
        Click vào nút Login/Đăng nhập.

        Returns:
            LoginPage: self (cho method chaining)
        """
        self.click(self.LOGIN_BUTTON)
        print("🔘 Đã click nút Login")
        return self

    def click_reset_button(self) -> "LoginPage":
        """
        Click vào nút Reset để xóa form.

        Dùng để test chức năng reset, hoặc xóa form
        giữa các test steps.

        Returns:
            LoginPage: self (cho method chaining)
        """
        self.click(self.RESET_BUTTON)
        print("🔄 Đã click nút Reset")
        return self

    def login(self, username: str, password: str) -> None:
        """
        Thực hiện đăng nhập đầy đủ: nhập username + password + click login.

        Đây là method "high-level" - gộp nhiều bước thành 1.
        Dùng khi chỉ cần đăng nhập mà không cần kiểm tra từng bước.

        Args:
            username (str): Tên đăng nhập
            password (str): Mật khẩu

        Cách dùng trong test:
            login_page.open()
            login_page.login("admin", "password123")
            # Sau đó verify kết quả
        """
        print(f"\n🔐 Thực hiện đăng nhập với user: '{username}'")
        self.enter_username(username)
        self.enter_password(password)
        self.click_login_button()

    def login_with_method_chaining(self, username: str, password: str) -> None:
        """
        Đăng nhập sử dụng method chaining (cú pháp chuỗi).

        Cú pháp đẹp hơn, nhưng cả hai cách đều hoạt động giống nhau.

        Cách dùng:
            login_page.open()
                      .enter_username("admin")
                      .enter_password("pass")
                      .click_login_button()
        """
        (self.open()
             .enter_username(username)
             .enter_password(password)
             .click_login_button())

    def click_forgot_password(self) -> None:
        """
        Click vào link "Forgot Password".
        """
        self.click(self.FORGOT_PASSWORD_LINK)

    # ──────────────────────────────────────────────────────────
    # METHODS VERIFY KẾT QUẢ (Getters)
    # ──────────────────────────────────────────────────────────

    def get_error_message(self) -> str:
        """
        Lấy text của thông báo lỗi chung.

        Returns:
            str: Nội dung thông báo lỗi, hoặc "" nếu không có
        """
        if self.is_element_visible(self.ERROR_MESSAGE):
            return self.get_text(self.ERROR_MESSAGE)
        return ""

    def get_username_error(self) -> str:
        """
        Lấy thông báo lỗi cho trường username.

        Returns:
            str: Thông báo lỗi của username field
        """
        if self.is_element_visible(self.USERNAME_ERROR):
            return self.get_text(self.USERNAME_ERROR)
        return ""

    def get_password_error(self) -> str:
        """
        Lấy thông báo lỗi cho trường password.

        Returns:
            str: Thông báo lỗi của password field
        """
        if self.is_element_visible(self.PASSWORD_ERROR):
            return self.get_text(self.PASSWORD_ERROR)
        return ""

    def get_welcome_message(self) -> str:
        """
        Lấy lời chào mừng sau khi đăng nhập thành công.

        Returns:
            str: Text của welcome message
        """
        return self.get_text(self.WELCOME_MESSAGE)

    def get_locked_message(self) -> str:
        """
        Lấy thông báo tài khoản bị khóa.

        Returns:
            str: Nội dung thông báo tài khoản bị khóa
        """
        if self.is_element_visible(self.LOCKED_MESSAGE):
            return self.get_text(self.LOCKED_MESSAGE)
        return ""

    # ──────────────────────────────────────────────────────────
    # METHODS KIỂM TRA TRẠNG THÁI (Boolean checkers)
    # ──────────────────────────────────────────────────────────

    def is_login_successful(self) -> bool:
        """
        Kiểm tra đăng nhập có thành công không.

        Cách verify: Kiểm tra URL đã chuyển sang dashboard,
        hoặc welcome message xuất hiện.

        Returns:
            bool: True nếu đăng nhập thành công
        """
        # Cách 1: Kiểm tra URL (phổ biến nhất)
        current_url = self.get_current_url()
        if "index.php" in current_url or "dashboard" in current_url.lower():
            print("✅ Đăng nhập thành công (verified qua URL)")
            return True

        # Cách 2: Kiểm tra welcome message có xuất hiện không
        if self.is_element_visible(self.WELCOME_MESSAGE, timeout=5):
            print("✅ Đăng nhập thành công (verified qua welcome message)")
            return True

        print("❌ Đăng nhập thất bại")
        return False

    def is_error_message_displayed(self) -> bool:
        """
        Kiểm tra có thông báo lỗi nào đang hiển thị không.

        Returns:
            bool: True nếu có error message
        """
        return self.is_element_visible(self.ERROR_MESSAGE)

    def is_username_error_displayed(self) -> bool:
        """
        Kiểm tra có thông báo lỗi username không.

        Returns:
            bool: True nếu có lỗi username
        """
        return self.is_element_visible(self.USERNAME_ERROR)

    def is_password_error_displayed(self) -> bool:
        """
        Kiểm tra có thông báo lỗi password không.

        Returns:
            bool: True nếu có lỗi password
        """
        return self.is_element_visible(self.PASSWORD_ERROR)

    def is_on_login_page(self) -> bool:
        """
        Kiểm tra browser có đang ở trang login không.

        Returns:
            bool: True nếu đang ở trang login
        """
        return Config.BASE_URL in self.get_current_url()
```

---

## 📄 tests/test_login.py

```python
# ============================================================
# FILE: tests/test_login.py
# MỤC ĐÍCH: Test cases cho chức năng đăng nhập
#
# Cấu trúc một test case tốt gồm 3 phần (AAA Pattern):
#   1. ARRANGE: Chuẩn bị dữ liệu và điều kiện
#   2. ACT:     Thực hiện hành động cần test
#   3. ASSERT:  Kiểm tra kết quả mong đợi
#
# Quy tắc đặt tên test:
#   test_[tên_chức_năng]_[điều_kiện]_[kết_quả_mong_đợi]
#   Ví dụ: test_login_valid_credentials_redirect_to_dashboard
# ============================================================

import pytest
from pages.login_page import LoginPage
from utils.config import Config
from utils.helpers import (
    assert_equal_with_message,
    assert_contains_with_message,
    take_screenshot
)


class TestLogin:
    """
    Test class chứa tất cả test cases cho chức năng Login.

    pytest sẽ tự động tìm và chạy tất cả methods
    bắt đầu bằng "test_" trong class này.

    Fixtures được inject tự động qua tên tham số:
    - driver: Từ conftest.py (WebDriver instance)
    """

    # ──────────────────────────────────────────────────────────
    # TEST CASE 1: ĐĂNG NHẬP THÀNH CÔNG
    # ──────────────────────────────────────────────────────────

    @pytest.mark.smoke    # Đánh dấu là smoke test (test cơ bản)
    @pytest.mark.login    # Đánh dấu thuộc nhóm login tests
    def test_login_success_with_valid_credentials(self, driver):
        """
        TC001 - Kiểm tra đăng nhập thành công với thông tin hợp lệ.

        Mục đích:
            Đảm bảo user có thể đăng nhập khi nhập đúng
            username và password.

        Điều kiện tiên quyết:
            - Tài khoản đã tồn tại trong hệ thống
            - Tài khoản chưa bị khóa

        Các bước thực hiện:
            1. Mở trang đăng nhập
            2. Nhập username hợp lệ
            3. Nhập password hợp lệ
            4. Click nút Login

        Kết quả mong đợi:
            - Redirect đến trang Dashboard
            - URL chứa "index.php" hoặc "dashboard"
            - Hiển thị welcome message
        """

        # ── ARRANGE: Chuẩn bị ─────────────────────────────────
        # Tạo LoginPage object và truyền driver vào
        login_page = LoginPage(driver)

        # Lấy thông tin đăng nhập từ config
        username = Config.VALID_USERNAME
        password = Config.VALID_PASSWORD

        # ── ACT: Thực hiện hành động ───────────────────────────
        login_page.open()                    # Bước 1: Mở trang login
        login_page.login(username, password)  # Bước 2-4: Đăng nhập

        # ── ASSERT: Kiểm tra kết quả ───────────────────────────
        # Kiểm tra đã redirect sang trang khác (không còn ở trang login)
        assert login_page.is_login_successful(), (
            "❌ Đăng nhập thất bại!\n"
            "   Nguyên nhân có thể: Sai username/password, "
            "server không hoạt động, hoặc UI đã thay đổi."
        )

        print("\n✅ TC001 PASSED: Đăng nhập thành công!")

    # ──────────────────────────────────────────────────────────
    # TEST CASE 2: ĐĂNG NHẬP THẤT BẠI - SAI MẬT KHẨU
    # ──────────────────────────────────────────────────────────

    @pytest.mark.regression    # Đây là regression test
    @pytest.mark.negative      # Test với dữ liệu không hợp lệ
    @pytest.mark.login
    def test_login_fail_with_wrong_password(self, driver):
        """
        TC002 - Kiểm tra hệ thống báo lỗi khi nhập sai mật khẩu.

        Mục đích:
            Đảm bảo hệ thống KHÔNG cho phép đăng nhập khi
            mật khẩu không đúng và hiển thị thông báo lỗi phù hợp.

        Các bước thực hiện:
            1. Mở trang đăng nhập
            2. Nhập username đúng
            3. Nhập password SAI
            4. Click nút Login

        Kết quả mong đợi:
            - Không redirect đến Dashboard
            - Hiển thị thông báo lỗi
            - Thông báo lỗi chứa text phù hợp
        """

        # ── ARRANGE ────────────────────────────────────────────
        login_page = LoginPage(driver)
        username = Config.VALID_USERNAME
        wrong_password = "WrongPassword@123"  # Mật khẩu SAI

        # ── ACT ────────────────────────────────────────────────
        login_page.open()
        login_page.login(username, wrong_password)

        # ── ASSERT ─────────────────────────────────────────────
        # Kiểm tra 1: Vẫn còn ở trang login (chưa redirect)
        assert login_page.is_on_login_page() or not login_page.is_login_successful(), (
            "❌ Hệ thống đã cho đăng nhập với mật khẩu sai! "
            "Đây là lỗi bảo mật nghiêm trọng!"
        )

        # Kiểm tra 2: Hiển thị thông báo lỗi
        # (Có thể là alert popup hoặc error message trên trang)

        # Trường hợp A: Lỗi hiển thị dưới dạng alert popup
        # alert_text = login_page.accept_alert()
        # assert_contains_with_message(
        #     alert_text,
        #     Config.INVALID_CREDENTIAL_MSG,
        #     "Alert message"
        # )

        # Trường hợp B: Lỗi hiển thị trực tiếp trên trang
        error_msg = login_page.get_error_message()
        if error_msg:
            assert_contains_with_message(
                error_msg.lower(),
                "invalid",  # Kiểm tra message chứa từ "invalid"
                "Error message khi sai mật khẩu"
            )
        else:
            # Nếu không có error message, verify qua URL
            assert not login_page.is_login_successful(), (
                "❌ Đăng nhập thành công với mật khẩu sai! "
                "Lỗi bảo mật!"
            )

        print("\n✅ TC002 PASSED: Hệ thống chặn đăng nhập với mật khẩu sai!")

    # ──────────────────────────────────────────────────────────
    # TEST CASE 3: ĐỂ TRỐNG USERNAME
    # ──────────────────────────────────────────────────────────

    @pytest.mark.regression
    @pytest.mark.negative
    @pytest.mark.login
    def test_login_fail_with_empty_username(self, driver):
        """
        TC003 - Kiểm tra validation khi để trống trường username.

        Mục đích:
            Đảm bảo form validation hoạt động đúng khi
            user không điền username.

        Các bước thực hiện:
            1. Mở trang đăng nhập
            2. Để trống trường username
            3. Nhập password hợp lệ
            4. Click nút Login (hoặc click vào password field)

        Kết quả mong đợi:
            - Hiển thị thông báo lỗi cho username field
            - Thông báo: "User-ID must not be blank" (hoặc tương tự)
            - Không thực hiện đăng nhập
        """

        # ── ARRANGE ────────────────────────────────────────────
        login_page = LoginPage(driver)

        # ── ACT ────────────────────────────────────────────────
        login_page.open()

        # Nhập password nhưng bỏ trống username
        login_page.enter_password(Config.VALID_PASSWORD)

        # Click vào username field (trigger validation)
        # hoặc click Login button
        login_page.click_login_button()

        # Hoặc: chỉ click vào username và bỏ qua (để trigger blur event)
        # login_page.click((By.NAME, "uid"))
        # login_page.click((By.NAME, "password"))

        # ── ASSERT ─────────────────────────────────────────────
        # Cách 1: Kiểm tra có thông báo lỗi username không
        username_error = login_page.get_username_error()

        if username_error:
            # Kiểm tra nội dung thông báo lỗi
            assert_contains_with_message(
                username_error,
                "not be blank",     # Text mong đợi trong error message
                "Username validation message"
            )
            print(f"   📋 Username error: '{username_error}'")
        else:
            # Cách 2: Nếu không có error message riêng,
            # kiểm tra đảm bảo không đăng nhập được
            assert not login_page.is_login_successful(), (
                "❌ Hệ thống cho phép đăng nhập với username trống!"
            )

        print("\n✅ TC003 PASSED: Validation hoạt động khi username trống!")

    # ──────────────────────────────────────────────────────────
    # TEST CASE 4: ĐỂ TRỐNG PASSWORD
    # ──────────────────────────────────────────────────────────

    @pytest.mark.regression
    @pytest.mark.negative
    @pytest.mark.login
    def test_login_fail_with_empty_password(self, driver):
        """
        TC004 - Kiểm tra validation khi để trống trường password.

        Mục đích:
            Đảm bảo form validation hoạt động đúng khi
            user không điền password.

        Các bước thực hiện:
            1. Mở trang đăng nhập
            2. Nhập username hợp lệ
            3. Để trống trường password
            4. Click nút Login

        Kết quả mong đợi:
            - Hiển thị thông báo lỗi cho password field
            - Thông báo: "Password must not be blank" (hoặc tương tự)
            - Không thực hiện đăng nhập
        """

        # ── ARRANGE ────────────────────────────────────────────
        login_page = LoginPage(driver)

        # ── ACT ────────────────────────────────────────────────
        login_page.open()

        # Nhập username nhưng bỏ trống password
        login_page.enter_username(Config.VALID_USERNAME)

        # Click vào password field rồi click sang chỗ khác
        # để trigger validation event
        login_page.click_login_button()

        # ── ASSERT ─────────────────────────────────────────────
        # Kiểm tra có thông báo lỗi password không
        password_error = login_page.get_password_error()

        if password_error:
            assert_contains_with_message(
                password_error,
                "not be blank",
                "Password validation message"
            )
            print(f"   📋 Password error: '{password_error}'")
        else:
            # Fallback: Kiểm tra không đăng nhập được
            assert not login_page.is_login_successful(), (
                "❌ Hệ thống cho phép đăng nhập với password trống!"
            )

        print("\n✅ TC004 PASSED: Validation hoạt động khi password trống!")

    # ──────────────────────────────────────────────────────────
    # TEST CASE 5: TÀI KHOẢN BỊ KHÓA
    # ──────────────────────────────────────────────────────────

    @pytest.mark.regression
    @pytest.mark.negative
    @pytest.mark.login
    def test_login_fail_with_locked_account(self, driver):
        """
        TC005 - Kiểm tra hành vi khi đăng nhập với tài khoản bị khóa.

        Mục đích:
            Đảm bảo hệ thống chặn đăng nhập và hiển thị
            thông báo phù hợp khi tài khoản bị khóa/banned.

        Điều kiện tiên quyết:
            - Có tài khoản đã bị khóa trong hệ thống
            - Config.LOCKED_USERNAME đã được cài đặt

        Các bước thực hiện:
            1. Mở trang đăng nhập
            2. Nhập username của tài khoản bị khóa
            3. Nhập password của tài khoản đó
            4. Click nút Login

        Kết quả mong đợi:
            - Không thể đăng nhập
            - Hiển thị thông báo tài khoản bị khóa
            - Không redirect đến Dashboard
        """

        # ── ARRANGE ────────────────────────────────────────────
        login_page = LoginPage(driver)
        locked_username = Config.LOCKED_USERNAME
        locked_password = Config.LOCKED_PASSWORD

        # ── ACT ────────────────────────────────────────────────
        login_page.open()
        login_page.login(locked_username, locked_password)

        # ── ASSERT ─────────────────────────────────────────────
        # Kiểm tra 1: Không được phép đăng nhập
        assert not login_page.is_login_successful(), (
            f"❌ Tài khoản bị khóa '{locked_username}' "
            f"vẫn đăng nhập được! Đây là lỗi bảo mật!"
        )

        # Kiểm tra 2: Có thông báo locked (nếu app hỗ trợ)
        locked_msg = login_page.get_locked_message()
        if locked_msg:
            # Nếu có thông báo, kiểm tra nội dung
            assert any(
                keyword in locked_msg.lower()
                for keyword in ["locked", "blocked", "suspended", "khóa", "banned"]
            ), (
                f"❌ Thông báo locked không đúng format!\n"
                f"   Thực tế: '{locked_msg}'"
            )
            print(f"   📋 Locked message: '{locked_msg}'")
        else:
            # Một số app chỉ hiện error chung, không phân biệt
            error_msg = login_page.get_error_message()
            print(f"   📋 Error message: '{error_msg}'")

        print("\n✅ TC005 PASSED: Hệ thống chặn tài khoản bị khóa!")

    # ──────────────────────────────────────────────────────────
    # BONUS TEST CASES (Tham khảo thêm)
    # ──────────────────────────────────────────────────────────

    @pytest.mark.regression
    @pytest.mark.negative
    def test_login_fail_both_fields_empty(self, driver):
        """
        TC006 (BONUS) - Kiểm tra khi cả hai field đều trống.

        Kết quả mong đợi: Hiện lỗi cho cả username và password
        """

        # ── ARRANGE + ACT ──────────────────────────────────────
        login_page = LoginPage(driver)
        login_page.open()

        # Click Login ngay không điền gì
        login_page.click_login_button()

        # ── ASSERT ─────────────────────────────────────────────
        # Ít nhất phải có 1 trong 2 loại validation
        has_username_error = login_page.is_username_error_displayed()
        has_password_error = login_page.is_password_error_displayed()
        has_general_error = login_page.is_error_message_displayed()

        assert (
            has_username_error or
            has_password_error or
            has_general_error or
            not login_page.is_login_successful()
        ), "❌ Không có validation nào khi cả 2 fields đều trống!"

        print("\n✅ TC006 PASSED: Validation hoạt động khi cả 2 fields trống!")

    @pytest.mark.parametrize(
        # Tên các tham số
        "username, password, test_description",
        [
            # Danh sách các bộ dữ liệu test
            # (username, password, mô tả)
            ("", "", "Cả hai trường trống"),
            ("admin", "", "Password trống"),
            ("", "password123", "Username trống"),
            ("invalid@user", "wrongpass", "Cả hai sai"),
            ("' OR '1'='1", "' OR '1'='1", "SQL Injection attack"),
            ("<script>alert(1)</script>", "test", "XSS attack"),
            ("a" * 256, "password", "Username quá dài"),
        ]
    )
    @pytest.mark.regression
    @pytest.mark.negative
    def test_login_invalid_scenarios(
        self, driver, username, password, test_description
    ):
        """
        TC007 (BONUS) - Kiểm tra nhiều trường hợp không hợp lệ cùng lúc.

        Sử dụng pytest.mark.parametrize để chạy 1 test với nhiều bộ dữ liệu.
        Pytest sẽ tự động tạo 7 test cases riêng biệt từ danh sách trên.

        Args:
            username: Tên đăng nhập test
            password: Mật khẩu test
            test_description: Mô tả trường hợp test
        """
        print(f"\n🧪 Đang test: {test_description}")
        print(f"   Username: '{username}' | Password: '{password}'")

        # ── ARRANGE + ACT ──────────────────────────────────────
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login(username, password)

        # ── ASSERT ─────────────────────────────────────────────
        # Với tất cả các bộ dữ liệu không hợp lệ,
        # đảm bảo đều KHÔNG đăng nhập được
        assert not login_page.is_login_successful(), (
            f"❌ Hệ thống cho phép đăng nhập với dữ liệu: {test_description}\n"
            f"   Username: '{username}'\n"
            f"   Password: '{password}'\n"
            f"   Đây có thể là lỗi bảo mật nghiêm trọng!"
        )

        print(f"✅ PASSED: Chặn thành công - {test_description}")
```

---

## 📄 test_data/test_users.json

```json
{
  "_comment": "File chứa dữ liệu test users - KHÔNG commit file này lên Git nếu có password thật",

  "valid_user": {
    "username": "mngr123456",
    "password": "password123",
    "description": "Tài khoản hợp lệ để test đăng nhập thành công"
  },

  "locked_user": {
    "username": "locked_user",
    "password": "secret_sauce",
    "description": "Tài khoản đã bị khóa"
  },

  "invalid_users": [
    {
      "username": "wronguser123",
      "password": "wrongpassword",
      "description": "Cả username và password đều sai"
    },
    {
      "username": "mngr123456",
      "password": "WrongPassword@999",
      "description": "Username đúng nhưng password sai"
    },
    {
      "username": "nonexistent@user.com",
      "password": "password123",
      "description": "Username không tồn tại"
    }
  ],

  "edge_cases": [
    {
      "username": "",
      "password": "",
      "description": "Cả hai trường trống"
    },
    {
      "username": "   ",
      "password": "   ",
      "description": "Username và password chỉ có khoảng trắng"
    },
    {
      "username": "' OR '1'='1' --",
      "password": "' OR '1'='1' --",
      "description": "SQL Injection attack"
    },
    {
      "username": "<script>alert('xss')</script>",
      "password": "test123",
      "description": "XSS attack"
    }
  ]
}
```

---

## 📄 pages/__init__.py

```python
# File này làm cho thư mục 'pages' trở thành Python package.
# Python package = thư mục có thể import như module.
#
# Không cần viết gì trong file này,
# nhưng nó phải TỒN TẠI trong mỗi thư mục.
```

---

## 📄 utils/__init__.py

```python
# File này làm cho thư mục 'utils' trở thành Python package.
# Xem giải thích ở pages/__init__.py
```

---

## 📄 tests/__init__.py

```python
# File này làm cho thư mục 'tests' trở thành Python package.
# Xem giải thích ở pages/__init__.py
```

---

## 🚀 Hướng Dẫn Sử Dụng Nhanh

```bash
# 1. Cài đặt môi trường
python -m venv venv && source venv/bin/activate  # macOS/Linux
# python -m venv venv && venv\Scripts\activate   # Windows
pip install -r requirements.txt

# 2. Chỉnh sửa config
# Mở utils/config.py và thay BASE_URL, VALID_USERNAME, VALID_PASSWORD

# 3. Chạy smoke tests (test nhanh nhất)
pytest -m smoke -v

# 4. Chạy tất cả login tests
pytest -m login -v

# 5. Chạy với báo cáo HTML
pytest --html=reports/report.html --self-contained-html -v

# 6. Chạy headless (CI/CD)
pytest --headless -v
```