Introduction to Software Testing & Python Unit Testing

SA
StudyAI
AI-generated study notes
· Published Updated Pro Plus model

From the Tests in python and flask web apps curriculum

TL;DR

You'll understand why automated tests exist, what separates a unit test from other test types, and how Python's unittest and pytest frameworks actually work. You'll write real test files, run them, and read failure output correctly. You'll also know the common mistakes that make test suites useless or slow, and when to use mocks versus real objects.

1. The Mental Model

A test is just a small program that runs another program and checks if the result matches what you expected. You write the expectation once, then the computer checks it forever, every time code changes, for free. Without tests, you're checking correctness by reading code and hoping; with tests, you're checking it by running code and proving. Tests turn "I think this works" into "I can show you this works."

2. The Core Material

2.1 What testing actually is, and the test pyramid

A 3D pyramid of metallic blocks forming an abstract geometric sculpture, showcasing modern design.
Photo by Santhosh Kanthala on Pexels

Software testing means running your code under controlled conditions and verifying the output against a known-correct expectation. You're not testing "does it compile" — Python doesn't compile in the traditional sense, so a script can run fine and still be wrong. Testing is about behaviour, not syntax.

There are several levels of tests, and they trade off speed against realism:

  • Unit tests — test one function or one class in isolation. Fast (milliseconds), no database, no network, no filesystem. You fake out anything external.
  • Integration tests — test several pieces together, e.g. a Flask route that actually hits a real (test) database.
  • End-to-end tests — test the whole system like a user would, e.g. a browser clicking through your Flask app.

The classic advice is the "test pyramid": write lots of unit tests (they're cheap and precise), fewer integration tests, and very few end-to-end tests (they're slow and brittle). This course topic is about the base of that pyramid.

graph TD
    A["Unit Tests — functions, classes in isolation"] --> B["Integration Tests — multiple components together"]
    B --> C["End-to-End Tests — full app, real user flow"]
    A -.->|"fast, cheap, run thousands per second"| D["Test Pyramid Base"]
    C -.->|"slow, expensive, run dozens per minute"| E["Test Pyramid Top"]

2.2 Anatomy of a unit test

A good unit test follows the Arrange–Act–Assert pattern:

  1. Arrange — set up the inputs and any objects you need.
  2. Act — call the function you're testing.
  3. Assert — check the output matches what you expected.

Here's the same test written two ways — first with Python's built-in unittest module, then with pytest, which most real-world Python projects (including Flask apps) actually use.

# calculator.py
def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

Using unittest (standard library, no install needed):

# test_calculator_unittest.py
import unittest
from calculator import divide

class TestDivide(unittest.TestCase):

    def test_divides_two_positive_numbers(self):
        # Arrange
        a, b = 10, 2
        # Act
        result = divide(a, b)
        # Assert
        self.assertEqual(result, 5)

    def test_raises_on_zero_division(self):
        with self.assertRaises(ValueError):
            divide(10, 0)

if __name__ == "__main__":
    unittest.main()

Run it with python -m unittest test_calculator_unittest.py -v and you'll see each test name and PASS/FAIL.

Using pytest (install with pip install pytest):

# test_calculator_pytest.py
import pytest
from calculator import divide

def test_divides_two_positive_numbers():
    assert divide(10, 2) == 5

def test_raises_on_zero_division():
    with pytest.raises(ValueError):
        divide(10, 0)

Run it with pytest -v from the terminal. Notice: no class needed, no self, just plain assert statements. pytest rewrites assertions internally so failure messages are detailed automatically — you don't need assertEqual, assertTrue, etc.

2.3 Fixtures, parametrization, and test discovery

Real test suites repeat setup code across many tests. Two tools remove that duplication.

Fixtures (pytest) let you define reusable setup that's injected into test functions as an argument:

import pytest
from calculator import divide

@pytest.fixture
def sample_numbers():
    return {"a": 20, "b": 4}

def test_divide_with_fixture(sample_numbers):
    result = divide(sample_numbers["a"], sample_numbers["b"])
    assert result == 5

Pytest sees the argument name sample_numbers, matches it to the fixture function, calls it, and passes the return value in. This matters once you're testing Flask apps: a fixture can spin up a test client or a temporary database connection once, and every test that needs it just asks for it by name.

Parametrization lets one test function run multiple cases without copy-pasting:

@pytest.mark.parametrize("a, b, expected", [
    (10, 2, 5),
    (9, 3, 3),
    (-10, 2, -5),
])
def test_divide_many_cases(a, b, expected):
    assert divide(a, b) == expected

This runs as three separate tests — if case 2 fails, you know exactly which inputs broke it, instead of one giant test function hiding the failure.

Test discovery — both frameworks find tests automatically by naming convention: files named test_*.py or *_test.py, functions named test_*, classes named Test*. If you misname a file, it silently won't run. This is the single most common "why isn't my test running" bug.

3. Worked Example

Let's build and test a small, realistic function: validating a username for a signup form (the kind of logic that later sits inside a Flask route).

# validators.py
import re

def is_valid_username(username):
    if not isinstance(username, str):
        return False
    if not (3 <= len(username) <= 20):
        return False
    return bool(re.fullmatch(r"[a-zA-Z0-9_]+", username))

Now write tests before you trust it — this is the spirit of TDD (test-driven development): write the test, watch it fail, then write code until it passes.

# test_validators.py
import pytest
from validators import is_valid_username

@pytest.mark.parametrize("username, expected", [
    ("bob", True),               # valid, minimum length
    ("bob_the_builder", True),   # valid, underscore allowed
    ("ab", False),                # too short
    ("a" * 21, False),            # too long
    ("bob smith", False),         # space not allowed
    ("bob!", False),               # symbol not allowed
    (12345, False),                # wrong type entirely
])
def test_is_valid_username(username, expected):
    assert is_valid_username(username) == expected

Run pytest -v test_validators.py and you get:

test_validators.py::test_is_valid_username[bob-True] PASSED
test_validators.py::test_is_valid_username[bob_the_builder-True] PASSED
test_validators.py::test_is_valid_username[ab-False] PASSED
test_validators.py::test_is_valid_username[aaaaaaaaaaaaaaaaaaaaa-False] PASSED
test_validators.py::test_is_valid_username[bob smith-False] PASSED
test_validators.py::test_is_valid_username[bob!-False] PASSED
test_validators.py::test_is_valid_username[12345-False] PASSED

Seven test cases, one function, zero duplicated setup code. Notice the case list deliberately includes boundary values (length exactly 3, exactly 20, and one over/under) — boundaries are where real bugs hide, not the middle of the valid range. If you'd only tested "bob" and "bob!", you'd never catch an off-by-one error in the length check.

4. Production Pitfalls & Best Practices

4.1 Real-World Best Practices

Close-up of a financial monitor displaying real-time cryptocurrency data and charts in a modern office.
Photo by Jakub Zerdzicki on Pexels

  • One assertion concept per test. A test named test_divide that also checks logging, timing, and exceptions is three tests wearing a trenchcoat — when it fails you won't know which part broke.
  • Name tests as sentences. test_raises_valueerror_when_dividing_by_zero tells you what broke just from the test report, no need to open the file.
  • Keep unit tests fast and isolated. No real network calls, no real database, no sleep(). If a function needs the database, that's an integration test, not a unit test — label it separately (e.g. a tests/integration/ folder).
  • Test behaviour, not implementation. Don't assert on internal variable names or private helper calls; assert on what the function returns or raises. Otherwise refactoring breaks tests that didn't need to break.
  • Use fixtures for shared setup, not copy-pasted code at the top of every test function — DRY applies to tests too.
  • Run tests in CI on every commit (GitHub Actions, GitLab CI). A test you don't run automatically is a test you'll eventually forget to run manually.
  • Measure coverage, but don't worship it. pytest --cov=myapp tells you which lines ran. 100% coverage with weak assertions (assert result is not None) is nearly worthless — coverage shows what ran, not what was checked.

4.2 Common Bugs & Anti-Patterns

1. Testing implementation details instead of behaviour.

# BAD — breaks if you rename the internal helper, even though behaviour is unchanged
def test_internal_helper_called():
    calc = Calculator()
    calc._round_result = Mock()
    calc.divide(10, 3)
    calc._round_result.assert_called_once()
# FIXED — test what the user actually sees
def test_divide_rounds_result():
    calc = Calculator()
    assert calc.divide(10, 3) == 3.33

2. Shared mutable state leaking between tests.

# BAD — a module-level list is shared across every test in the file
cart = []

def test_add_item():
    cart.append("apple")
    assert cart == ["apple"]

def test_cart_starts_empty():
    assert cart == []  # fails! "apple" is still there from the previous test
# FIXED — fresh state per test via a fixture
import pytest

@pytest.fixture
def cart():
    return []

def test_add_item(cart):
    cart.append("apple")
    assert cart == ["apple"]

def test_cart_starts_empty(cart):
    assert cart == []  # passes — new empty list every time

3. Overly broad assert that hides real failures.

# BAD — passes even if the function returns completely wrong data, as long as it's truthy
def test_get_user():
    user = get_user(id=1)
    assert user
# FIXED — check the actual expected values
def test_get_user():
    user = get_user(id=1)
    assert user["id"] == 1
    assert user["name"] == "Alice"

4. Mocking too much, so the test checks nothing real.

# BAD — the entire function under test is mocked, so the test proves nothing
def test_divide():
    with patch("calculator.divide", return_value=5):
        assert divide(10, 2) == 5  # this will ALWAYS pass, divide never actually ran
# FIXED — mock only external dependencies (network, DB), never the function you're testing
def test_divide():
    assert divide(10, 2) == 5

4.3 When to Use What (Decision Matrix)

Situation Approach
Testing a pure function (no I/O) Plain pytest unit test, no mocking needed
Function calls an external API Mock/patch the API client, test your logic around it
Testing a Flask route end-to-end Use Flask's test_client() fixture — integration test
Same logic, many input/output pairs @pytest.mark.parametrize instead of repeated functions
Shared setup needed across many tests A pytest fixture, scoped function (default) or module if setup is expensive
Need to check an exception is raised pytest.raises(ExceptionType) or self.assertRaises

5. Now Try It

Create a file inventory.py with a function remove_stock(current_stock, amount) that subtracts amount from current_stock, raises ValueError if amount is negative, and raises ValueError if the result would go below zero. Then create test_inventory.py using pytest: write at least 5 parametrized cases (normal subtraction, exact depletion to zero, negative amount, amount larger than stock, zero amount) plus one separate pytest.raises test. Run pytest -v and get every test green.

Success looks like: running pytest -v test_inventory.py shows 6+ PASSED lines with no FAILED, and you can explain out loud why each test case was chosen.

Frequently asked about Introduction to Software Testing & Python Unit Testing

You'll understand why automated tests exist, what separates a unit test from other test types, and how Python's unittest and pytest frameworks actually work. You'll write real test files, run them, and read failure output correctly. Read the full notes above for the details.

Introduction to Software Testing & Python Unit Testing is a core topic in Tests in python and flask web apps. Most exam papers test it via a mix of definitions, worked examples, and applied problems. The notes above cover the high-yield sub-topics, common pitfalls, and the kind of questions examiners typically set.

Yes — every note in the StudyAI Campus Hub is free to read in full, right here on this page, with no account needed. If you clone the plan into your own dashboard, the free plan shows a preview of each note there; Basic and above unlock the full notes in your dashboard, along with practice quizzes, flashcards and offline study. You can always come back here to read the complete note for free.

Study this next


Get the full Tests in python and flask web apps curriculum

Clone the complete plan to your dashboard for unlimited AI-generated notes, practice quizzes, and a personalised revision schedule.

Save this course free