Test-Drive a Password Validator

Build a password validator test-first using pytest and the TDD cycle.

Introduction

30 Second Summary

Every time you create an online account, a password checker tells you whether your choice is strong enough. Behind the scenes, that checker is running your password through a set of rules.

In this project, you will build a password strength validator from scratch using Test-Driven Development with pytest. You will write failing tests first, then write just enough code to make them pass.

What You'll Build

You will type one command and watch a full suite of green tests scroll by, each one proving that a different password rule works exactly as you specified.

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

  • Run pytest and see a full suite of passing tests that prove every validation rule works, from minimum length to common password rejection.
  • Run pytest -v and see each parametrized test case listed individually by name, all generated from a single test function instead of repetitive copy-paste code.
  • Mock external dependencies like file reads using monkeypatch, so your tests run fast and reliably without any real files on disk.
  • Secret Mission: Add code coverage reporting with pytest-cov to find untested branches in your code and close the gaps.

Are there any prerequisites?

You should be comfortable with basic Python concepts like variables, functions, conditionals, and lists. No prior testing experience is needed.

Before We Start

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

Set Up Your Testing Environment

You are about to build a password validator using Test-Driven Development. Every piece of code will start with a failing test, and then you will write just enough implementation to make it pass.

Before any of that can happen, you need pytest installed and a project structure it knows how to discover. This step gets those foundations in place.

In this step, get ready to:
  • Create a password-validator project folder and open it in VS Code.
  • Install pytest and create your project files.
  • Run pytest to confirm everything is wired up.
Create your project folder
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, usually above Tab).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder by running:
mkdir password-validator
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the password-validator folder on your Desktop, then click Open.

Trust the folder authors

VS Code may ask whether you trust the authors of the files in this folder. Click Yes, I trust the authors to continue.

The VS Code title bar now shows password-validator. The Explorer sidebar on the left shows an empty folder.

  • Open a fresh terminal by pressing Ctrl+` (backtick). This terminal starts inside your password-validator folder.
Install pytest and create your project files

pytest is the most popular Python testing framework. It uses plain assert statements instead of special methods, and it discovers your tests automatically based on file and function naming.

  • Install pytest by running:
pip install pytest==9.1.1

You should see pip download and install pytest along with its dependencies. The output ends with a confirmation that the packages were successfully installed.

Seeing 'command not found'?

If your terminal does not recognize pip, try pip3 install pytest==9.1.1 instead. On some systems, pip is named pip3 to distinguish it from an older Python 2 installation.

If that does not work either, try running python3 -m pip install pytest==9.1.1 to invoke pip through Python directly.

Help me troubleshoot my pip install

Now you need two files: one for the password validator code, and one for the tests. pytest discovers test files automatically when their names start with test_.

  • In the Explorer sidebar on the left, click the New File icon next to PASSWORD-VALIDATOR.
  • Type password_validator.py and press Enter.
  • Click the New File icon again.
  • Type test_password_validator.py and press Enter.
  • Confirm both files exist by running this in your terminal:
ls

You should see password_validator.py and test_password_validator.py listed in the output.

Why does the test file start with test_?

pytest scans your project for files whose names match the pattern test_*.py. Inside those files, it looks for functions whose names also start with test_. If you name a file or function without that prefix, pytest silently skips it.

Confirm pytest is wired up

With pytest installed and both files created, you can verify the full setup with two quick commands.

  • Check the installed pytest version by running:
pytest --version

You should see pytest 9.1.1 (or the version you installed) printed in the terminal.

Now for the interesting check. Your test file exists, but it is completely empty. What do you think pytest will report when it finds a test file with no test functions inside?

  • Run pytest in your project by running:
pytest

pytest discovers test_password_validator.py (because the filename starts with test_), but collects 0 test items because there are no test functions inside. You see a clean exit with no errors.

What does 0 collected mean?

pytest separates discovery from execution. It first scans for test files, then collects individual test functions from those files. An empty test file means "found the file, but nothing to run." This clean exit confirms pytest, your project folder, and your file naming are all set up correctly.

Seeing 'pytest: command not found'?

This means pytest was installed but is not on your terminal's PATH. Try running it through Python directly:

python3 -m pytest

If that works, use python3 -m pytest in place of pytest for the rest of this project.

Help me get pytest running in my terminal

Your testing environment is ready. Next up, you will write your first failing test and experience the Red-Green-Refactor cycle in action.

Your First Red-Green-Refactor Cycle

Your testing environment is ready, with pytest installed and two empty files waiting. Now it's time to write your first tests and watch them drive your code into existence.

In Test-Driven Development, you write the test before you write the code. The test defines what "correct" means. Your code's only job is to make that test pass.

In this step, get ready to:
  • Write two test functions that define what a valid password looks like.
  • Create the validate_password function to make those tests pass.
Write your first test functions

TDD follows a cycle called Red-Green-Refactor. You start with Red: write a test that fails. Both your files are empty right now, so any test that imports from password_validator.py will immediately fail.

  • Open test_password_validator.py in VS Code and paste the following code:
from password_validator import validate_password


# Test that a password shorter than 8 characters fails
def test_short_password_is_invalid():
    result = validate_password("Ab1!xyz")
    assert result["valid"] is False
    assert "Password must be at least 8 characters long" in result["errors"]


# Test that a password with 8+ characters passes the length check
def test_eight_char_password_passes_length_check():
    result = validate_password("Abcdef1!")
    assert result["valid"] is True
    assert result["errors"] == []

What does this code do?

  • test_short_password_is_invalid calls validate_password with a 7-character password ("Ab1!xyz") and checks that the result shows valid as False with the correct error message.
  • test_eight_char_password_passes_length_check calls validate_password with an 8-character password ("Abcdef1!") and checks that valid is True with no errors.
  • Both tests call a function that doesn't exist yet. That's intentional.
  • Save test_password_validator.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

from password_validator import validate_password


def test_short_password_is_invalid():
    result = validate_password("Ab1!xyz")
    assert result["valid"] is False
    assert "Password must be at least 8 characters long" in result["errors"]


def test_eight_char_password_passes_length_check():
    result = validate_password("Abcdef1!")
    assert result["valid"] is True
    assert result["errors"] == []

Before you run pytest, take a guess. What do you think will happen when pytest tries to run tests that import a function from an empty file?

  • Run your tests by entering this command in the terminal:
pytest

You'll see red output with errors. Both tests fail because validate_password does not exist in password_validator.py yet.

What is the Red phase?

This is the Red phase of TDD. You wrote tests that define what "correct" looks like, then confirmed they fail.

The failure proves your tests are actually checking something real. If they passed right now with an empty file, that would be suspicious.

Create the validate_password function

Now for the Green phase. Your job is to write the minimum code needed to make both tests pass.

  • Open password_validator.py and paste the following code:
def validate_password(password):
    errors = []

    # Check minimum length
    if len(password) < 8:
        errors.append("Password must be at least 8 characters long")

    return {
        "valid": len(errors) == 0,
        "errors": errors,
    }

What does this code do?

  • validate_password takes a password string and starts with an empty errors list.
  • It checks if the password is shorter than 8 characters. If so, it appends the error message to the list.
  • It returns a dictionary with valid (True when there are no errors) and errors (the list of error messages).
  • Save password_validator.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before running pytest again.

ⓧ I'd like to double check the full code

def validate_password(password):
    errors = []

    if len(password) < 8:
        errors.append("Password must be at least 8 characters long")

    return {
        "valid": len(errors) == 0,
        "errors": errors,
    }

Before you run pytest again, do you think both tests will pass this time? What changed since the last run?

  • Run your tests again by entering this command:
pytest

You'll see green output showing 2 tests passed.

Still seeing test failures?

  • Check that the function is named exactly validate_password in password_validator.py. The import in your test file must match the function name character for character.
  • Make sure you saved both password_validator.py and test_password_validator.py before running pytest. Unsaved changes won't be picked up.
  • Confirm the return dictionary uses the exact keys "valid" and "errors". A typo like "error" instead of "errors" will cause a KeyError.

Why are my pytest tests still failing after writing validate_password?

Read the green output

Look at the terminal output from pytest. It tells you exactly how many tests passed and how long they took to run.

What just happened?

You completed your first Red-Green-Refactor cycle. You started with a failing test (Red), wrote the minimum code to pass it (Green), and now have proof that your length check works.

Every new feature you add to the validator will follow this same cycle. Test first, implement second.

Your length check is locked in with passing tests. Next up, you'll add more validation rules and discover what happens when new rules break existing tests.

Add More Rules and Watch Tests Break

You have a working length check with two green tests proving it. A real password validator needs more than just a length check. Users need digits, uppercase letters, and special characters in their passwords too.

Each new rule follows the same Red-Green-Refactor cycle: write a failing test first, then write the minimum code to make it pass. In this step, you will add three new rules and watch each test go red before turning green.

In this step, get ready to:
  • Add a digit-required rule by writing a failing test, then making it pass.
  • Add uppercase and special character rules following the same cycle.
  • Review the repetitive test structure your test file has grown into.
Write a failing test for the digit rule

The TDD cycle always starts with a failing test. You need a test that proves passwords without digits are rejected, before you write any digit-checking code.

  • In test_password_validator.py, add this test function below your existing tests:
def test_missing_digit_is_invalid():
    # Password has length, uppercase, and special char, but no digit
    result = validate_password("Abcdefg!")
    assert result["valid"] is False
    assert "Password must contain at least one digit" in result["errors"]

What does this test check?

The password "Abcdefg!" is 8 characters long, has an uppercase letter, and has a special character. The only thing it's missing is a digit. This isolates the digit rule so the test proves exactly one thing.

Before you run pytest, what do you think will happen? Your validate_password function has no digit check yet.

  • Save test_password_validator.py and run your tests:
pytest

You should see 1 failed and 2 passed. The new test fails because validate_password does not check for digits yet. It returned valid: True for a password with no digit. That's your RED phase.

Seeing 0 tests collected?

Make sure your function name starts with test_. pytest only discovers functions that follow the test_ naming convention.

Help me figure out why pytest isn't collecting my new test

Now it's time to make that red test go green. The digit check belongs in password_validator.py.

  • In password_validator.py, find the length check. Add this code below it, before the return statement:
    if not any(c.isdigit() for c in password):
        errors.append("Password must contain at least one digit")

What does any() do here?

any(c.isdigit() for c in password) loops through every character in the password and returns True as soon as it finds one that is a digit. If none are digits, it returns False.

The not in front flips the logic: if no character is a digit, append the error.

  • Save password_validator.py and run your tests again:
pytest

You should see 3 passed. All three tests are green, including your original test_eight_char_password_passes_length_check test. That's because its test password "Abcdef1!" already contains a digit.

Why didn't the existing test break?

When you add a new validation rule, any test that asserts valid is True will break if its test password doesn't satisfy the new rule. If you had used "abcdefgh" as the "valid" password, this test would have failed the moment you added the digit check.

The password "Abcdef1!" was chosen carefully. It has length, uppercase, a digit, and a special character. This means it survives every rule you are about to add. Choosing robust test data upfront saves you from chasing false failures later.

Still seeing a failure?

Check that the error message string in your test matches the one in password_validator.py exactly. Even a single character difference will cause the assert ... in result["errors"] check to fail.

Help me debug why my digit test is still failing

Add the uppercase and special character rules

You have the Red-Green pattern down. Now apply it two more times: once for the uppercase rule, and once for the special character rule.

  • In test_password_validator.py, add this test below your digit test:
def test_missing_uppercase_is_invalid():
    # Has length, digit, and special char, but no uppercase
    result = validate_password("abcdef1!")
    assert result["valid"] is False
    assert "Password must contain at least one uppercase letter" in result["errors"]
  • In password_validator.py, add the uppercase check below the digit check:
    if not any(c.isupper() for c in password):
        errors.append("Password must contain at least one uppercase letter")

What does this code do?

Same pattern as the digit check. c.isupper() returns True for uppercase letters. The test password "abcdef1!" is all lowercase, so it will fail this check.

  • Save both files and run your tests:
pytest

You should see 4 passed. The uppercase rule is working.

One more rule to go. Passwords need at least one special character.

  • In test_password_validator.py, add this test below the uppercase test:
def test_missing_special_char_is_invalid():
    # Has length, digit, and uppercase, but no special character
    result = validate_password("Abcdefg1")
    assert result["valid"] is False
    assert "Password must contain at least one special character" in result["errors"]
  • In password_validator.py, add the special character check below the uppercase check:
    if not any(c in "!@#$%^&*" for c in password):
        errors.append("Password must contain at least one special character")

What does this code do?

Instead of a built-in method like isdigit() or isupper(), this check uses c in "!@#$%^&*" to test whether each character belongs to a set of special characters. The test password "Abcdefg1" has no characters from that set, so it will fail.

Before you run pytest, how many tests do you expect to pass? Think about whether any existing test data is missing a special character.

  • Save both files and run your tests:
pytest

You should see 5 passed. Every test is green. Your validator now enforces four rules: minimum length, digit, uppercase, and special character.

Seeing unexpected failures?

If test_eight_char_password_passes_length_check is failing, make sure it still uses "Abcdef1!" as its test password. This password satisfies all four rules: it is 8 characters, has a digit, an uppercase letter, and a special character.

Also confirm that your validation checks in password_validator.py are all placed before the return statement. A check placed after the return will never run.

Help me debug my failing password validator tests

✔️ Awesome, I've got everything!

Great. Make sure you have saved both password_validator.py and test_password_validator.py.

ⓧ I'd like to double check the full code

Here is the full password_validator.py at this point:

def validate_password(password):
    errors = []

    if len(password) < 8:
        errors.append("Password must be at least 8 characters long")

    if not any(c.isdigit() for c in password):
        errors.append("Password must contain at least one digit")

    if not any(c.isupper() for c in password):
        errors.append("Password must contain at least one uppercase letter")

    if not any(c in "!@#$%^&*" for c in password):
        errors.append("Password must contain at least one special character")

    return {
        "valid": len(errors) == 0,
        "errors": errors,
    }

And here is the full test_password_validator.py:

from password_validator import validate_password

def test_short_password_is_invalid():
    result = validate_password("Ab1!xyz")
    assert result["valid"] is False
    assert "Password must be at least 8 characters long" in result["errors"]

def test_eight_char_password_passes_length_check():
    result = validate_password("Abcdef1!")
    assert result["valid"] is True
    assert result["errors"] == []

def test_missing_digit_is_invalid():
    result = validate_password("Abcdefg!")
    assert result["valid"] is False
    assert "Password must contain at least one digit" in result["errors"]

def test_missing_uppercase_is_invalid():
    result = validate_password("abcdef1!")
    assert result["valid"] is False
    assert "Password must contain at least one uppercase letter" in result["errors"]

def test_missing_special_char_is_invalid():
    result = validate_password("Abcdefg1")
    assert result["valid"] is False
    assert "Password must contain at least one special character" in result["errors"]
Review the repetitive test structure

Look at your test_password_validator.py file. You have five test functions, and three of them follow the exact same structure.

  • Each one calls validate_password with a different password.
  • Each one checks result["valid"] is False.
  • Each one asserts a specific error message is in result["errors"].

The only difference between them is the input password and the expected error message. Everything else is copy-paste. If you wanted to add ten more rules, you would have ten more nearly identical functions. That kind of duplication makes a test suite hard to read and hard to maintain.

Why is test duplication a problem?

Duplicated test functions hide the important differences between test cases under layers of identical boilerplate. When every function looks the same, a reviewer has to read each one carefully to spot the one line that actually differs.

If you ever change the return structure of validate_password (say, renaming "errors" to "messages"), you would have to update every single function. The next step solves this problem.

Your validator now checks four rules and you have five passing tests to prove it. Next up, you will use pytest's @pytest.mark.parametrize decorator to collapse all that repetition into a single, clean test function.

Parametrize Away Repetitive Tests

Your validator now enforces four rules, and every one of them has a passing test to prove it. The Red-Green-Refactor cycle got you here. Now take a look at your test file.

Every test function follows the same three-line pattern: call validate_password, check result["valid"], check result["errors"]. The only thing that changes between them is the password and the expected error. That duplication is the problem this step solves with @pytest.mark.parametrize.

In this step, get ready to:
  • Replace repetitive test functions with parametrized tests that run the same logic with different inputs.
  • Organize tests into classes for clean, readable structure.
Replace repetitive tests with @pytest.mark.parametrize

pytest's @pytest.mark.parametrize decorator lets you run a single test function with many different inputs. Instead of writing three near-identical functions, you write one function and hand it a list of test cases.

  • In test_password_validator.py, add import pytest as the very first line of the file, above the existing from password_validator import validate_password line.
  • Find these three test functions:
def test_missing_digit_is_invalid():
    result = validate_password("Abcdefg!")
    assert result["valid"] is False
    assert "Password must contain at least one digit" in result["errors"]


def test_missing_uppercase_is_invalid():
    result = validate_password("abcdef1!")
    assert result["valid"] is False
    assert "Password must contain at least one uppercase letter" in result["errors"]


def test_missing_special_char_is_invalid():
    result = validate_password("Abcdefg1")
    assert result["valid"] is False
    assert "Password must contain at least one special character" in result["errors"]
  • Delete all three functions. Replace them with this single parametrized test:
# Test each character-type rule with different passwords
@pytest.mark.parametrize(
    "password, expected_error",
    [
        ("Abcdefg!", "Password must contain at least one digit"),
        ("abcdef1!", "Password must contain at least one uppercase letter"),
        ("Abcdefg1", "Password must contain at least one special character"),
    ],
)
def test_missing_character_type(password, expected_error):
    result = validate_password(password)
    assert result["valid"] is False
    assert expected_error in result["errors"]

What does @pytest.mark.parametrize do?

The decorator takes two arguments: a string naming the parameters ("password, expected_error") and a list of tuples, where each tuple is one test case.

pytest runs the function once per tuple, injecting the values as arguments. Three tuples means three test runs, all from one function.

  • Save test_password_validator.py.
  • Confirm the parametrized tests work by running:
pytest

You should see 5 passed. The count stays the same because three separate functions became one function running three times.

Seeing an import error or 0 tests collected?

  • Make sure import pytest is on the very first line of the file, above the from password_validator import.
  • Check that you deleted the old three test functions completely. Leftover function signatures with missing bodies cause syntax errors.
  • Verify the @pytest.mark.parametrize decorator sits directly above def test_missing_character_type with no blank lines between them.

Help me debug my parametrized test setup.

Now you can cover the other direction too. A good test suite checks both invalid and valid inputs.

  • Add this parametrized test below test_missing_character_type in test_password_validator.py:
# Verify that strong passwords pass all rules
@pytest.mark.parametrize(
    "password",
    [
        "Abcdef1!",
        "MyP@ss99",
        "Test!ng1",
    ],
)
def test_valid_passwords(password):
    result = validate_password(password)
    assert result["valid"] is True
    assert result["errors"] == []

What does this test do?

This parametrized test runs the same assertion logic with three different strong passwords. Each one satisfies all four rules (length, digit, uppercase, special character), so the result should always be valid: True with an empty errors list.

Adding a new valid-password case later is just one more string in the list.

  • Save test_password_validator.py.
  • Run the tests in verbose mode to see each parametrized case listed by name:
pytest -v

You should see 8 passed. Each parametrized case gets its own line in the output, with the password and error message visible in the test name.

Seeing fewer than 8 tests?

  • Make sure test_valid_passwords is a standalone function at the same indentation level as test_missing_character_type. It should not be nested inside another function.
  • Check that the function name starts with test_. pytest only discovers functions whose names begin with that prefix.

Help me figure out why my test count is wrong.

Organize tests into classes

Your tests work, but they are a flat list of functions. Grouping related tests into classes makes the file easier to scan and tells pytest to display them in a structured hierarchy.

  • In test_password_validator.py, find the two length-related test functions: test_short_password_is_invalid and test_eight_char_password_passes_length_check.
  • Replace both standalone functions with this class that wraps them:
class TestPasswordLength:
    def test_short_password_is_invalid(self):
        result = validate_password("Ab1!xyz")
        assert result["valid"] is False
        assert "Password must be at least 8 characters long" in result["errors"]

    def test_eight_char_password_passes_length_check(self):
        result = validate_password("Abcdef1!")
        assert result["valid"] is True
        assert result["errors"] == []

What changed?

  • The class TestPasswordLength: line groups the two length tests under one name. pytest discovers any class prefixed with Test.
  • Each function gains a self parameter because it is now a method on a class. The test logic stays identical.
  • Now find the two parametrized test functions: test_missing_character_type and test_valid_passwords.
  • Replace both standalone functions (including their decorators) with this class:
class TestPasswordRules:
    @pytest.mark.parametrize(
        "password, expected_error",
        [
            ("Abcdefg!", "Password must contain at least one digit"),
            ("abcdef1!", "Password must contain at least one uppercase letter"),
            ("Abcdefg1", "Password must contain at least one special character"),
        ],
    )
    def test_missing_character_type(self, password, expected_error):
        result = validate_password(password)
        assert result["valid"] is False
        assert expected_error in result["errors"]

    @pytest.mark.parametrize(
        "password",
        [
            "Abcdef1!",
            "MyP@ss99",
            "Test!ng1",
        ],
    )
    def test_valid_passwords(self, password):
        result = validate_password(password)
        assert result["valid"] is True
        assert result["errors"] == []

What does this class do?

  • TestPasswordRules groups both parametrized tests together. They all validate character-type rules, so they belong in the same class.
  • Notice that self is the first parameter on each method, and the parametrize arguments come after it: self, password, expected_error.

Before you run this, what do you think the verbose output will look like now that your tests are inside classes? Will the test count change?

  • Save test_password_validator.py.
  • Run the tests in verbose mode:
pytest -v

You should see 8 passed. The test count stays the same, but now each test name includes its class. You will see names like TestPasswordRules::test_missing_character_type[Abcdefg!-Password must contain at least one digit] in the output.

Seeing test errors after adding classes?

  • Check that every test method has self as its first parameter. A missing self causes a TypeError about unexpected arguments.
  • Make sure all method bodies are indented one level deeper than the def line. Python uses indentation to determine what belongs inside the class.
  • Verify the class names start with Test (capital T). pytest skips classes that do not follow this naming convention.

Help me fix my class-based test organization.

See the refactor payoff

Why is this better?

Compare your test file now to what it looked like at the end of Step 3. You went from five nearly identical functions to two concise parametrized tests grouped in classes. Fewer lines, more test cases covered.

The real win shows up when you need a new rule. Adding a new test case is just one more tuple in the parameter list. No copying, no pasting, no risk of forgetting to update one of the clones.

✔️ Awesome, I've got everything!

Great. Double check you have saved test_password_validator.py.

ⓧ I'd like to double check the full code

Scroll up and confirm these pieces are present in your file, in this order:

  • import pytest and from password_validator import validate_password at the top.
  • The TestPasswordLength class with test_short_password_is_invalid and test_eight_char_password_passes_length_check (both with self).
  • The TestPasswordRules class with the parametrized test_missing_character_type (3 cases) and parametrized test_valid_passwords (3 cases), both with self.
  • No standalone test functions outside of a class.

The password_validator.py file is unchanged from the previous step.

Your test suite is clean, organized, and easy to extend. Next up, you will add one final rule that depends on an external file, and learn how to mock that dependency so your tests never touch the disk.

Mock an External Dependency

Your test suite is clean, parametrized, and organized into classes. There is one more real-world rule to add: rejecting commonly used passwords like "password" or "12345678".

In production, this list of common passwords usually lives in a file or comes from an API. Tests should not depend on a real file existing on disk. pytest's built-in monkeypatch fixture lets you replace the file-reading function with a fake that returns a known list, so tests run fast and reliably without external dependencies.

In this step, get ready to:
  • Add a function that reads common passwords from a file.
  • Write tests using monkeypatch to mock the file-reading function.
  • Understand why mocking keeps your tests fast and reliable.
Add a function to load common passwords

A real password validator checks user input against a list of commonly used passwords stored in a file. Your code needs a function that reads this file and a check inside validate_password that rejects any match.

  • Add a load_common_passwords function at the very top of password_validator.py, above the existing validate_password function, by copying this code:
def load_common_passwords(filepath="common_passwords.txt"):
    # Try to read passwords from a file, one per line
    try:
        with open(filepath) as f:
            return [line.strip().lower() for line in f]
    # If the file doesn't exist, return an empty list
    except FileNotFoundError:
        return []
  • Now scroll down to the return statement at the bottom of validate_password and add these lines just above it:
    # Check against a list of commonly used passwords
    common_passwords = load_common_passwords()
    if password.lower() in common_passwords:
        errors.append("Password is too common")

What does this code do?

load_common_passwords opens a text file, reads each line, strips whitespace, and converts every password to lowercase. The except FileNotFoundError block returns an empty list instead of crashing when the file does not exist.

Inside validate_password, the new lines call that function and check whether the lowercased password appears in the list. If it does, the error "Password is too common" gets appended.

  • Save password_validator.py.
  • Confirm your existing tests still pass by running:
pytest

All 8 tests should still pass. The common password check runs on every call, but since no common_passwords.txt file exists on disk, load_common_passwords returns an empty list and no password gets rejected as too common.

Seeing an ImportError or NameError?

  • Make sure load_common_passwords is defined above validate_password in password_validator.py. Python reads files top to bottom, so the function must exist before it is called.
  • Check that the indentation of the new lines inside validate_password matches the surrounding code. They should be indented one level (four spaces) inside the function.
  • help me debug a NameError in my password_validator.py file

✔️ Awesome, I've got everything!

Great. Make sure you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the full password_validator.py at this point:

def load_common_passwords(filepath="common_passwords.txt"):
    try:
        with open(filepath) as f:
            return [line.strip().lower() for line in f]
    except FileNotFoundError:
        return []

def validate_password(password):
    errors = []

    if len(password) < 8:
        errors.append("Password must be at least 8 characters long")

    if not any(c.isdigit() for c in password):
        errors.append("Password must contain at least one digit")

    if not any(c.isupper() for c in password):
        errors.append("Password must contain at least one uppercase letter")

    if not any(c in "!@#$%^&*" for c in password):
        errors.append("Password must contain at least one special character")

    common_passwords = load_common_passwords()
    if password.lower() in common_passwords:
        errors.append("Password is too common")

    return {
        "valid": len(errors) == 0,
        "errors": errors,
    }
Write tests using monkeypatch

Your common password check works, but how do you test it? If your tests depend on a real common_passwords.txt file, they would break whenever the file is missing, renamed, or has different contents.

pytest's monkeypatch fixture solves this. It replaces a real function with a fake one during a test, then automatically restores the original when the test finishes.

  • Add a TestCommonPasswords class at the bottom of test_password_validator.py, below the existing TestPasswordRules class, by copying this code:
class TestCommonPasswords:
    def test_common_password_is_rejected(self, monkeypatch):
        # Replace load_common_passwords with a fake that returns a known list
        monkeypatch.setattr(
            "password_validator.load_common_passwords",
            lambda: ["password", "12345678", "qwerty123"],
        )
        result = validate_password("Password1!")
        assert result["valid"] is False
        assert "Password is too common" in result["errors"]

    def test_uncommon_password_is_accepted(self, monkeypatch):
        # Replace load_common_passwords with a different fake list
        monkeypatch.setattr(
            "password_validator.load_common_passwords",
            lambda: ["password", "12345678"],
        )
        result = validate_password("MyStr0ng!Pass")
        assert result["valid"] is True
        assert result["errors"] == []

What does this code do?

  • monkeypatch is a built-in pytest fixture. You get access to it by adding it as a parameter to your test method.
  • monkeypatch.setattr replaces load_common_passwords at the module level with a lambda that returns a hardcoded list. When validate_password runs, it calls the fake function instead of the real one.
  • test_common_password_is_rejected checks that "Password1!" is flagged because "password" is in the common list (case-insensitive comparison).
  • test_uncommon_password_is_accepted checks that "MyStr0ng!Pass" passes when it is not in the common list.
  • After each test finishes, monkeypatch automatically restores the original function. No cleanup code needed.
  • Save test_password_validator.py.

Before you run the tests, think about this: do you expect all tests to pass, even though no common_passwords.txt file exists anywhere on disk?

  • Run your full test suite with verbose output:
pytest -v

All 10 tests pass. The two new common password tests work because monkeypatch replaces the file-reading function with your fake. The eight existing tests also pass because monkeypatch automatically restores the original function after each test that uses it.

Tests failing or seeing unexpected errors?

  • Make sure the string inside monkeypatch.setattr is exactly "password_validator.load_common_passwords". It must match the module name and function name precisely.
  • Check that TestCommonPasswords is at the same indentation level as your other test classes. It should not be nested inside TestPasswordRules.
  • If you see an AttributeError, make sure load_common_passwords is defined in password_validator.py and that you saved both files.
  • help me debug my monkeypatch test failures

✔️ Awesome, I've got everything!

Great. Make sure you have saved your file before moving on.

ⓧ I'd like to double check the full code

Your test_password_validator.py should have three classes in this order. Scroll up and confirm each one is present:

  • The imports at the top: import pytest and from password_validator import validate_password.
  • TestPasswordLength with test_short_password_is_invalid and test_eight_char_password_passes_length_check.
  • TestPasswordRules with the parametrized test_missing_character_type and test_valid_passwords.
  • TestCommonPasswords with test_common_password_is_rejected and test_uncommon_password_is_accepted, both using monkeypatch.
Understand what monkeypatch gives you

Take a moment to appreciate what just happened. Your common password tests pass with flying colors, and there is no common_passwords.txt file anywhere on your machine.

Why is mocking so powerful?

Mocking with monkeypatch gives you three guarantees that make your tests production-ready.

  • Fast tests with no disk I/O. Your tests never open a file, so they run in microseconds.
  • Reliable tests with no dependency on a file existing. The tests pass on any machine, in any directory, every time.
  • Controlled tests where you decide exactly what the "common passwords" list contains. No surprises from external data.

This pattern appears in every professional test suite. Anytime your code depends on something external (a file, a database, an API), you mock it in tests so you are testing your logic, not your environment.

Secret mission

Generate a Code Coverage Report

Your tests prove every validation rule works. But do they actually touch every line of code? Install a coverage plugin, find the gaps in your test suite, and write new tests to close them.

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 files and installed packages, or delete them entirely.

Resources you used:

  • password-validator project folder containing password_validator.py and test_password_validator.py.
  • pytest Python package (9.1.1).
  • pytest-cov Python package (7.1.0).

Keep everything running

No action needed. Choose this if you want to keep experimenting with your password validator or add more test cases.

  • Your password-validator folder stays on your Desktop with all your files intact.
  • pytest and pytest-cov remain installed and available for any future Python testing projects.

Pause - I'll come back to this later

There are no running processes to stop. Your files and packages sit quietly on disk until you are ready to return.

  • Your project folder and installed packages take up minimal disk space.
  • When you come back, open the password-validator folder in VS Code and run pytest to pick up right where you left off.

Delete - I don't want to use this again

Remove all project files and uninstall the packages you installed during this project.

  • Delete the project folder by running this command in your terminal:
cd ~/Desktop
rm -rf password-validator

On Windows, run this instead:

cd ~\Desktop
rmdir /s /q password-validator
  • Uninstall pytest and pytest-cov by running:
pip uninstall pytest pytest-cov -y
  • Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir ~\Desktop (Windows). You should no longer see password-validator listed.

Nice Work!

Nice Work!

You just built a fully tested password validator from scratch using the TDD Red-Green-Refactor cycle. Every rule started as a failing test, got implemented with the minimum code to pass, then got refactored into a clean, maintainable suite.

You've learned how to:

  • Build features test-first using the Red-Green-Refactor cycle: write a failing test, implement the minimum code to make it pass, then refactor.
  • Eliminate repetitive test functions using @pytest.mark.parametrize to run many test cases from a single test function, and organize tests into classes for clean, maintainable structure.
  • Mock external dependencies with monkeypatch so your tests run fast and reliably without real files on disk.
  • Secret Mission: Generate a code coverage report with pytest-cov, identify untested branches, and write tests to close the gaps.

Ready to quiz yourself?