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.
What Is Testing?
Section titled “What Is Testing?”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) == 5If the result is not 5, Python raises an AssertionError to tell you something is wrong.
Why Testing Matters
Section titled “Why Testing Matters”- 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
When To Use Testing
Section titled “When To Use Testing”Testing is almost always a good idea, but how much you write should match where you are in the project.
Great times to invest heavily in tests
Section titled “Great times to invest heavily in tests”- 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
Practical rule for beginners
Section titled “Practical rule for beginners”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.
Testing Pyramid
Section titled “Testing Pyramid”- Unit tests - fast, small, and make up most of your tests
- Integration tests - check that different parts of your code work together
- End-to-end tests - test the full user flow from start to finish, slower, fewer of them
Assertion Testing with assert
Section titled “Assertion Testing with assert”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
Limitations
Section titled “Limitations”- 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
Section titled “unittest”unittest is built into Python, so you do not need to install anything.
Basic test case
Section titled “Basic test case”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()Common assertions
Section titled “Common assertions”self.assertEqual(a, b)- check thataandbare equalself.assertTrue(x)- check thatxis trueself.assertFalse(x)- check thatxis falseself.assertIn(item, collection)- check thatitemis inside a list or setself.assertRaises(ErrorType)- check that a specific error is raised
Setup and Teardown
Section titled “Setup and Teardown”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, [])Class-level setup/teardown
Section titled “Class-level setup/teardown”@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
Section titled “pytest”pytest is very popular because it is simple to write, powerful, and has lots of useful plugins.
Install
Section titled “Install”uv add pytestWriting tests in pytest
Section titled “Writing tests in 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) == 0Run tests
Section titled “Run tests”uv run pytestUseful commands:
pytest -qfor shorter, cleaner outputpytest -k "name_filter"to run only tests whose name matches a keywordpytest -xto stop as soon as the first test failspytest -vto show the full name of each test as it runs
Pytest Fixtures
Section titled “Pytest Fixtures”Fixtures let you share setup code between multiple tests without repeating yourself.
import pytest
@pytest.fixturedef 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"Fixture scopes
Section titled “Fixture scopes”Test Discovery
Section titled “Test Discovery”Both unittest and pytest can find your test files automatically without you telling them exactly where to look.
Common naming convention
Section titled “Common naming convention”- file names:
test_*.pyor*_test.py - test functions: start with
test_ - test classes: usually start with
Test
Run discovery
Section titled “Run discovery”# pytest discoverypytest
# unittest discoverypython -m unittest discoverTesting Exceptions
Section titled “Testing Exceptions”Sometimes you want to check that your code raises the right error when something goes wrong.
With unittest
Section titled “With unittest”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)With pytest
Section titled “With pytest”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.
A. pytest monkeypatch for user input
Section titled “A. pytest monkeypatch for user input”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.
B. unittest.mock.patch for input
Section titled “B. unittest.mock.patch for input”import unittestfrom 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)C. Monkeypatch environment variable
Section titled “C. Monkeypatch environment variable”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"D. Mock network request
Section titled “D. Mock network request”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
Project Structure Example
Section titled “Project Structure Example”my_project/ src/ calculator.py tests/ test_calculator.py pyproject.tomlKeep your tests in a separate tests/ folder so they are easy to find and don’t get mixed up with your main code.
Good Testing Habits
Section titled “Good Testing Habits”Arrange-Act-Assert pattern
Section titled “Arrange-Act-Assert pattern”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 == 90Common Mistakes and How To Avoid Them
Section titled “Common Mistakes and How To Avoid Them”Choosing Between assert, unittest, and pytest
Section titled “Choosing Between assert, unittest, and pytest”| Option | Best For | Pros | Cons |
|---|---|---|---|
assert | Quick checks, learning | simplest to use | not scalable for big projects |
unittest | Standard library-only projects, enterprise legacy code | built-in, structured | more boilerplate code to write |
pytest | Modern projects of all sizes | concise, powerful fixtures, many plugins | needs to be installed separately |
Recommendation for most beginners starting a real project: go with pytest.
Minimal Testing Plan for New Projects
Section titled “Minimal Testing Plan for New Projects”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