Skip to content

Testing

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

If coding is “building,” then testing is “proof that what you built actually works.”

Good tests help you:

  • catch bugs early
  • refactor code safely
  • document expected behavior
  • ship with confidence

In this chapter, you will learn testing from beginner level to practical real-world usage.

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 behaves as expected for different inputs and conditions.

Simple example:

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

If the result is not 5, Python raises an AssertionError.


  • Prevents regressions (old features breaking after new changes)
  • Makes debugging faster
  • Helps teams collaborate with fewer surprises
  • Acts as executable documentation

Testing is almost always useful, but how much testing you write should match the project stage.

  • Production apps (web apps, APIs, fintech, health, education platforms)
  • Shared libraries used by many people
  • Code with business rules (pricing, calculations, permissions)
  • Long-lived projects with frequent refactors

Early-stage cases where lightweight testing is enough

Section titled “Early-stage cases where lightweight testing is enough”
  • Tiny throwaway scripts
  • One-time automation script for personal use
  • Very early prototype where requirements change every hour

At initial days of a project:

  • Write tests for core logic from day 1
  • Skip heavy end-to-end tests until flows stabilize
  • Add deeper test coverage as the project becomes stable and shared

So testing is still good at the beginning, but start smart, not heavy.


  1. Unit tests: fast, small, most of your tests
  2. Integration tests: test modules working together
  3. End-to-end tests: full user flow, slower, fewer
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 test in Python.

def is_even(n):
return n % 2 == 0
assert is_even(4)
assert is_even(7) is False
  • very easy to start
  • no setup needed
  • poor test organization for large projects
  • weak reporting compared to test frameworks
  • can be disabled with optimized mode (python -O)

Use assert for quick checks and learning. For real projects, prefer unittest or pytest.


unittest comes with Python standard library, so no installation needed.

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)
  • self.assertTrue(x)
  • self.assertFalse(x)
  • self.assertIn(item, collection)
  • self.assertRaises(ErrorType)

Use setup/teardown when tests need repeated preparation and cleanup.

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 tests
  • @classmethod tearDownClass(cls) runs once after all tests

Use these for expensive resources like database connection setup.


pytest is widely used because it is simple, powerful, and has great plugins.

Terminal window
uv add pytest

You can write simple 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 concise output
  • pytest -k "name_filter" run matching tests
  • pytest -x stop after first failure
  • pytest -v verbose names

Fixtures help you avoid duplicate setup code.

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 discover tests automatically.

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

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 code often depends on things that are hard to test directly, like:

  • user input
  • current time
  • network calls
  • environment variables

To test properly, replace those dependencies in tests.

A. pytest monkeypatch for auto input method

Section titled “A. pytest monkeypatch for auto input method”

This is useful when your function uses input().

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 now runs automatically without manual typing.

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 calling real API in unit tests, mock the request function and return fake data.

This makes tests:

  • faster
  • reliable
  • independent of internet/server uptime

my_project/
src/
calculator.py
tests/
test_calculator.py
pyproject.toml

Keep tests in a separate tests/ directory for clarity.


def test_discount_applied_for_premium_user():
# Arrange
price = 100
is_premium = True
# Act
result = apply_discount(price, is_premium)
# Assert
assert result == 90


Choosing Between assert, unittest, and pytest

Section titled “Choosing Between assert, unittest, and pytest”
OptionBest ForProsCons
assertQuick checks, learningsimplestnot scalable for big projects
unittestStandard library-only projects, enterprise legacy codebuilt-in, structuredmore boilerplate
pytestModern projects of all sizesconcise, powerful fixtures, pluginsextra dependency

Recommended for most beginners building real projects: start with pytest.


Week 1:

  • test pure utility functions
  • test input validation logic

Week 2-3:

  • add tests for important workflows (signup, payment calc, API parsing)

Later:

  • add integration tests for DB/API boundaries
  • add CI (GitHub Actions) to run tests on every push