Automated Test Suite with Selenium

Build a Selenium WebDriver test framework using the Page Object Model.

Introduction

30 Second Summary

Every time you update a website, something else can break. Manual testing catches some bugs, but it is slow, repetitive, and easy to miss things when you are checking the same flows over and over again.

In this project, you will build a complete automated test suite using Selenium WebDriver and Python. Your tests will log into a web application, browse products, and add items to a cart, all without you lifting a finger.

What You'll Build

A professional test automation framework that runs automated browser tests against a real e-commerce web application.

By the end of this project, you'll have:

  • A Page Object Model framework with reusable page classes that mirror how professional QA teams structure their test automation code.
  • A suite of 10 automated tests covering login validation, inventory browsing, and cart operations.
  • A professional HTML test report generated by pytest-html that documents your test results with pass/fail status and execution time.
  • Secret Mission: Write a defect report by testing a buggy user account and documenting the bug like a QA professional.

Are there any prerequisites?

No prior test automation experience is needed. This guide walks you through setting up every tool from scratch, including Python, Selenium, and pytest.

Set Up Your Test Automation Environment

Before you can write a single test, you need a proper environment where your testing tools can run in isolation.

In this step, you will create a project folder, install Selenium WebDriver and pytest, and verify that Selenium can control your Chrome browser automatically.

In this step, get ready to:
  • Create a project folder with a virtual environment and install testing packages.
  • Build the project structure with directories and configuration files.
  • Verify Selenium can open and control Chrome.
Create a virtual environment and install dependencies

A virtual environment keeps your project's packages separate from other Python projects on your machine. This prevents version conflicts between different projects.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, above Tab on your keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir selenium-tests
cd selenium-tests
  • Confirm the folder exists by running:
ls

The folder will be empty for now, but you should see no errors.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the selenium-tests folder, and click Open.

Why open the folder this way?

Opening a folder in VS Code sets it as your workspace root. The integrated terminal, file explorer, and any relative paths all start from this folder.

  • Open a fresh terminal in VS Code by pressing Ctrl+` (backtick).
  • Create a virtual environment by running this command:
python -m venv venv

What does this command do?

The venv module creates an isolated Python environment inside a folder called venv. Any packages you install after activating it stay confined to this project.

  • Activate the virtual environment by running the command for your operating system:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

On macOS, make sure you used source before the path. On Windows, if you get a permissions error in PowerShell, run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser first, then try the activate command again.

help me activate my Python virtual environment

  • Install the testing packages by running:
pip install selenium==4.44.0 pytest==8.3.4 pytest-html==4.2.0

What are these packages?

  • selenium==4.44.0 controls web browsers programmatically.
  • pytest==8.3.4 is the test runner that discovers and executes your tests.
  • pytest-html==4.2.0 generates professional HTML reports from your test results.
  • Verify Selenium installed correctly by running:
python -c "import selenium; print(selenium.__version__)"

You should see 4.44.0 printed in the terminal.

Seeing a ModuleNotFoundError?

Make sure (venv) appears in your terminal prompt. If it does not, run the activate command again. Packages installed without an active virtual environment go to the wrong location.

help me fix a ModuleNotFoundError for selenium

  • Create a requirements.txt file to record your dependencies. In the VS Code Explorer sidebar, right-click in the empty area and select New File.
  • Name it requirements.txt and press Enter.
  • Paste the following content into the file:
selenium==4.44.0
pytest==8.3.4
pytest-html==4.2.0

What does requirements.txt do?

This file records the exact package versions your project needs. Anyone who clones your project can run pip install -r requirements.txt to get the same setup.

  • Save the file with Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you have saved requirements.txt before moving on.

ⓧ I'd like to double check the full code

selenium==4.44.0
pytest==8.3.4
pytest-html==4.2.0
Create the project folder structure

A well-organized project structure makes it easy to find and maintain your tests as the suite grows. You will create separate directories for page objects, tests, and reports.

  • Create the project directories by running this command in your terminal:
mkdir pages tests reports
  • Confirm the directories exist by running:
ls

You should see pages, reports, tests, and venv in the output.

  • Create the __init__.py files that tell Python these directories are packages. Run:

macOS

touch pages/__init__.py tests/__init__.py

Windows

New-Item pages/__init__.py
New-Item tests/__init__.py

Why do we need __init__.py files?

Python uses __init__.py files to mark a directory as a package. Without them, Python cannot import modules from those directories.

  • Create a pytest.ini file. Right-click in the VS Code Explorer sidebar and select New File.
  • Name it pytest.ini and press Enter.
  • Paste the following content into the file:
[pytest]
testpaths = tests
markers =
    smoke: Quick tests to verify core functionality is working
    regression: Tests that verify existing features still work after changes
    functional: Tests that verify specific feature requirements

What does pytest.ini do?

This configuration file tells pytest where to find your tests (testpaths = tests) and registers custom markers you will use to categorize tests later.

Markers let you run subsets of your test suite. For example, you can run only smoke tests for a quick check, or only regression tests after making changes.

  • Save pytest.ini with Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you have saved pytest.ini before moving on.

ⓧ I'd like to double check the full code

[pytest]
testpaths = tests
markers =
    smoke: Quick tests to verify core functionality is working
    regression: Tests that verify existing features still work after changes
    functional: Tests that verify specific feature requirements
  • Create a conftest.py file. Right-click in the VS Code Explorer sidebar and select New File.
  • Name it conftest.py and press Enter.
  • Paste the following code into the file:
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


@pytest.fixture
def driver():
    options = Options()
    options.add_argument("--start-maximized")
    driver = webdriver.Chrome(options=options)
    yield driver
    driver.quit()

What does this code do?

This file defines a pytest fixture called driver. A fixture is reusable setup code that pytest automatically injects into any test that requests it.

  • Options() configures Chrome to open maximized so elements are visible.
  • webdriver.Chrome(options=options) launches a Chrome browser instance.
  • yield driver hands the browser to the test function. Everything after yield runs as cleanup.
  • driver.quit() closes the browser after the test finishes, even if the test fails.
  • Save conftest.py with Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you have saved conftest.py before moving on.

ⓧ I'd like to double check the full code

import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


@pytest.fixture
def driver():
    options = Options()
    options.add_argument("--start-maximized")
    driver = webdriver.Chrome(options=options)
    yield driver
    driver.quit()
Verify Selenium works with Chrome

Selenium Manager (bundled with Selenium 4.x) handles downloading the correct ChromeDriver automatically. You do not need to install ChromeDriver manually.

  • Run this verification command in your terminal to confirm Selenium can control Chrome:
python -c "from selenium import webdriver; driver = webdriver.Chrome(); driver.get('https://www.saucedemo.com'); print(driver.title); driver.quit()"

Chrome will open briefly, navigate to the SauceDemo website, then close. Your terminal should print Swag Labs.

Chrome didn't open or you see an error?

Make sure Google Chrome is installed and up to date. Selenium Manager downloads the matching ChromeDriver automatically, but it needs Chrome present on your system.

If you see a WebDriverException, check that no firewall or antivirus is blocking the ChromeDriver download.

help me fix a Selenium Chrome WebDriver error

Your test automation environment is ready. Next up, you will explore the application under test and write your first automated login test.

Write Your First Automated Test

Your test environment is ready with Selenium, pytest, and a Chrome WebDriver fixture. Now it's time to write actual tests that automate a browser and verify real application behavior.

Before writing any code, you'll explore the application you're testing to understand its structure. Then you'll write two tests that cover the most fundamental login scenarios: a successful login and a failed login.

In this step, get ready to:
  • Explore SauceDemo and identify the elements your tests interact with.
  • Write a test that verifies successful login.
  • Write a test that verifies error handling for invalid credentials.
Explore the application under test

Every test automation project starts with understanding the application you're testing. You need to know which elements exist on the page and how to locate them in code.

  • Open Google Chrome manually.
  • Navigate to https://www.saucedemo.com in the address bar.

The SauceDemo login page displays accepted usernames and a shared password directly on the page. This is a practice application built specifically for test automation.

  • Identify the three form elements you'll automate: the username field, the password field, and the login button.
  • Note the test credentials displayed on the page: username standard_user and password secret_sauce.

What are locator strategies?

Selenium finds elements on a page using locator strategies. Each strategy targets a different attribute of the HTML element.

  • By.ID finds an element by its id attribute. This is the most reliable strategy when an ID is available.
  • By.CSS_SELECTOR uses CSS selector syntax to find elements. Useful for elements without simple IDs.
  • By.CLASS_NAME finds elements by their CSS class name. Good for groups of similar elements.

On the SauceDemo login page, the username field has ID user-name, the password field has ID password, and the login button has ID login-button. You'll use these IDs in your test code.

Write a basic login test

Your first automated test will log into SauceDemo with valid credentials and verify that the login succeeded. The driver fixture you created in conftest.py handles opening and closing Chrome automatically.

  • Create a new file called test_login.py inside the tests folder.
  • Add the following code to tests/test_login.py:
import pytest
from selenium.webdriver.common.by import By


def test_valid_login(driver):
    # Navigate to the SauceDemo login page
    driver.get("https://www.saucedemo.com")
    # Enter valid username and password
    driver.find_element(By.ID, "user-name").send_keys("standard_user")
    driver.find_element(By.ID, "password").send_keys("secret_sauce")
    # Click the login button
    driver.find_element(By.ID, "login-button").click()
    # Verify we landed on the inventory page
    assert "inventory" in driver.current_url

What does this code do?

  • from selenium.webdriver.common.by import By imports the locator strategy class so you can find elements by ID, CSS selector, etc.
  • def test_valid_login(driver) defines a test function. The driver parameter automatically receives the Chrome WebDriver from your fixture in conftest.py.
  • driver.get() opens the URL in the browser.
  • find_element(By.ID, "user-name") locates the username input by its HTML id attribute.
  • assert "inventory" in driver.current_url checks that the browser navigated to the inventory page after login. If it didn't, the test fails.
  • Save tests/test_login.py.
  • Run your test by entering this command in the terminal:
pytest tests/test_login.py -v
  • A Chrome window opens automatically, navigates to SauceDemo, logs in, and closes.
  • In the terminal, you should see output showing test_valid_login PASSED in green.

Why does pytest find my fixture automatically?

pytest looks for a file called conftest.py in the same directory as your tests (or any parent directory). Any fixture defined there is automatically available to all test functions in that scope without needing an import.

Chrome didn't open or the test failed?

  • Make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.
  • Confirm your conftest.py is in the selenium-tests root folder, not inside the tests folder.
  • Check that Google Chrome is installed and up to date. Selenium Manager handles the driver automatically, but Chrome itself must be present.

Help me debug why my pytest Selenium test isn't running

Test invalid login credentials

A good test suite covers both happy paths and error scenarios. Your second test will verify that the application displays an error message when someone enters the wrong password.

  • In tests/test_login.py, add this second test function below test_valid_login:
def test_invalid_password(driver):
    # Navigate to the SauceDemo login page
    driver.get("https://www.saucedemo.com")
    # Enter valid username but wrong password
    driver.find_element(By.ID, "user-name").send_keys("standard_user")
    driver.find_element(By.ID, "password").send_keys("wrong_password")
    # Click the login button
    driver.find_element(By.ID, "login-button").click()
    # Find the error message using a CSS selector and verify it's visible
    error = driver.find_element(By.CSS_SELECTOR, "[data-test='error']")
    assert error.is_displayed()

What does this test verify?

  • This test uses the same login flow but with wrong_password instead of valid credentials.
  • By.CSS_SELECTOR, "[data-test='error']" finds the error message element using a CSS attribute selector. This targets any element with a data-test attribute set to error.
  • assert error.is_displayed() confirms the error message is visible on the page. If the element exists but is hidden, this assertion would fail.
  • Save tests/test_login.py.
  • Run both tests together by entering this command:
pytest tests/test_login.py -v
  • Chrome opens twice (once for each test), performing the login flow each time.
  • You should see both test_valid_login PASSED and test_invalid_password PASSED in the terminal output.

Why does Chrome open and close twice?

Each test function gets its own fresh browser instance. The driver fixture creates a new Chrome window before each test and calls driver.quit() after it finishes. This isolation ensures one test can never affect another.

One or both tests failing?

  • Check for typos in the element IDs. The username field is user-name (with a hyphen), not username.
  • Make sure the test_invalid_password function is at the same indentation level as test_valid_login (not nested inside it).
  • Verify your internet connection. Selenium needs to reach https://www.saucedemo.com to run the tests.

Help me fix my failing Selenium login tests

✔️ Awesome, I've got everything!

Great. Double check you've saved tests/test_login.py and both tests show PASSED in the terminal output.

ⓧ I'd like to double check the full code

Here is the complete tests/test_login.py file:

import pytest
from selenium.webdriver.common.by import By


def test_valid_login(driver):
    driver.get("https://www.saucedemo.com")
    driver.find_element(By.ID, "user-name").send_keys("standard_user")
    driver.find_element(By.ID, "password").send_keys("secret_sauce")
    driver.find_element(By.ID, "login-button").click()
    assert "inventory" in driver.current_url


def test_invalid_password(driver):
    driver.get("https://www.saucedemo.com")
    driver.find_element(By.ID, "user-name").send_keys("standard_user")
    driver.find_element(By.ID, "password").send_keys("wrong_password")
    driver.find_element(By.ID, "login-button").click()
    error = driver.find_element(By.CSS_SELECTOR, "[data-test='error']")
    assert error.is_displayed()

You just automated your first browser interaction and verified real application behavior with code. Next up, you'll organize your tests using the Page Object Model, the same design pattern professional QA teams use to keep test code clean and maintainable.

Implement the Page Object Model

Your two login tests pass, and you can see Chrome opening and closing automatically. But look at the test code itself. Every test repeats the same find_element calls with hardcoded locator strings scattered everywhere.

The Page Object Model solves this by giving each page in your application its own Python class. Instead of scattering locators across your tests, you store them in one place and wrap interactions in readable methods. In this step, you'll build three page classes and refactor your tests to use them.

In this step, get ready to:
  • Create a BasePage class with reusable browser interaction methods.
  • Create LoginPage and InventoryPage classes that inherit from BasePage.
  • Refactor your existing tests to use page objects instead of raw Selenium calls.
Create the BasePage class

Every page object class will need the same core interactions: finding elements, clicking buttons, typing text, and reading content. A BasePage class provides these shared methods so individual page classes can inherit them without duplicating code.

Why use the Page Object Model?

Without POM, every test file contains raw locator strings and direct Selenium calls. When a developer changes a button's ID, you'd need to update every test that touches that button.

With POM, locators live in one class per page. If a UI element changes, you update one file and all tests that use that page still work. This is why QA teams use POM on real projects.

  • Create a new file called base_page.py inside your pages folder and paste this code:
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):
        self.driver = driver
        self.wait = WebDriverWait(driver, 10)

    def find(self, locator):
        # Wait until the element is visible, then return it
        return self.wait.until(EC.visibility_of_element_located(locator))

    def click(self, locator):
        # Wait until the element is clickable, then click it
        element = self.wait.until(EC.element_to_be_clickable(locator))
        element.click()

    def type(self, locator, text):
        # Clear the field first, then type the new text
        element = self.find(locator)
        element.clear()
        element.send_keys(text)

    def get_text(self, locator):
        # Return the visible text content of an element
        return self.find(locator).text

    def is_visible(self, locator, timeout=5):
        # Check if an element appears within the timeout period
        try:
            WebDriverWait(self.driver, timeout).until(
                EC.visibility_of_element_located(locator)
            )
            return True
        except TimeoutException:
            return False

What does this code do?

  • WebDriverWait creates an explicit wait that pauses up to 10 seconds for an element to appear. This prevents flaky tests caused by pages loading slowly.
  • EC.visibility_of_element_located is an expected condition that checks whether the element is both present in the DOM and visible on the page.
  • EC.element_to_be_clickable waits until an element is visible and enabled before clicking it. This avoids clicking buttons that haven't finished rendering.
  • is_visible() uses a try/except block to return True or False instead of throwing an error when an element is missing.
  • Save pages/base_page.py.
  • You won't see a change yet. The LoginPage and InventoryPage classes you build next will use BasePage as their foundation.

Seeing a red underline on imports?

If VS Code shows an import error on selenium.webdriver.support, make sure your virtual environment is selected as the Python interpreter. Click the Python version in the bottom status bar and choose the one inside your venv folder.

Help me fix the import errors in base_page.py

✔️ Awesome, I've got everything!

Great. Double check you've saved pages/base_page.py before moving on.

ⓧ I'd like to double check the full code

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):
        self.driver = driver
        self.wait = WebDriverWait(driver, 10)

    def find(self, locator):
        return self.wait.until(EC.visibility_of_element_located(locator))

    def click(self, locator):
        element = self.wait.until(EC.element_to_be_clickable(locator))
        element.click()

    def type(self, locator, text):
        element = self.find(locator)
        element.clear()
        element.send_keys(text)

    def get_text(self, locator):
        return self.find(locator).text

    def is_visible(self, locator, timeout=5):
        try:
            WebDriverWait(self.driver, timeout).until(
                EC.visibility_of_element_located(locator)
            )
            return True
        except TimeoutException:
            return False
Create the LoginPage class

The LoginPage class wraps the SauceDemo login screen. It stores locators for the username field, password field, login button, and error message. It also provides methods like login() that bundle multiple interactions into a single readable call.

  • Create a new file called login_page.py inside your pages folder and paste this code:
from selenium.webdriver.common.by import By
from pages.base_page import BasePage


class LoginPage(BasePage):
    # URL for the login page
    URL = "https://www.saucedemo.com"

    # Locators stored as tuples of (By strategy, value)
    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']")

    def open(self):
        # Navigate to the login page
        self.driver.get(self.URL)
        return self

    def login(self, username, password):
        # Type credentials and click the login button
        self.type(self.USERNAME_INPUT, username)
        self.type(self.PASSWORD_INPUT, password)
        self.click(self.LOGIN_BUTTON)
        return self

    def get_error_message(self):
        # Return the text of the error message element
        return self.get_text(self.ERROR_MESSAGE)

    def is_error_displayed(self):
        # Check if the error message is visible on the page
        return self.is_visible(self.ERROR_MESSAGE)

What does this code do?

  • Locators are stored as class-level tuples like (By.ID, "user-name"). This means if an element's ID changes, you update it in one place.
  • login() chains three actions (type username, type password, click button) into one method call. Your tests can now say login_page.login("standard_user", "secret_sauce") instead of repeating three find_element calls.
  • Each method returns self so you can chain method calls together if needed.
  • Save pages/login_page.py.
  • You won't see a change yet. Your test file will import this page in the next chunk.

✔️ Awesome, I've got everything!

Great. Double check you've saved pages/login_page.py before moving on.

ⓧ I'd like to double check the full code

from selenium.webdriver.common.by import By
from pages.base_page import BasePage


class LoginPage(BasePage):
    URL = "https://www.saucedemo.com"
    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']")

    def open(self):
        self.driver.get(self.URL)
        return self

    def login(self, username, password):
        self.type(self.USERNAME_INPUT, username)
        self.type(self.PASSWORD_INPUT, password)
        self.click(self.LOGIN_BUTTON)
        return self

    def get_error_message(self):
        return self.get_text(self.ERROR_MESSAGE)

    def is_error_displayed(self):
        return self.is_visible(self.ERROR_MESSAGE)
Create the InventoryPage class

After a successful login, the user lands on the inventory page. This class captures the product listing, cart badge, and the ability to add items to the cart.

  • Create a new file called inventory_page.py inside your pages folder and paste this code:
from selenium.webdriver.common.by import By
from pages.base_page import BasePage


class InventoryPage(BasePage):
    # Locators for inventory page elements
    TITLE = (By.CLASS_NAME, "title")
    INVENTORY_ITEMS = (By.CLASS_NAME, "inventory_item")
    CART_BADGE = (By.CLASS_NAME, "shopping_cart_badge")
    CART_LINK = (By.CLASS_NAME, "shopping_cart_link")

    def get_page_title(self):
        # Return the page heading text (e.g. "Products")
        return self.get_text(self.TITLE)

    def get_inventory_items_count(self):
        # Count how many product cards are on the page
        items = self.driver.find_elements(*self.INVENTORY_ITEMS)
        return len(items)

    def add_item_to_cart(self, item_id):
        # Click the "Add to cart" button for a specific product
        add_button = (By.ID, f"add-to-cart-{item_id}")
        self.click(add_button)
        return self

    def get_cart_count(self):
        # Return the number displayed on the cart badge
        return self.get_text(self.CART_BADGE)

    def is_cart_badge_visible(self):
        # Check if the cart badge (number) is showing
        return self.is_visible(self.CART_BADGE, timeout=3)

What does this code do?

  • get_inventory_items_count() uses find_elements (plural) to grab all product cards, then returns the count. The *self.INVENTORY_ITEMS syntax unpacks the tuple into the By strategy and value arguments.
  • add_item_to_cart() dynamically builds a locator using an f-string. This means you can pass different product IDs like sauce-labs-backpack without creating a separate locator for each product.
  • is_cart_badge_visible() uses a shorter 3-second timeout because the badge should appear almost instantly after adding an item.
  • Save pages/inventory_page.py.
  • You won't see a change yet. The next substep refactors your tests to use these page classes and runs them to confirm everything works.

✔️ Awesome, I've got everything!

Great. Double check you've saved pages/inventory_page.py before moving on.

ⓧ I'd like to double check the full code

from selenium.webdriver.common.by import By
from pages.base_page import BasePage


class InventoryPage(BasePage):
    TITLE = (By.CLASS_NAME, "title")
    INVENTORY_ITEMS = (By.CLASS_NAME, "inventory_item")
    CART_BADGE = (By.CLASS_NAME, "shopping_cart_badge")
    CART_LINK = (By.CLASS_NAME, "shopping_cart_link")

    def get_page_title(self):
        return self.get_text(self.TITLE)

    def get_inventory_items_count(self):
        items = self.driver.find_elements(*self.INVENTORY_ITEMS)
        return len(items)

    def add_item_to_cart(self, item_id):
        add_button = (By.ID, f"add-to-cart-{item_id}")
        self.click(add_button)
        return self

    def get_cart_count(self):
        return self.get_text(self.CART_BADGE)

    def is_cart_badge_visible(self):
        return self.is_visible(self.CART_BADGE, timeout=3)
Refactor your tests to use page objects

Now that your page classes are ready, you can replace the raw Selenium calls in test_login.py with clean page object method calls. The tests will also move into a TestLogin class for better organization.

  • Open tests/test_login.py and replace the entire file contents with this code:
import pytest
from pages.login_page import LoginPage
from pages.inventory_page import InventoryPage


class TestLogin:
    @pytest.mark.smoke
    def test_valid_login(self, driver):
        # Create page objects and perform login
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "secret_sauce")

        # Verify we landed on the inventory page
        inventory_page = InventoryPage(driver)
        assert inventory_page.get_page_title() == "Products"

    @pytest.mark.functional
    def test_invalid_password(self, driver):
        # Attempt login with wrong password
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "wrong_password")

        # Verify the error message appears
        assert login_page.is_error_displayed()
        assert "Username and password do not match" in login_page.get_error_message()

What changed?

  • The imports changed from selenium.webdriver.common.by to your own LoginPage and InventoryPage classes.
  • Raw driver.find_element() calls are replaced with readable methods like login_page.login() and inventory_page.get_page_title().
  • Tests are grouped inside a TestLogin class. Each test method takes self as its first argument, plus the driver fixture.
  • pytest markers (@pytest.mark.smoke and @pytest.mark.functional) categorize each test. You'll use these in a later step to run specific test subsets.
  • Save tests/test_login.py.
  • Run your tests to confirm the refactoring didn't break anything by running this command:
pytest tests/test_login.py -v
  • You should see output showing both tests with PASSED next to each one, similar to tests/test_login.py::TestLogin::test_valid_login PASSED.

Seeing ModuleNotFoundError?

If you see an error about pages.login_page or pages.base_page not being found, make sure you have __init__.py files inside both the pages and tests folders. These files can be empty, but they tell Python to treat the folders as packages.

Also confirm you're running pytest from the selenium-tests folder (not from inside tests or pages).

Help me fix the ModuleNotFoundError in my test run

✔️ Awesome, I've got everything!

Great. Double check you've saved tests/test_login.py and confirmed both tests pass.

ⓧ I'd like to double check the full code

import pytest
from pages.login_page import LoginPage
from pages.inventory_page import InventoryPage


class TestLogin:
    @pytest.mark.smoke
    def test_valid_login(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "secret_sauce")

        inventory_page = InventoryPage(driver)
        assert inventory_page.get_page_title() == "Products"

    @pytest.mark.functional
    def test_invalid_password(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "wrong_password")

        assert login_page.is_error_displayed()
        assert "Username and password do not match" in login_page.get_error_message()

Your tests now read like plain English describing user actions. Next up, you'll expand this foundation into a full test suite covering login edge cases, inventory browsing, and cart operations.

Build Your Functional Test Suite

Your Page Object Model is ready to support a growing test suite. Right now you have two login tests, but a real QA team needs comprehensive coverage across multiple features.

In this step, you will expand your login tests to cover common failure scenarios and create a brand new test file for inventory and cart functionality. By the end, you will have 10 automated tests covering different areas of the application.

In this step, get ready to:
  • Add three negative login tests for locked-out users, empty usernames, and empty passwords.
  • Write inventory and cart tests in a new test file with an autouse fixture.
  • Run all 10 tests and confirm they pass.
Expand login test coverage

A login form has many ways to fail. Testing only the "happy path" leaves gaps where bugs hide. You need tests for locked accounts, missing usernames, and missing passwords.

  • In VS Code, open tests/test_login.py.
  • Find the end of the test_invalid_password method (after the last assert line).
  • Add three new test methods below it by copying the following code:
    @pytest.mark.functional
    def test_locked_out_user(self, driver):
        # Test that a locked-out account sees the correct error
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("locked_out_user", "secret_sauce")

        assert login_page.is_error_displayed()
        assert "locked out" in login_page.get_error_message()

    @pytest.mark.functional
    def test_empty_username(self, driver):
        # Test that submitting with no username shows a required field error
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("", "secret_sauce")

        assert login_page.is_error_displayed()
        assert "Username is required" in login_page.get_error_message()

    @pytest.mark.functional
    def test_empty_password(self, driver):
        # Test that submitting with no password shows a required field error
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "")

        assert login_page.is_error_displayed()
        assert "Password is required" in login_page.get_error_message()

What do these tests do?

Each test targets a specific way a login can fail. test_locked_out_user uses a real test account that SauceDemo intentionally blocks. test_empty_username and test_empty_password verify that the form validates required fields before submitting.

All three follow the same pattern: open the page, attempt login with bad input, and assert that the correct error message appears. This is called negative testing.

  • Save tests/test_login.py.
  • Run the login tests by running this command in your terminal:
pytest tests/test_login.py -v

You should see 5 tests listed, each showing PASSED next to the test name.

Seeing a test failure?

  • Check that the new methods are indented inside the TestLogin class (4 spaces of indentation for the decorator, 8 spaces for the method body).
  • Make sure you placed the new methods after test_invalid_password, not outside the class.
  • Verify the error message strings match exactly: "locked out", "Username is required", and "Password is required".

Why aren't my new login tests passing?

✔️ Awesome, I've got everything!

Great. Double check you have saved tests/test_login.py before moving on.

ⓧ I'd like to double check the full code

import pytest
from pages.login_page import LoginPage
from pages.inventory_page import InventoryPage


class TestLogin:
    @pytest.mark.smoke
    def test_valid_login(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "secret_sauce")

        inventory_page = InventoryPage(driver)
        assert inventory_page.get_page_title() == "Products"

    @pytest.mark.functional
    def test_invalid_password(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "wrong_password")

        assert login_page.is_error_displayed()
        assert "Username and password do not match" in login_page.get_error_message()

    @pytest.mark.functional
    def test_locked_out_user(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("locked_out_user", "secret_sauce")

        assert login_page.is_error_displayed()
        assert "locked out" in login_page.get_error_message()

    @pytest.mark.functional
    def test_empty_username(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("", "secret_sauce")

        assert login_page.is_error_displayed()
        assert "Username is required" in login_page.get_error_message()

    @pytest.mark.functional
    def test_empty_password(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "")

        assert login_page.is_error_displayed()
        assert "Password is required" in login_page.get_error_message()
Write inventory and cart tests

Login is only the entry point. The real value of SauceDemo is its inventory page where users browse products and add them to a cart. You need a separate test file dedicated to this functionality.

  • Create a new file called test_inventory.py inside the tests folder.
  • Add the following code to tests/test_inventory.py:
import pytest
from pages.login_page import LoginPage
from pages.inventory_page import InventoryPage


class TestInventory:
    @pytest.fixture(autouse=True)
    def login(self, driver):
        # Automatically log in before each test in this class
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "secret_sauce")

    @pytest.mark.smoke
    def test_products_displayed(self, driver):
        # Verify the inventory page loads with the expected product count
        inventory_page = InventoryPage(driver)
        assert inventory_page.get_page_title() == "Products"
        assert inventory_page.get_inventory_items_count() == 6

    @pytest.mark.functional
    def test_add_item_to_cart(self, driver):
        # Add one item and verify the cart badge updates
        inventory_page = InventoryPage(driver)
        inventory_page.add_item_to_cart("sauce-labs-backpack")

        assert inventory_page.get_cart_count() == "1"

    @pytest.mark.regression
    def test_add_multiple_items_to_cart(self, driver):
        # Add two items and verify the cart badge shows the correct total
        inventory_page = InventoryPage(driver)
        inventory_page.add_item_to_cart("sauce-labs-backpack")
        inventory_page.add_item_to_cart("sauce-labs-bike-light")

        assert inventory_page.get_cart_count() == "2"

What does the autouse fixture do?

The @pytest.fixture(autouse=True) decorator on the login method means pytest automatically runs it before every test in the TestInventory class. You do not need to pass it as a parameter.

This eliminates repetitive login code at the start of every test. Each inventory test starts already logged in and on the inventory page.

Why are some tests marked differently?

The @pytest.mark.smoke marker on test_products_displayed means it is a quick sanity check that confirms the page loads correctly. The @pytest.mark.regression marker on test_add_multiple_items_to_cart flags it as a test that catches bugs in existing features after code changes.

You will use these markers in the next step to run targeted subsets of your test suite.

  • Save tests/test_inventory.py.
  • Run just the inventory tests to confirm they work by running this command:
pytest tests/test_inventory.py -v

You should see 3 tests listed, each showing PASSED. Chrome will open and close three times as each test gets its own fresh browser session.

Cart tests failing?

  • Check that the item IDs match exactly: sauce-labs-backpack and sauce-labs-bike-light. These are the hyphenated product slugs that SauceDemo uses in its button IDs.
  • Make sure the login fixture is indented inside the TestInventory class and has autouse=True set.
  • Verify that pages/inventory_page.py has the add_item_to_cart method from the previous step.

Why are my inventory cart tests failing when the login tests pass?

✔️ Awesome, I've got everything!

Great. Double check you have saved tests/test_inventory.py before moving on.

ⓧ I'd like to double check the full code

import pytest
from pages.login_page import LoginPage
from pages.inventory_page import InventoryPage


class TestInventory:
    @pytest.fixture(autouse=True)
    def login(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "secret_sauce")

    @pytest.mark.smoke
    def test_products_displayed(self, driver):
        inventory_page = InventoryPage(driver)
        assert inventory_page.get_page_title() == "Products"
        assert inventory_page.get_inventory_items_count() == 6

    @pytest.mark.functional
    def test_add_item_to_cart(self, driver):
        inventory_page = InventoryPage(driver)
        inventory_page.add_item_to_cart("sauce-labs-backpack")

        assert inventory_page.get_cart_count() == "1"

    @pytest.mark.regression
    def test_add_multiple_items_to_cart(self, driver):
        inventory_page = InventoryPage(driver)
        inventory_page.add_item_to_cart("sauce-labs-backpack")
        inventory_page.add_item_to_cart("sauce-labs-bike-light")

        assert inventory_page.get_cart_count() == "2"
Run the full suite and review results

Now it is time to run every test together and see the full picture of your test coverage.

  • Run all tests across both files by running this command:
pytest -v

What should I see?

The -v flag stands for verbose. It tells pytest to print each test on its own line with its full path (like tests/test_login.py::TestLogin::test_valid_login PASSED) instead of just dots.

You should see 10 tests total. Five from test_login.py and three from test_inventory.py. All should show PASSED. The final summary line reads something like 10 passed.

You should see all 10 tests pass with PASSED next to each test name, grouped by file.

Not seeing 10 tests?

  • Confirm your pytest.ini has testpaths = tests so pytest knows where to look for test files.
  • Make sure both tests/test_login.py and tests/test_inventory.py are saved.
  • Check that tests/__init__.py exists (even if empty). This file lets Python treat the folder as a package.

Help me figure out why pytest is not finding all 10 of my tests.

You now have a comprehensive test suite covering login validation, product display, and cart operations. Next up, you will categorize these tests with markers and generate a professional HTML report.

Categorize Tests and Generate Reports

In the previous step, you built a complete test suite with 10 automated tests covering login, inventory, and cart functionality. Now it's time to organize those tests into meaningful categories and generate professional documentation of your results.

Real QA teams don't run all their tests every time. They categorize tests so they can run quick sanity checks on every build, feature-specific tests during development, and full regression sweeps overnight. You'll use pytest markers to bring that same workflow to your test suite.

In this step, get ready to:
  • Categorize your tests with smoke, functional, and regression markers.
  • Run targeted test subsets using the -m flag.
  • Generate a professional HTML test report with pytest-html.
Add pytest markers to categorize tests

Test markers are decorators that label each test with a category. Your pytest.ini already defines three custom markers. Now you'll apply them to your test functions.

What are smoke, functional, and regression tests?

Smoke tests are quick sanity checks that verify core functionality is working. Teams run these first on every build to catch obvious failures fast.

Functional tests verify that specific feature requirements work correctly. These cover the detailed behaviors of each feature.

Regression tests confirm that existing features still work after code changes. These catch bugs introduced by new code.

  • Open tests/test_login.py in VS Code.
  • Verify that each test method has a marker decorator above it, matching this code:
import pytest
from pages.login_page import LoginPage
from pages.inventory_page import InventoryPage


class TestLogin:
    @pytest.mark.smoke
    def test_valid_login(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "secret_sauce")

        inventory_page = InventoryPage(driver)
        assert inventory_page.get_page_title() == "Products"

    @pytest.mark.functional
    def test_invalid_password(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "wrong_password")

        assert login_page.is_error_displayed()
        assert "Username and password do not match" in login_page.get_error_message()

    @pytest.mark.functional
    def test_locked_out_user(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("locked_out_user", "secret_sauce")

        assert login_page.is_error_displayed()
        assert "locked out" in login_page.get_error_message()

    @pytest.mark.functional
    def test_empty_username(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("", "secret_sauce")

        assert login_page.is_error_displayed()
        assert "Username is required" in login_page.get_error_message()

    @pytest.mark.functional
    def test_empty_password(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "")

        assert login_page.is_error_displayed()
        assert "Password is required" in login_page.get_error_message()

What does this code do?

  • @pytest.mark.smoke on test_valid_login marks it as a quick sanity check. If logging in doesn't work, nothing else will.
  • @pytest.mark.functional on the remaining four tests marks them as feature-specific verification. Each one tests a distinct login scenario.
  • Now open tests/test_inventory.py and verify the markers match this code:
import pytest
from pages.login_page import LoginPage
from pages.inventory_page import InventoryPage


class TestInventory:
    @pytest.fixture(autouse=True)
    def login(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "secret_sauce")

    @pytest.mark.smoke
    def test_products_displayed(self, driver):
        inventory_page = InventoryPage(driver)
        assert inventory_page.get_page_title() == "Products"
        assert inventory_page.get_inventory_items_count() == 6

    @pytest.mark.functional
    def test_add_item_to_cart(self, driver):
        inventory_page = InventoryPage(driver)
        inventory_page.add_item_to_cart("sauce-labs-backpack")

        assert inventory_page.get_cart_count() == "1"

    @pytest.mark.regression
    def test_add_multiple_items_to_cart(self, driver):
        inventory_page = InventoryPage(driver)
        inventory_page.add_item_to_cart("sauce-labs-backpack")
        inventory_page.add_item_to_cart("sauce-labs-bike-light")

        assert inventory_page.get_cart_count() == "2"

What does this code do?

  • @pytest.mark.smoke on test_products_displayed makes it a quick sanity check. If the inventory page loads with 6 items, the app is alive.
  • @pytest.mark.functional on test_add_item_to_cart marks it as a feature verification test.
  • @pytest.mark.regression on test_add_multiple_items_to_cart marks it as a regression test. Adding multiple items is a workflow that could easily break during refactoring.
  • Save both files.

How does this map to real QA workflows?

Smoke tests run on every build as a quick sanity check. If they fail, the team knows something fundamental is broken before running the full suite.

Functional tests run during development to verify feature requirements. Regression suites typically run nightly or before a release to catch unintended breakage.

Run test subsets by marker

The -m flag tells pytest to run only tests tagged with a specific marker. This lets you target exactly the tests you need for each situation.

  • Run only your smoke tests by running this command:
pytest -m smoke -v

What should I see?

You should see exactly 2 tests run: test_valid_login and test_products_displayed. Both should pass with green checkmarks. The remaining 8 tests are skipped because they don't carry the smoke marker.

  • Run only your functional tests by running this command:
pytest -m functional -v

What should I see?

You should see 6 tests run: the four login edge-case tests plus test_add_item_to_cart. All should pass.

  • Run only your regression tests by running this command:
pytest -m regression -v

What should I see?

You should see exactly 1 test run: test_add_multiple_items_to_cart. It should pass.

Seeing 0 tests collected?

Make sure the marker decorator is placed directly above the test method, inside the class. Also check that the marker name in your @pytest.mark.smoke decorator exactly matches the marker name in your pytest.ini file.

Help me figure out why pytest is collecting 0 tests when I use the -m flag.

Generate an HTML test report

A shareable test report is essential for communicating results to your team. The pytest-html plugin generates a professional HTML report with summary statistics, individual test results, and execution times.

  • Generate your HTML report by running this command:
pytest --html=reports/test_report.html --self-contained-html -v

What does this command do?

  • --html=reports/test_report.html tells pytest-html where to save the report file.
  • --self-contained-html bundles all CSS and assets into a single HTML file. This makes it easy to share the report without needing extra files.
  • -v enables verbose output so you can see each test result in the terminal as well.
  • Open the generated report in Chrome. You can find it at reports/test_report.html inside your selenium-tests folder.
  • Double-click the file to open it in your browser.

You should see a professional report with a summary table at the top showing total tests, pass count, and duration. Below that, each individual test is listed with its status, duration, and the file it belongs to.

Report file not found?

Make sure the reports directory exists inside your selenium-tests folder. If it doesn't exist, create it by running mkdir reports in your terminal, then run the pytest command again.

Help me figure out why my HTML test report wasn't generated.

✔️ Awesome, I've got everything!

Great. Make sure both test files are saved and your report opened in Chrome successfully.

ⓧ I'd like to double check the full code

import pytest
from pages.login_page import LoginPage
from pages.inventory_page import InventoryPage


class TestLogin:
    @pytest.mark.smoke
    def test_valid_login(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "secret_sauce")

        inventory_page = InventoryPage(driver)
        assert inventory_page.get_page_title() == "Products"

    @pytest.mark.functional
    def test_invalid_password(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "wrong_password")

        assert login_page.is_error_displayed()
        assert "Username and password do not match" in login_page.get_error_message()

    @pytest.mark.functional
    def test_locked_out_user(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("locked_out_user", "secret_sauce")

        assert login_page.is_error_displayed()
        assert "locked out" in login_page.get_error_message()

    @pytest.mark.functional
    def test_empty_username(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("", "secret_sauce")

        assert login_page.is_error_displayed()
        assert "Username is required" in login_page.get_error_message()

    @pytest.mark.functional
    def test_empty_password(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "")

        assert login_page.is_error_displayed()
        assert "Password is required" in login_page.get_error_message()
import pytest
from pages.login_page import LoginPage
from pages.inventory_page import InventoryPage


class TestInventory:
    @pytest.fixture(autouse=True)
    def login(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "secret_sauce")

    @pytest.mark.smoke
    def test_products_displayed(self, driver):
        inventory_page = InventoryPage(driver)
        assert inventory_page.get_page_title() == "Products"
        assert inventory_page.get_inventory_items_count() == 6

    @pytest.mark.functional
    def test_add_item_to_cart(self, driver):
        inventory_page = InventoryPage(driver)
        inventory_page.add_item_to_cart("sauce-labs-backpack")

        assert inventory_page.get_cart_count() == "1"

    @pytest.mark.regression
    def test_add_multiple_items_to_cart(self, driver):
        inventory_page = InventoryPage(driver)
        inventory_page.add_item_to_cart("sauce-labs-backpack")
        inventory_page.add_item_to_cart("sauce-labs-bike-light")

        assert inventory_page.get_cart_count() == "2"

Your test suite is now professionally organized and documented. You can run quick smoke tests in seconds, target specific features, or generate a full report to share with your team. Next up, you'll tackle the Secret Mission and think like a real QA engineer hunting for bugs.

Secret mission

Document a Defect with the Problem User

SauceDemo has a built-in buggy user account called problem_user. In this mission, you will write a test that exposes the bug, then document it using a professional defect report format that QA teams use in real projects.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project folder for future reference or delete it to free up disk space.

Resources you used:

  • The selenium-tests project folder on your Desktop (contains all test code, page objects, reports, and the virtual environment with installed packages).

Keep everything running

No action needed. Choose this if you plan to keep building on your test automation framework or want to reference the code later.

  • Your selenium-tests folder stays on your Desktop ready for further development.
  • The virtual environment and all installed packages remain available whenever you reactivate it.
  • Chrome browser sessions are already closed automatically by the pytest fixture teardown, so no lingering processes to worry about.

Pause - I'll come back to this later

Deactivate the virtual environment to free up your terminal session. Your files stay intact for when you return.

  • Deactivate the virtual environment by running:
deactivate
  • Your project folder and all files remain on your Desktop.
  • When you return, reactivate the environment with venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux).

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes your test code, page objects, reports, and the virtual environment.

  • Deactivate the virtual environment first (if it is still active):
deactivate
  • Navigate to your Desktop and delete the project folder.

On macOS/Linux:

cd ~/Desktop
rm -rf selenium-tests

On Windows:

cd ~\Desktop
rmdir /s /q selenium-tests
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows). You should no longer see selenium-tests in the output.

Nice Work!

Nice Work!

Nice work! You just built a complete test automation framework using Selenium WebDriver, Python, and pytest.

You've learned how to:

  • Build a Selenium WebDriver test automation framework structured with the Page Object Model design pattern.
  • Write 10 automated tests covering smoke, regression, and functional testing scenarios using pytest markers to run targeted test subsets.
  • Generate a professional HTML test report with pytest-html documenting pass/fail results, execution time, and summary statistics.
  • Secret Mission: Identified and documented a software defect using professional QA defect reporting format with the problem_user account.

Ready to quiz yourself?