Automated Test Suite with Selenium

Build a pytest + Selenium test suite using the Page Object Model pattern.

Introduction

30 Second Summary

Every time a web application gets updated, someone has to click through every page to make sure nothing broke. QA teams do this manually, over and over, and it's slow, repetitive, and easy to miss things.

In this project, you will build an automated test suite that controls Chrome on its own, logging into a real e-commerce web application and verifying that login, cart, and error-handling flows all work correctly. You'll use Python, Selenium WebDriver, and pytest to write the exact kind of test suite that enterprise QA teams maintain in production.

What You'll Build

You'll run a single command and watch Chrome launch by itself, navigate to a login page, type credentials, click buttons, and report back whether everything passed or failed.

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

  • Tests that automatically open Chrome, log into SauceDemo, and verify the inventory page loads with products.
  • A single pytest command that produces a pass/fail report for login scenarios including valid users, locked-out users, and wrong passwords.
  • A professional Page Object Model structure that organizes your code the exact way interviewers ask about in QA job interviews.
  • Secret Mission: Add a GitHub Actions CI pipeline that runs your tests automatically on every push.

Are there any prerequisites?

You'll need basic Python knowledge (variables, functions, and classes) and a GitHub account. No prior experience with test automation or Selenium is required.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

Committing to what you're building sharpens your focus throughout the project. It also helps you explain this work clearly in QA interviews later.

Set Up Your Environment

You need a working Python environment with Selenium and pytest before you can write your first test. Without these tools installed and verified, nothing else in the project works.

In this step, you will install Python, set up VS Code as your editor, and create an isolated project environment with Selenium and pytest ready to go.

In this step, get ready to:
  • Install Python 3.14 and verify it works.
  • Install VS Code with the Python extension.
  • Create a project folder with a virtual environment and dependencies.
Install Python 3.14

Python is the programming language that powers your test automation. Selenium and pytest are both Python libraries, so Python needs to be installed first.

  • Press the Windows key to open your search bar.
  • Type Command Prompt and press Enter to open it.
  • Check if Python is already installed by running this command:
python --version

✔️ I see Python 3.14

You already have the correct version installed. Skip ahead to the next substep.

ⓧ I see an older Python version

You have Python installed, but the project requires 3.14. Installing the new version will update your PATH to point to 3.14.

  • Open your browser and go to the Python 3.14 release page:
  • https://www.python.org/downloads/release/python-3146/
  • Scroll down to Files and click Windows installer (64-bit) to download python-3.14.6-amd64.exe.
  • Run the installer once it finishes downloading.
  • On the first screen, check the box that says Add python.exe to PATH at the bottom.
  • Click Install Now.
  • Close your current Command Prompt window completely.
  • Open a new Command Prompt (press the Windows key, type Command Prompt, press Enter).
  • Run python --version again. You should now see Python 3.14.6.

Still seeing the old version?

Make sure you closed and reopened Command Prompt after installing. PATH changes only take effect in new terminal sessions.

help me fix Python version issues on Windows

ⓧ I don't see a Python version

Python is not installed on your machine yet. You might see a message like Python was not found or 'python' is not recognized. The Microsoft Store may also open. Close it if so.

  • Open your browser and go to the Python 3.14 release page:
  • https://www.python.org/downloads/release/python-3146/
  • Scroll down to Files and click Windows installer (64-bit) to download python-3.14.6-amd64.exe.
  • Run the installer once it finishes downloading.
  • On the first screen, check the box that says Add python.exe to PATH at the bottom. This is critical.
  • Click Install Now.
  • Close your current Command Prompt window completely.
  • Open a new Command Prompt (press the Windows key, type Command Prompt, press Enter).
  • Confirm the installation by running:
python --version

You should see Python 3.14.6 printed in the terminal.

Still not working?

If you still see an error, the PATH was not set correctly. Re-run the installer, choose Modify, and make sure Add python.exe to PATH is checked.

help me get Python working on Windows 11

Why check the PATH box?

PATH is a list of folders your operating system searches when you type a command. Without Python on the PATH, your terminal cannot find the python command.

Install VS Code and create your project

VS Code is the code editor where you will write all your test files. Its built-in terminal lets you run commands without switching windows.

  • Open your browser and go to https://code.visualstudio.com.
  • Click the large Download for Windows button.
  • Run the installer and accept all the default options.
  • Once installation finishes, open VS Code by pressing the Windows key, typing Visual Studio Code, and pressing Enter.

VS Code opens with a Welcome tab. Now install the Python extension so VS Code understands your code.

  • Click the Extensions icon in the left sidebar (it looks like four small squares).
  • Type Python in the search bar at the top of the Extensions panel.
  • Click Install on the first result (published by Microsoft).

What does the Python extension do?

It adds syntax highlighting, error detection, and autocomplete for Python files. It also helps VS Code find your Python interpreter.

Now create the project folder on your Desktop.

  • Open the integrated terminal by pressing Ctrl+` (the backtick key, above Tab).
  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir saucedemo-tests
  • Confirm the folder was created by running:
ls

You should see saucedemo-tests listed in the output.

  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the saucedemo-tests folder, and click Select Folder.

Seeing a trust dialog?

VS Code may ask "Do you trust the authors of the files in this folder?" when you open a new folder. Click Yes, I trust the authors to continue.

VS Code reloads with your project folder open. The Explorer sidebar on the left now shows SAUCEDEMO-TESTS as the workspace name.

Don't see the folder name in the sidebar?

Make sure you selected the saucedemo-tests folder itself, not a file inside it. Try File > Open Folder again and double-click into saucedemo-tests before clicking Select Folder.

help me open a project folder in VS Code

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.

  • Open the integrated terminal in VS Code by pressing Ctrl+`.
  • Create the virtual environment by running this command:
python -m venv venv
  • Activate the virtual environment by running:
venv\Scripts\activate

Your terminal prompt now shows (venv) at the beginning. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

If you see an error about running scripts being disabled, you need to allow script execution. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell, then try the activate command again.

help me activate a Python virtual environment on Windows

Now create the file that lists your project dependencies.

  • In the Explorer sidebar, hover over SAUCEDEMO-TESTS and click the new file icon (a page with a plus sign).
  • Type requirements.txt as the file name and press Enter.
  • Add the following dependencies to the file:
selenium==4.46.0
pytest==9.1.1

What does this file do?

The requirements.txt file pins exact versions of your dependencies. This ensures anyone who runs your project gets the same package versions you tested with.

  • selenium==4.46.0 is the browser automation library that controls Chrome.
  • pytest==9.1.1 is the test runner that discovers and executes your tests.
  • Save the file with Ctrl+S.
  • Install the dependencies by running this command in the terminal:
pip install -r requirements.txt

You should see output showing both packages downloading and installing successfully.

Seeing a permission error or install failure?

Make sure your terminal still shows (venv) at the start of the prompt. If it disappeared, re-activate with venv\Scripts\activate and try the install again.

help me install Python packages with pip

✔️ Awesome, I've got everything!

Great. Double check you saved requirements.txt and the install command completed without errors.

ⓧ I'd like to double check the full code

Your requirements.txt should contain exactly this:

selenium==4.46.0
pytest==9.1.1

Before you run the final check, what do you think pytest --version will print if everything installed correctly?

  • Verify your entire setup by running:
pytest --version

You should see the pytest version number printed (e.g. pytest 9.1.1). This confirms Python, pip, and pytest are all working.

Command not recognized?

If pytest is not recognized, your virtual environment may not be active. Run venv\Scripts\activate again, then retry pytest --version.

help me get pytest working

Your environment is ready to go. Next up, you will write your first automated test and watch Chrome move on its own.

Write Your First Automated Test

Your Python environment is set up and ready to go. Selenium and pytest are installed, and your project folder is open in VS Code.

Now comes the exciting part. You are going to write a test that makes Chrome launch and navigate to a website completely on its own, driven entirely by your Python code.

In this step, get ready to:
  • Create a test file that launches Chrome and navigates to SauceDemo.
  • Run the test and watch Chrome open automatically.
  • Break the test on purpose to see what failure looks like.
Create the tests folder and your first test file

Every pytest project keeps its tests in a dedicated folder. This keeps test code separate from application code.

  • Create the tests folder by running this command in your VS Code terminal:
mkdir tests
  • In the VS Code Explorer sidebar, right-click the tests folder.
  • Click New File.
  • Name the file test_first.py.

Why the file name starts with test_

pytest automatically discovers test files by looking for files that start with test_. If you name it something else, pytest will not find it.

  • Copy the following code into test_first.py:
from selenium import webdriver

def test_page_title():
    # Launch a new Chrome browser window
    browser = webdriver.Chrome()
    # Navigate to the SauceDemo login page
    browser.get("https://www.saucedemo.com")
    # Verify the page title contains "Swag Labs"
    assert "Swag Labs" in browser.title
    # Close the browser when done
    browser.quit()

What does this code do?

  • from selenium import webdriver imports Selenium's browser control module.
  • webdriver.Chrome() launches a new Chrome window. Selenium Manager automatically downloads the correct driver for your Chrome version.
  • browser.get("https://www.saucedemo.com") navigates Chrome to the SauceDemo login page.
  • assert "Swag Labs" in browser.title checks that the page title contains the expected text. If it does not, the test fails.
  • browser.quit() closes Chrome after the test finishes.
  • Save test_first.py.
  • Confirm the file exists by running:
ls tests/

You should see test_first.py listed in the output.

Don't see test_first.py?

  • Make sure you created the file inside the tests folder, not in the project root.
  • Check the spelling. The file must be named exactly test_first.py (not test-first.py or testfirst.py).
  • help me find my test file
Run the test and see what failure looks like

Your test file is ready. Before you run it, take a moment to predict what will happen.

What do you think you will see? Will Chrome actually open on its own?

  • Run your first test by entering this command:
pytest tests/test_first.py -v

Chrome launches automatically, navigates to SauceDemo, and closes itself. In your terminal, you see a line ending in PASSED and a summary showing 1 passed in green.

That is Selenium WebDriver in action. Your Python code just drove a real browser through a real website.

Chrome did not open?

  • Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt.
  • Confirm Chrome is installed on your machine. Selenium needs a Chrome browser present to control it.
  • If you see a driver-related error, Selenium Manager should handle this automatically. Try running the test a second time in case the driver download was interrupted.
  • help me debug why Chrome is not launching

Now let's see what a failing test looks like. Knowing how to read failure output is just as important as writing passing tests.

  • In test_first.py, find this line:
    assert "Swag Labs" in browser.title
  • Change "Swag Labs" to "Wrong Title", so the line looks like this:
    assert "Wrong Title" in browser.title
  • Save the file.

What do you think will happen now that the expected title does not match the real page?

  • Run the test again:
pytest tests/test_first.py -v

Chrome opens and navigates to SauceDemo again. This time the terminal shows FAILED in red. pytest prints the assertion line and shows you exactly what it found versus what you expected.

How to read the failure output

pytest rewrites your assertion to show the actual values. You will see a line like AssertionError: assert 'Wrong Title' in 'Swag Labs'. This tells you the real page title was Swag Labs but your test expected Wrong Title.

In real QA work, this is how you catch bugs. If a developer accidentally changes the page title, your test fails and shows exactly what changed.

  • Change the assertion back to the correct value. Find this line:
    assert "Wrong Title" in browser.title
  • Replace "Wrong Title" with "Swag Labs", so the line looks like this:
    assert "Swag Labs" in browser.title
  • Save the file and run the test one final time to confirm it passes:
pytest tests/test_first.py -v

You should see 1 passed in green again. Your test is back to a working state.

✔️ Awesome, I've got everything!

Great. Double check you have saved test_first.py with the correct assertion.

ⓧ I'd like to double check the full code

Here is the complete tests/test_first.py file:

from selenium import webdriver

def test_page_title():
    # Launch a new Chrome browser window
    browser = webdriver.Chrome()
    # Navigate to the SauceDemo login page
    browser.get("https://www.saucedemo.com")
    # Verify the page title contains "Swag Labs"
    assert "Swag Labs" in browser.title
    # Close the browser when done
    browser.quit()

You just watched Chrome move on its own, driven entirely by your Python code. Next up, you will write tests that verify real user workflows like logging in, handling errors, and adding products to a cart.

Test Login and Cart Flows

In the previous step, you proved Selenium can open Chrome and check a page title. That is a solid foundation, but a single assertion on the page title is not going to land you a QA role.

Real QA engineers test user workflows. Can a valid user log in? Does a locked-out user see the right error? Does the cart update when a product is added? In this step, you will write tests for all of these scenarios. Along the way, you will notice something interesting about how the code starts to look.

In this step, get ready to:
  • Write three login tests covering valid login, locked-out users, and wrong passwords.
  • Write two cart tests verifying add-to-cart and remove-from-cart behaviour.
  • Spot a maintenance problem hiding in your test code.
Write the login tests

The SauceDemo login page has three interesting scenarios to test. A standard user should log in successfully. A locked-out user should see an error. A wrong password should also trigger an error.

  • Create a new file called test_login.py inside the tests/ folder.
  • Add the import, class definition, and first test by pasting this code:
from selenium import webdriver

class TestLogin:
    def test_successful_login(self):
        # Launch a fresh Chrome browser
        driver = webdriver.Chrome()
        driver.get("https://www.saucedemo.com")
        # Fill in valid credentials and click login
        driver.find_element("id", "user-name").send_keys("standard_user")
        driver.find_element("id", "password").send_keys("secret_sauce")
        driver.find_element("id", "login-button").click()
        # Verify the inventory page loads
        assert driver.find_element("class name", "inventory_list").is_displayed()
        driver.quit()

What does this code do?

This test launches Chrome, navigates to SauceDemo, fills in the username and password fields using their element IDs, and clicks the login button.

The assertion checks that an element with class inventory_list appears on the page. That element only exists on the products page after a successful login.

  • Save tests/test_login.py.
  • Run your first login test by running this command:
pytest tests/test_login.py -v

Chrome opens, navigates to SauceDemo, logs in, and closes. The terminal shows 1 passed in green.

Test failing with NoSuchElementException?

This means Selenium cannot find the element on the page. Check that your element IDs match exactly: user-name, password, login-button. A single typo will cause this error.

help me debug a NoSuchElementException in my Selenium login test

Now add the second test. This one verifies that a locked-out user sees an error message instead of the products page.

  • In tests/test_login.py, add this method below test_successful_login (inside the TestLogin class):
    def test_locked_out_user(self):
        # Launch Chrome and navigate to SauceDemo
        driver = webdriver.Chrome()
        driver.get("https://www.saucedemo.com")
        # Try logging in with the locked_out_user
        driver.find_element("id", "user-name").send_keys("locked_out_user")
        driver.find_element("id", "password").send_keys("secret_sauce")
        driver.find_element("id", "login-button").click()
        # Verify an error message mentioning "locked out" appears
        error = driver.find_element("css selector", "[data-test='error']").text
        assert "locked out" in error.lower()
        driver.quit()

What does this test verify?

SauceDemo has a user called locked_out_user who can never log in. This test proves your app handles that case correctly by checking the error message element (located with a CSS attribute selector) contains the text "locked out".

  • Save tests/test_login.py and run:
pytest tests/test_login.py -v

You should see 2 passed. Chrome opens twice (once per test), and both assertions succeed.

Second test failing?

Make sure the CSS selector is exactly [data-test='error'] with single quotes inside the brackets. Also confirm the method is indented inside the TestLogin class (same indent level as the first test).

help me fix my locked_out_user Selenium test

Time for the third test. This one submits a wrong password and checks for the appropriate error.

  • Add this method below test_locked_out_user (still inside TestLogin):
    def test_invalid_password(self):
        # Launch Chrome and navigate to SauceDemo
        driver = webdriver.Chrome()
        driver.get("https://www.saucedemo.com")
        # Try logging in with a wrong password
        driver.find_element("id", "user-name").send_keys("standard_user")
        driver.find_element("id", "password").send_keys("wrong_password")
        driver.find_element("id", "login-button").click()
        # Verify the error mentions credentials not matching
        error = driver.find_element("css selector", "[data-test='error']").text
        assert "do not match" in error.lower()
        driver.quit()

Why check for partial text?

The assertion uses "do not match" in error.lower() instead of checking the full error string. This makes the test resilient to minor wording changes while still verifying the correct error category appears.

  • Save tests/test_login.py and run all three login tests:
pytest tests/test_login.py -v

Chrome opens three times. The terminal shows 3 passed. All three login scenarios are covered.

Seeing 2 passed, 1 failed?

Check the assertion string. The error message from SauceDemo contains "do not match" (lowercase). If you typed something different, the in check will fail. Also ensure .lower() is appended so the comparison is case-insensitive.

help me fix my invalid password assertion in Selenium

✔️ Awesome, I've got everything!

Great. Make sure you have saved tests/test_login.py and all 3 tests pass.

ⓧ I'd like to double check the full code

from selenium import webdriver

class TestLogin:
    def test_successful_login(self):
        driver = webdriver.Chrome()
        driver.get("https://www.saucedemo.com")
        driver.find_element("id", "user-name").send_keys("standard_user")
        driver.find_element("id", "password").send_keys("secret_sauce")
        driver.find_element("id", "login-button").click()
        assert driver.find_element("class name", "inventory_list").is_displayed()
        driver.quit()

    def test_locked_out_user(self):
        driver = webdriver.Chrome()
        driver.get("https://www.saucedemo.com")
        driver.find_element("id", "user-name").send_keys("locked_out_user")
        driver.find_element("id", "password").send_keys("secret_sauce")
        driver.find_element("id", "login-button").click()
        error = driver.find_element("css selector", "[data-test='error']").text
        assert "locked out" in error.lower()
        driver.quit()

    def test_invalid_password(self):
        driver = webdriver.Chrome()
        driver.get("https://www.saucedemo.com")
        driver.find_element("id", "user-name").send_keys("standard_user")
        driver.find_element("id", "password").send_keys("wrong_password")
        driver.find_element("id", "login-button").click()
        error = driver.find_element("css selector", "[data-test='error']").text
        assert "do not match" in error.lower()
        driver.quit()
Write the cart tests

Login is covered. Now you need to verify that core e-commerce actions work. Can a user add a product to the cart? Can they remove it? These are the flows QA teams test on every release.

  • Create a new file called test_cart.py inside the tests/ folder.
  • Add the import, class definition, login helper, and first cart test by pasting this code:
from selenium import webdriver

class TestCart:
    def _login(self, driver):
        # Reusable login steps for cart tests
        driver.get("https://www.saucedemo.com")
        driver.find_element("id", "user-name").send_keys("standard_user")
        driver.find_element("id", "password").send_keys("secret_sauce")
        driver.find_element("id", "login-button").click()

    def test_add_item_to_cart(self):
        # Launch Chrome and log in
        driver = webdriver.Chrome()
        self._login(driver)
        # Click the "Add to cart" button for the backpack
        driver.find_element("id", "add-to-cart-sauce-labs-backpack").click()
        # Verify the cart badge shows "1"
        assert driver.find_element("class name", "shopping_cart_badge").text == "1"
        driver.quit()

What does _login do?

The _login method is a helper that both cart tests share. It navigates to SauceDemo and logs in as standard_user. The underscore prefix is a Python convention meaning "internal use only". pytest will skip any method starting with an underscore when collecting tests.

The test itself clicks the add-to-cart button for the Sauce Labs Backpack, then checks that the cart badge element shows the text "1".

  • Save tests/test_cart.py and run the cart tests:
pytest tests/test_cart.py -v

Chrome opens, logs in, adds the backpack, and closes. The terminal shows 1 passed.

Cart badge not found?

The shopping_cart_badge element only appears after an item is added. If the add-to-cart button ID is wrong, nothing gets added and the badge never appears. Double-check the ID is exactly add-to-cart-sauce-labs-backpack (all lowercase with hyphens).

help me debug my cart badge assertion in Selenium

Now add the removal test. This one adds an item and then removes it, verifying the cart badge disappears.

  • Add this method below test_add_item_to_cart (inside the TestCart class):
    def test_remove_item_from_cart(self):
        # Launch Chrome and log in
        driver = webdriver.Chrome()
        self._login(driver)
        # Add then remove the backpack
        driver.find_element("id", "add-to-cart-sauce-labs-backpack").click()
        driver.find_element("id", "remove-sauce-labs-backpack").click()
        # Verify the cart badge is gone (no elements found)
        assert len(driver.find_elements("class name", "shopping_cart_badge")) == 0
        driver.quit()

Why find_elements instead of find_element?

When you remove an item, the cart badge disappears from the page entirely. Using find_element would throw a NoSuchElementException. Using find_elements (plural) returns an empty list instead of crashing, letting you assert the badge count is 0.

  • Save tests/test_cart.py and run both cart tests:
pytest tests/test_cart.py -v

Chrome opens twice. The terminal shows 2 passed. Both cart operations are verified.

Remove test failing?

The remove button ID is remove-sauce-labs-backpack (not the same as the add button ID). Make sure you call add-to-cart-sauce-labs-backpack first. The remove button only exists after an item has been added.

help me fix my remove-from-cart Selenium test

✔️ Awesome, I've got everything!

Great. Confirm you have saved tests/test_cart.py and both tests pass.

ⓧ I'd like to double check the full code

from selenium import webdriver

class TestCart:
    def _login(self, driver):
        driver.get("https://www.saucedemo.com")
        driver.find_element("id", "user-name").send_keys("standard_user")
        driver.find_element("id", "password").send_keys("secret_sauce")
        driver.find_element("id", "login-button").click()

    def test_add_item_to_cart(self):
        driver = webdriver.Chrome()
        self._login(driver)
        driver.find_element("id", "add-to-cart-sauce-labs-backpack").click()
        assert driver.find_element("class name", "shopping_cart_badge").text == "1"
        driver.quit()

    def test_remove_item_from_cart(self):
        driver = webdriver.Chrome()
        self._login(driver)
        driver.find_element("id", "add-to-cart-sauce-labs-backpack").click()
        driver.find_element("id", "remove-sauce-labs-backpack").click()
        assert len(driver.find_elements("class name", "shopping_cart_badge")) == 0
        driver.quit()
Spot the maintenance problem

All your tests pass individually. Now run every test file together and look at what you have built.

  • Before you run the next command, scroll through test_login.py and test_cart.py. What do you notice repeating across both files?
  • Run all tests together by running this command:
pytest tests/test_login.py tests/test_cart.py -v

All 5 tests pass. Chrome opens five separate times. The terminal shows green across the board.

Now look at the code structure. Every single test creates its own webdriver.Chrome(). Every test that needs to log in repeats the same four lines: navigate, type username, type password, click login. Every test calls driver.quit() at the end.

Why is this a problem?

Imagine SauceDemo renames their login button from login-button to submit-login. Right now, you would need to update that selector in every single test method across every file. With 5 tests, that is manageable. With 50 or 500 tests (the reality at enterprise companies), it becomes a nightmare.

This is exactly the problem that professional QA teams solve with a design pattern called the Page Object Model. You will implement it in the next step.

Your tests work and they verify real user workflows. In the next step, you will refactor this code using the Page Object Model so that each selector lives in exactly one place.

Refactor with the Page Object Model

Your tests pass, but look at the code you wrote. Every test file repeats the same selectors, the same login steps, and the same driver setup. If SauceDemo changes a single element ID tomorrow, you would need to update every file.

The Page Object Model solves exactly this problem. It is the design pattern enterprise QA teams use so that when a UI element changes, they update one file instead of every test. This is also the pattern interviewers ask about most frequently for QA automation roles.

In this step, get ready to:
  • Create reusable page object classes that centralize all selectors and actions.
  • Create a shared pytest fixture that handles browser setup and teardown automatically.
  • Rewrite your tests to use the page objects, then push the finished project to GitHub.
Create the page object folder structure

A page object class maps one page of the application to one Python file. Each class stores all the selectors for that page and exposes human-readable methods like login() or add_backpack_to_cart().

  • Create a pages folder inside your saucedemo-tests project by running this command in the terminal:
mkdir pages
  • Create the empty __init__.py files that Python needs to treat folders as importable packages. Run these commands:
type nul > pages\__init__.py
type nul > tests\__init__.py
  • Confirm both files were created by running:
dir pages
dir tests

You should see __init__.py listed in both folders.

Why do you need __init__.py files?

Python requires an __init__.py file inside a folder to treat it as a package you can import from. Without these files, from pages.login_page import LoginPage would fail with a ModuleNotFoundError.

Getting 'is not recognized' on type nul?

The type nul syntax works in Command Prompt. If your VS Code terminal uses PowerShell, run New-Item pages\__init__.py and New-Item tests\__init__.py instead.

Help me figure out which terminal I'm using and the correct command.

Write the login page object

The LoginPage class centralizes every selector on the SauceDemo login screen and wraps interactions into clear methods.

  • Create a new file called login_page.py inside the pages folder and paste the following code:
from selenium.webdriver.common.by import By


class LoginPage:
    URL = "https://www.saucedemo.com"

    # Selectors stored as tuples for easy unpacking
    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 __init__(self, driver):
        self.driver = driver

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

    def login(self, username, password):
        self.driver.find_element(*self.USERNAME_INPUT).clear()
        self.driver.find_element(*self.USERNAME_INPUT).send_keys(username)
        self.driver.find_element(*self.PASSWORD_INPUT).clear()
        self.driver.find_element(*self.PASSWORD_INPUT).send_keys(password)
        self.driver.find_element(*self.LOGIN_BUTTON).click()

    def get_error_message(self):
        return self.driver.find_element(*self.ERROR_MESSAGE).text

What does this code do?

Each selector is a tuple like (By.ID, "user-name"). The * operator unpacks the tuple into two arguments when you call find_element(). This means if SauceDemo changes the username input ID, you fix it in one place.

The login() method calls .clear() before typing to ensure the input fields are empty. This prevents leftover text from interfering with your tests.

  • Save pages/login_page.py.
Write the inventory page object

The inventory page is where products appear after login. Your cart tests interact with buttons and badges on this page.

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


class InventoryPage:
    # Selectors for the product listing and cart
    INVENTORY_LIST = (By.CLASS_NAME, "inventory_list")
    ADD_TO_CART_BACKPACK = (By.ID, "add-to-cart-sauce-labs-backpack")
    REMOVE_BACKPACK = (By.ID, "remove-sauce-labs-backpack")
    CART_BADGE = (By.CLASS_NAME, "shopping_cart_badge")
    CART_LINK = (By.CLASS_NAME, "shopping_cart_link")

    def __init__(self, driver):
        self.driver = driver

    def is_inventory_displayed(self):
        return self.driver.find_element(*self.INVENTORY_LIST).is_displayed()

    def add_backpack_to_cart(self):
        self.driver.find_element(*self.ADD_TO_CART_BACKPACK).click()

    def remove_backpack_from_cart(self):
        self.driver.find_element(*self.REMOVE_BACKPACK).click()

    def get_cart_badge_count(self):
        return self.driver.find_element(*self.CART_BADGE).text

    def is_cart_badge_present(self):
        elements = self.driver.find_elements(*self.CART_BADGE)
        return len(elements) > 0

What does this code do?

Each method maps to one user action: clicking "Add to Cart", clicking "Remove", or checking the cart badge count. Your tests will call these methods by name instead of repeating raw selectors.

Notice is_cart_badge_present() uses find_elements (plural). This returns an empty list instead of throwing an error when the badge does not exist.

  • Save pages/inventory_page.py.
  • Verify both page objects load without syntax errors by running:
python -c "from pages.login_page import LoginPage; from pages.inventory_page import InventoryPage; print('Page objects OK')"

You should see Page objects OK printed. This confirms Python can find and import both classes.

Seeing ModuleNotFoundError?

Make sure pages/__init__.py exists. Without it, Python cannot treat the pages folder as a package.

Also confirm your terminal is in the saucedemo-tests folder (not inside pages or tests).

Help me debug my ModuleNotFoundError when importing page objects.

Create the shared driver fixture

Right now, every test manually creates a Chrome driver and calls driver.quit(). A pytest fixture centralizes this so tests just receive a ready-to-use browser.

  • Create a new file called conftest.py in the root of your saucedemo-tests folder (not inside tests/) and paste:
import pytest
from selenium import webdriver


@pytest.fixture
def driver():
    # Create Chrome with default options
    chrome_options = webdriver.ChromeOptions()
    browser = webdriver.Chrome(options=chrome_options)
    browser.implicitly_wait(10)
    # yield gives the browser to the test, then cleanup runs after
    yield browser
    browser.quit()

What does this code do?

The @pytest.fixture decorator tells pytest this function provides a reusable resource. Any test that includes driver as a parameter will automatically receive the browser instance.

The yield keyword pauses the fixture after handing the browser to the test. Once the test finishes (pass or fail), execution resumes at browser.quit(). This guarantees the browser always closes, even when tests crash.

The implicitly_wait(10) tells Selenium to wait up to 10 seconds for elements to appear before raising an error.

  • Save conftest.py.
  • Verify pytest recognizes the fixture by running:
pytest --collect-only -q

You should see your 5 existing tests listed (3 in test_login.py, 2 in test_cart.py) plus your first test. This confirms pytest can find and parse all your files without errors.

Why put conftest.py in the project root?

Pytest automatically discovers conftest.py files and makes their fixtures available to all tests in the same directory and below. Placing it at the project root means every test file can use the driver fixture without any import statement.

Rewrite the login tests with page objects

Now for the payoff. You will replace the verbose, repetitive test code with clean methods from your page objects.

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


class TestLogin:
    def test_successful_login(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "secret_sauce")

        inventory_page = InventoryPage(driver)
        assert inventory_page.is_inventory_displayed()

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

        error = login_page.get_error_message()
        assert "locked out" in error.lower()

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

        error = login_page.get_error_message()
        assert "do not match" in error.lower()

What changed?

Each test method now accepts driver as a parameter. Pytest automatically injects the browser from your conftest.py fixture. No manual driver creation or quit() calls.

The test reads like plain English: open the login page, login with credentials, verify the result. No raw selectors anywhere.

  • Save tests/test_login.py.
  • Run the login tests to confirm they still pass:
pytest tests/test_login.py -v

Chrome should open three times (once per test), and the terminal shows all 3 tests passing in green.

Tests failing with TypeError?

If you see an error about driver being an unexpected argument, make sure each test method has self, driver as parameters (not just self). The parameter name must match the fixture name exactly.

Help me debug my login tests after the Page Object Model refactor.

Rewrite the cart tests with page objects
  • Open tests/test_cart.py and replace its entire contents with:
from pages.login_page import LoginPage
from pages.inventory_page import InventoryPage


class TestCart:
    def _login(self, driver):
        login_page = LoginPage(driver)
        login_page.open()
        login_page.login("standard_user", "secret_sauce")

    def test_add_item_to_cart(self, driver):
        self._login(driver)
        inventory_page = InventoryPage(driver)
        inventory_page.add_backpack_to_cart()

        assert inventory_page.get_cart_badge_count() == "1"

    def test_remove_item_from_cart(self, driver):
        self._login(driver)
        inventory_page = InventoryPage(driver)
        inventory_page.add_backpack_to_cart()
        inventory_page.remove_backpack_from_cart()

        assert not inventory_page.is_cart_badge_present()

What does this code do?

The _login() helper method uses LoginPage to handle authentication before each cart test. The underscore prefix tells Python (and other developers) this is an internal helper, not a test.

Each test now reads like a user story: log in, add the backpack to the cart, and check the badge shows "1". If the badge selector ever changes, you fix it once in inventory_page.py.

  • Save tests/test_cart.py.
  • Delete the original tests/test_first.py since it served its purpose as a proof-of-concept:
del tests\test_first.py

Before you run the full test suite, what do you think will happen? Do you expect all 5 tests to pass, or will the refactoring have introduced a bug somewhere?

  • Run the full test suite by running:
pytest -v

All 5 tests pass. The terminal output shows each test clearly: TestLogin::test_successful_login, TestLogin::test_locked_out_user, and so on. Every test name reads like a human-readable description of what it verifies.

Why is this better?

Compare what you had before (raw selectors and driver setup repeated in every test) with what you have now (3-5 lines per test using named methods). If SauceDemo changes the login button from id="login-button" to something else, you update one line in login_page.py. Every test that calls login() automatically uses the fix.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files: conftest.py, pages/login_page.py, pages/inventory_page.py, tests/test_login.py, and tests/test_cart.py.

ⓧ I'd like to double check the full code

Scroll up and confirm each file is present in order:

  • pages/login_page.py with the LoginPage class (URL, 4 selector tuples, 3 methods).
  • pages/inventory_page.py with the InventoryPage class (5 selector tuples, 5 methods).
  • conftest.py at the project root with the driver fixture.
  • tests/test_login.py with TestLogin class (3 test methods, each accepting driver).
  • tests/test_cart.py with TestCart class (_login helper, 2 test methods, each accepting driver).
Push your project to GitHub

Your refactored test suite is ready to share. Pushing it to GitHub gives you a portfolio piece you can reference in QA interviews.

  • Initialize a Git repository in your project folder by running:
git init -b main
  • Create a .gitignore file to keep your virtual environment out of the repository. Run:
echo venv/ > .gitignore
  • Stage and commit all your project files by running:
git add .
git commit -m "Add POM test suite for SauceDemo"
  • Open your browser and go to github.com.
  • Click the + icon in the top-right corner, then select New repository.
  • Enter saucedemo-tests as the repository name.
  • Leave it set to Public.
  • Click Create repository.
  • GitHub shows a page with setup commands. Copy the URL shown (it looks like https://github.com/YOUR-USERNAME/saucedemo-tests.git).
  • Back in VS Code's terminal, connect your local repo to GitHub and push by running:
git remote add origin [[REPO_URL="https://github.com/YOUR-USERNAME/saucedemo-tests.git"]]
git push -u origin main

What if Git asks for credentials?

Git may open a browser window asking you to sign in to GitHub. Follow the prompts to authenticate. If you see a password prompt in the terminal, enter a Personal Access Token (not your GitHub password).

  • Refresh your GitHub repository page in the browser. You should see all your project files listed: conftest.py, pages/, tests/, and requirements.txt.

Push rejected or remote not found?

Double-check that the repository URL matches what GitHub showed you. Run git remote -v to see the current remote. If it is wrong, remove it with git remote remove origin and add the correct URL again.

Help me figure out what's wrong with my git remote setup.

Your professional test suite is now live on GitHub. You have a working Page Object Model project structure, shared fixtures, and clean readable tests. This is exactly what enterprise QA teams use in production.

Next up: the Secret Mission challenges you to add a CI pipeline that runs these tests automatically on every push.

Secret mission

Add CI with GitHub Actions

Enterprise QA teams never run tests manually. They configure tests to run automatically on every code push. Can you add a GitHub Actions CI pipeline that runs your test suite in a headless Chrome browser every time you push code?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a free GitHub repository, so there are no ongoing costs.

Resources you used:

  • The saucedemo-tests project folder on your Desktop (includes your virtual environment, page objects, and test files).
  • GitHub repository with your test suite and CI workflow (free, no storage costs).

Keep everything running

No action needed. Choose this if you want to keep your test suite as a portfolio piece.

  • Your GitHub repository stays live indefinitely at no cost. It serves as a portfolio piece you can reference in QA job interviews.
  • Your local project folder remains on your Desktop, ready for you to add more tests or extend the suite.
  • GitHub Actions runs your tests on every push for free (public repositories get unlimited Actions minutes).

Pause - I'll come back to this later

Free up local disk space while keeping your code safe on GitHub.

  • Confirm your latest code is pushed to GitHub by running this in your project folder:
git status
  • If you see uncommitted changes, push them before pausing.
  • You can safely delete the local saucedemo-tests folder from your Desktop. Clone it again from GitHub whenever you want to resume.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Delete the local project folder by running this in Command Prompt:
cd %USERPROFILE%\Desktop
rmdir /s /q saucedemo-tests
  • Delete the GitHub repository by going to your repository page on GitHub.
  • Click Settings (the tab at the top of the repository page).
  • Scroll to the bottom of the page to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

Nice Work!

Nice Work!

You just built a professional automated test suite that controls Chrome, tests real e-commerce workflows, and follows the exact design patterns enterprise QA teams use in production.

You've learned how to:

  • Write Selenium and pytest tests that launch Chrome automatically to verify login flows, error handling, and cart operations against a real web application.
  • Identify code duplication as a maintenance problem and solve it with the Page Object Model pattern, so every selector lives in one place and tests read like plain English.
  • Structure a portfolio-ready test suite with shared pytest fixtures and reusable page objects that demonstrates the exact skills enterprise QA job postings require.
  • Secret Mission: Add a GitHub Actions CI pipeline that runs your tests automatically on every push, proving you understand the full professional QA workflow.

Ready to quiz yourself?