Skip to content

Testing

Testing is one of the best habits you can pick up as a Python developer.

If writing code is “building something,” then testing is “making sure what you built actually works.”

Good tests help you:

  • catch bugs before they reach users
  • change your code safely without breaking things
  • show other developers what your code is supposed to do
  • ship your project with confidence

In this chapter, you will learn testing from the very basics all the way to practical patterns used in real projects.

flowchart LR A[Write small function] --> B[Write tests] B --> C[Run tests] C --> D{Pass?} D -- No --> E[Fix code or test] E --> C D -- Yes --> F[Refactor safely] F --> C

Testing means checking whether your code does what you expect it to do for different inputs and situations.

Simple example:

def add(a, b):
return a + b
assert add(2, 3) == 5

If the result is not 5, Python raises an AssertionError to tell you something is wrong.

  • Stops old features from breaking when you add new code (called regressions)
  • Makes it faster to find and fix bugs
  • Helps team members work together with fewer surprises
  • Acts like runnable documentation that shows how the code should behave

Testing is almost always a good idea, but how much you write should match where you are in the project.

  • Production apps (web apps, APIs, fintech, health, education platforms)
  • Shared libraries that many people use
  • Code with important business rules (pricing, calculations, permissions)
  • Long-lived projects that get updated often

Early-stage cases where lightweight testing is enough

Section titled “Early-stage cases where lightweight testing is enough”
  • Small throwaway scripts you will only use once
  • Personal automation scripts
  • Very early prototypes where the requirements change every few hours

At the start of a project:

  • Write tests for the core logic from day one
  • Skip heavy end-to-end tests until the main flows are stable
  • Add more test coverage as the project matures and more people use it

So testing is still valuable from the beginning - just start smart, not heavy.

  1. Unit tests - fast, small, and make up most of your tests
  2. Integration tests - check that different parts of your code work together
  3. End-to-end tests - test the full user flow from start to finish, slower, fewer of them
graph TD E2E[End-to-End Tests<br/>few, slow] --> INT[Integration Tests<br/>some, medium] INT --> UNIT[Unit Tests<br/>many, fast]

assert is the simplest way to write a test in Python.

def is_even(n):
return n % 2 == 0
assert is_even(4)
assert is_even(7) is False
  • extremely easy to start with
  • no setup or installation needed
  • hard to organise in large projects
  • gives weak error messages compared to testing frameworks
  • can be turned off when running Python with optimised mode (python -O)

Use assert for quick checks and while learning. For real projects, use unittest or pytest instead.

unittest is built into Python, so you do not need to install anything.

import unittest
def add(a, b):
return a + b
class TestMath(unittest.TestCase):
def test_add_positive(self):
self.assertEqual(add(2, 3), 5)
def test_add_negative(self):
self.assertEqual(add(-1, -1), -2)
if __name__ == "__main__":
unittest.main()
  • self.assertEqual(a, b) - check that a and b are equal
  • self.assertTrue(x) - check that x is true
  • self.assertFalse(x) - check that x is false
  • self.assertIn(item, collection) - check that item is inside a list or set
  • self.assertRaises(ErrorType) - check that a specific error is raised

Use setup and teardown when your tests need the same preparation and cleanup steps run before and after each test.

import unittest
class TestUserDB(unittest.TestCase):
def setUp(self):
# runs before each test method
self.users = []
def tearDown(self):
# runs after each test method
self.users.clear()
def test_add_user(self):
self.users.append("Aman")
self.assertEqual(len(self.users), 1)
def test_users_starts_empty(self):
self.assertEqual(self.users, [])
  • @classmethod setUpClass(cls) runs once before all the tests in the class
  • @classmethod tearDownClass(cls) runs once after all the tests in the class

Use these when the setup is expensive and slow, like opening a database connection, so you only do it once instead of before every single test.

pytest is very popular because it is simple to write, powerful, and has lots of useful plugins.

Terminal window
uv add pytest

You write plain test functions - no class required.

def multiply(a, b):
return a * b
def test_multiply_positive_numbers():
assert multiply(3, 4) == 12
def test_multiply_by_zero():
assert multiply(9, 0) == 0
Terminal window
uv run pytest

Useful commands:

  • pytest -q for shorter, cleaner output
  • pytest -k "name_filter" to run only tests whose name matches a keyword
  • pytest -x to stop as soon as the first test fails
  • pytest -v to show the full name of each test as it runs

Fixtures let you share setup code between multiple tests without repeating yourself.

import pytest
@pytest.fixture
def sample_user():
return {"name": "Sahil", "role": "student"}
def test_user_name(sample_user):
assert sample_user["name"] == "Sahil"
def test_user_role(sample_user):
assert sample_user["role"] == "student"

Both unittest and pytest can find your test files automatically without you telling them exactly where to look.

  • file names: test_*.py or *_test.py
  • test functions: start with test_
  • test classes: usually start with Test
Terminal window
# pytest discovery
pytest
# unittest discovery
python -m unittest discover

Sometimes you want to check that your code raises the right error when something goes wrong.

import unittest
def divide(a, b):
return a / b
class TestDivide(unittest.TestCase):
def test_divide_by_zero(self):
with self.assertRaises(ZeroDivisionError):
divide(10, 0)
import pytest
def divide(a, b):
return a / b
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(10, 0)

Monkeypatch and Mocking Input/External Calls

Section titled “Monkeypatch and Mocking Input/External Calls”

Real-world code often depends on things that are hard to test directly, like:

  • asking the user for input
  • the current time
  • network calls to an API
  • environment variables

To test these properly, you temporarily replace those dependencies with fake versions during the test.

This is useful when your function calls input() to ask the user for something.

def ask_name():
name = input("Enter name: ")
return f"Hello {name}"
def test_ask_name(monkeypatch):
monkeypatch.setattr("builtins.input", lambda _: "Aman")
assert ask_name() == "Hello Aman"

The test runs automatically without anyone needing to type anything.

import unittest
from unittest.mock import patch
def ask_age():
age = input("Enter age: ")
return int(age)
class TestInput(unittest.TestCase):
@patch("builtins.input", return_value="21")
def test_ask_age(self, _mock_input):
self.assertEqual(ask_age(), 21)
def get_mode():
import os
return os.getenv("APP_MODE", "dev")
def test_get_mode(monkeypatch):
monkeypatch.setenv("APP_MODE", "test")
assert get_mode() == "test"
import requests
def fetch_data(url):
response = requests.get(url)
return response.json()
def test_fetch_data(monkeypatch):
class MockResponse:
def __init__(self):
self.status_code = 200
def json(self):
return {"message": "Hello"}
def mock_get(url):
# Optional: validate input
assert url == "http://fakeurl"
return MockResponse()
# Patch requests.get
monkeypatch.setattr(requests, "get", mock_get)
result = fetch_data("http://fakeurl")
assert result == {"message": "Hello"}

Instead of making a real API call in your unit test, you replace requests.get with a fake version that returns made-up data.

This makes tests:

  • faster to run
  • reliable (they don’t depend on the internet or a server being up)
  • independent of anything outside your code
my_project/
src/
calculator.py
tests/
test_calculator.py
pyproject.toml

Keep your tests in a separate tests/ folder so they are easy to find and don’t get mixed up with your main code.

def test_discount_applied_for_premium_user():
# Arrange - set up the data you need
price = 100
is_premium = True
# Act - run the code you are testing
result = apply_discount(price, is_premium)
# Assert - check the result is what you expected
assert result == 90

Choosing Between assert, unittest, and pytest

Section titled “Choosing Between assert, unittest, and pytest”
OptionBest ForProsCons
assertQuick checks, learningsimplest to usenot scalable for big projects
unittestStandard library-only projects, enterprise legacy codebuilt-in, structuredmore boilerplate code to write
pytestModern projects of all sizesconcise, powerful fixtures, many pluginsneeds to be installed separately

Recommendation for most beginners starting a real project: go with pytest.

Week 1:

  • test your pure utility functions (functions that just take input and return output)
  • test your input validation logic

Week 2-3:

  • add tests for the most important workflows (like signup, payment calculation, or API response parsing)

Later:

  • add integration tests that check how your code connects to a database or external API
  • add CI (GitHub Actions) to automatically run your tests every time you push new code