unittest - The Standard Library Test Framework
Reading time: ~30 minutes | Level: Intermediate → Engineering
Before reading further, predict exactly how many tests this suite contains:
import unittest
class TestMath(unittest.TestCase):
def test_add(self):
result = 1 + 1
self.assertEqual(result, 2)
def test_subtract(self):
result = 5 - 3
self.assertEqual(result, 2)
def runTest(self): # what is this for?
pass
suite = unittest.TestLoader().loadTestsFromTestCase(TestMath)
print(len(list(suite))) # ?
Show Answer
Output: 2
loadTestsFromTestCase collects methods whose names begin with test. It finds test_add and test_subtract.
runTest is not collected - even though it is the conventional method name used when you instantiate a TestCase directly without a TestLoader. The rule is:
- If a
TestCasesubclass has any methods starting withtest, theTestLoadercollects those methods and ignoresrunTestentirely. runTestis only used when you instantiate the class directly:tc = TestMath(); tc.runTest().
This surprises most developers who assume runTest is always collected, or that the TestLoader uses special-case logic to detect it. The actual rule is simpler: runTest is only a fallback, not a discovery target when test_* methods exist.
If you remove test_add and test_subtract and keep only runTest, loadTestsFromTestCase would load exactly one test - runTest. The moment any test_* method exists, runTest is invisible to the loader.
This is the kind of detail that matters when you write test utilities, custom test runners, or CI tooling that introspects test suites. Understanding the TestLoader's collection logic is understanding what runs - and what silently does not.
What You Will Learn
- The
unittest.TestCaseclass: complete lifecycle from setup to teardown - Every built-in assertion method and when to use each
assertRaisesas context manager vs method call - why the context manager form is strictly bettersetUpandtearDownfor test isolation;setUpClassandtearDownClassfor expensive shared stateunittest.mock.patch: the import location rule and why patching at the wrong location silently does nothingTestSuite,TestLoader,TextTestRunnerfor programmatic test executionskip,skipIf,skipUnless,expectedFailuredecorators- Subtests with
self.subTestfor parametrised tests that don't abort on the first failure - When
unittestis the right choice vs when to reach forpytest
Prerequisites
- Python Foundation course (classes, inheritance, exception handling)
- Module 01 - Object-Oriented Programming (class hierarchy and
super()-TestCaseis a class you subclass) - Familiarity with Python's
try/exceptsyntax
Part 1 - The TestCase Lifecycle
The Structure of Every unittest Test
Every unittest test lives inside a class that inherits from unittest.TestCase. Each test is a method whose name starts with test:
import unittest
class TestBankAccount(unittest.TestCase):
def test_deposit_increases_balance(self):
account = BankAccount(balance=100)
account.deposit(50)
self.assertEqual(account.balance, 150)
def test_withdraw_decreases_balance(self):
account = BankAccount(balance=100)
account.withdraw(30)
self.assertEqual(account.balance, 70)
Running this:
python -m unittest test_bank_account.TestBankAccount
The Full Lifecycle
unittest executes a precise lifecycle for every test class. Understanding this lifecycle is the foundation of writing correctly isolated tests.
Key observations:
- A new instance is created for every test method. Tests share no instance state by default.
setUpruns before every test.tearDownruns after every test - even if the test fails (unlesssetUpitself raised an exception).setUpClassruns once before any test in the class.tearDownClassruns once after all tests complete.tearDownis guaranteed to run even when the test raises. This is how you ensure cleanup happens.
Lifecycle in Code
import unittest
import tempfile
import os
class TestWithLifecycle(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Runs once before any test in this class."""
print("\nsetUpClass: creating shared database connection")
cls.db_connection = create_db_connection() # expensive - do once
@classmethod
def tearDownClass(cls):
"""Runs once after all tests in this class complete."""
print("tearDownClass: closing database connection")
cls.db_connection.close()
def setUp(self):
"""Runs before EVERY test. Use for per-test isolation."""
self.temp_file = tempfile.NamedTemporaryFile(delete=False)
self.temp_path = self.temp_file.name
def tearDown(self):
"""Runs after EVERY test - even on failure."""
self.temp_file.close()
os.unlink(self.temp_path) # always clean up
def test_write_to_file(self):
self.temp_file.write(b"hello")
self.temp_file.flush()
with open(self.temp_path, "rb") as f:
self.assertEqual(f.read(), b"hello")
def test_empty_file(self):
self.temp_file.flush()
self.assertEqual(os.path.getsize(self.temp_path), 0)
:::warning setUp Runs Before Every Single Test
setUp is called before each test method - not once before the class. If setUp creates a database connection, opens a file, or makes a network call, that cost is paid once per test. For a test class with 50 tests, that is 50 database connections.
Expensive shared resources belong in setUpClass. Per-test isolation (unique data, temporary files, clean state) belongs in setUp.
:::
Part 2 - Assertion Methods
unittest.TestCase provides a rich set of assertion methods. Each produces a more informative error message than a raw assert statement.
Equality and Identity
class TestAssertions(unittest.TestCase):
def test_equality(self):
self.assertEqual(1 + 1, 2) # fails if values are not equal
self.assertNotEqual(1 + 1, 3) # fails if values are equal
self.assertIs(None, None) # fails if not the same object (identity, not equality)
self.assertIsNot([], []) # two different list objects
self.assertIsNone(None) # shorthand for assertIs(x, None)
self.assertIsNotNone("hello") # shorthand for assertIsNot(x, None)
def test_boolean(self):
self.assertTrue(1 < 2) # fails if not truthy
self.assertFalse(1 > 2) # fails if not falsy
# Prefer assertEqual for concrete values - assertTrue(x == 2) gives a worse error message
# than assertEqual(x, 2)
Collection Membership and Containment
def test_containment(self):
self.assertIn("admin", ["user", "admin", "moderator"])
self.assertNotIn("root", ["user", "admin", "moderator"])
# For dictionaries
config = {"debug": True, "port": 8080}
self.assertIn("debug", config) # checks keys
self.assertIn("port", config)
Numeric Approximation
def test_float_comparison(self):
# NEVER use assertEqual for floats
result = 0.1 + 0.2 # 0.30000000000000004 in IEEE 754
# self.assertEqual(result, 0.3) # FAILS
self.assertAlmostEqual(result, 0.3) # default 7 decimal places
self.assertAlmostEqual(result, 0.3, places=5) # 5 decimal places
self.assertAlmostEqual(result, 0.3, delta=0.001) # absolute difference
String Matching
def test_strings(self):
error_message = "Connection refused: host=db.prod.internal port=5432"
self.assertRegex(error_message, r"port=\d+")
self.assertNotRegex(error_message, r"password=\w+") # no credentials leaked
Exception Assertions
def test_exceptions(self):
with self.assertRaises(ValueError):
int("not a number")
with self.assertRaises(ZeroDivisionError):
result = 1 / 0
Complete Reference
| Method | Passes when | Use for |
|---|---|---|
assertEqual(a, b) | a == b | Values match |
assertNotEqual(a, b) | a != b | Values differ |
assertTrue(x) | bool(x) is True | Truthy check (prefer assertEqual when you have concrete values) |
assertFalse(x) | bool(x) is False | Falsy check |
assertIs(a, b) | a is b | Same object identity |
assertIsNot(a, b) | a is not b | Different objects |
assertIsNone(x) | x is None | Explicit None check |
assertIsNotNone(x) | x is not None | Explicit non-None check |
assertIn(a, b) | a in b | Membership in sequence or dict keys |
assertNotIn(a, b) | a not in b | Non-membership |
assertAlmostEqual(a, b) | round(a-b, 7) == 0 | Float comparison with tolerance |
assertNotAlmostEqual(a, b) | round(a-b, 7) != 0 | Floats are different enough |
assertGreater(a, b) | a > b | Ordering |
assertGreaterEqual(a, b) | a >= b | Ordering |
assertLess(a, b) | a < b | Ordering |
assertLessEqual(a, b) | a <= b | Ordering |
assertRegex(s, r) | re.search(r, s) | String matches pattern |
assertNotRegex(s, r) | not re.search(r, s) | String does not match |
assertRaises(exc) | Exception is raised | Exception handling (use as context manager) |
assertWarns(warn) | Warning is issued | Warning handling |
assertLogs(logger, level) | Log message is emitted | Logging output |
assertMultiLineEqual(a, b) | Multiline strings equal | Long string comparison with diff output |
assertListEqual(a, b) | Lists equal | Lists with better diff output |
assertDictEqual(a, b) | Dicts equal | Dicts with better diff output |
assertSetEqual(a, b) | Sets equal | Sets with better diff output |
assertTupleEqual(a, b) | Tuples equal | Tuples with better diff output |
:::tip Use assertRaises as a Context Manager
assertRaises can be called two ways. The context manager form is strictly better:
# BAD - method call form: cannot inspect the exception
self.assertRaises(ValueError, int, "bad")
# GOOD - context manager form: captures exception for inspection
with self.assertRaises(ValueError) as ctx:
int("bad")
# Now you can assert on the exception message
self.assertIn("invalid literal", str(ctx.exception))
self.assertEqual(ctx.exception.args[0], "invalid literal for int() with base 10: 'bad'")
The method call form only checks that the right exception type was raised. The context manager form gives you the actual exception object, letting you assert that the error message is correct - which is often the most important thing to verify. :::
:::danger Never Use Bare except in Tests
# NEVER do this
def test_something(self):
try:
result = risky_operation()
self.assertEqual(result, expected)
except: # catches EVERYTHING - including test framework signals
pass # test silently passes even when the operation failed
# The correct pattern
def test_something(self):
result = risky_operation() # let exceptions propagate naturally
self.assertEqual(result, expected)
# Or, if you expect an exception
def test_raises_on_bad_input(self):
with self.assertRaises(ValueError):
risky_operation("bad_input")
A bare except in a test catches SystemExit, KeyboardInterrupt, and the internal signals that unittest uses to communicate test results. It can cause tests to silently pass when they should fail, making the entire test suite unreliable.
:::
Part 3 - Test Isolation with setUp and tearDown
Per-Test Isolation
Every test should be independent: it should not depend on the state left by a previous test, and it should not leave state that affects subsequent tests.
setUp and tearDown enforce this by resetting state around every test:
import unittest
import sqlite3
class TestUserRepository(unittest.TestCase):
def setUp(self):
"""Create a fresh in-memory database for each test."""
self.conn = sqlite3.connect(":memory:")
self.cursor = self.conn.cursor()
self.cursor.execute("""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT NOT NULL
)
""")
self.conn.commit()
self.repo = UserRepository(self.conn)
def tearDown(self):
"""Close the connection - even if the test failed."""
self.conn.close()
def test_create_user(self):
self.assertEqual(user.username, "alice")
self.assertIsNotNone(user.id)
def test_duplicate_username_raises(self):
with self.assertRaises(ValueError):
def test_find_user_by_id(self):
found = self.repo.find_by_id(created.id)
self.assertEqual(found.username, "bob")
Each test starts with a clean, empty database. test_duplicate_username_raises creates an alice user, but that user disappears before test_find_user_by_id runs because a new connection and schema are created in the next setUp call.
Shared Expensive Setup with setUpClass
import unittest
class TestWithSharedServer(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Start the server once - not once per test."""
cls.server = MockServer()
cls.server.start()
cls.client = APIClient(base_url=cls.server.url)
@classmethod
def tearDownClass(cls):
"""Shut down after all tests in the class complete."""
cls.server.stop()
def setUp(self):
"""Reset just the per-test state - not the server."""
self.client.reset_session()
def test_get_users_returns_list(self):
response = self.client.get("/users")
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.json(), list)
def test_create_user_returns_201(self):
response = self.client.post("/users", json=payload)
self.assertEqual(response.status_code, 201)
The server starts once. All three lifecycle levels work together:
setUpClass/tearDownClass: server startup and shutdown (seconds)setUp/tearDown: session reset (milliseconds)- Each test: isolated assertions against the shared server
Part 4 - Mocking with unittest.mock.patch
Where to Patch - The Critical Rule
The most common unittest.mock mistake is patching at the wrong location. This causes the mock to have no effect at all - the real object is still used, and the test either fails or, worse, silently passes while calling real external services.
The rule: patch where the name is used, not where it is defined.
# payment.py
import stripe # stripe is defined in the stripe package
def charge_customer(customer_id, amount):
return stripe.Charge.create(
customer=customer_id,
amount=amount,
currency="usd"
)
# test_payment.py
import unittest
from unittest.mock import patch, MagicMock
class TestPayment(unittest.TestCase):
def test_charge_calls_stripe(self):
# WRONG: patching stripe in its own package - has no effect on payment.py
# with patch("stripe.Charge.create") as mock_create:
# CORRECT: patching stripe as it is used in the payment module
with patch("payment.stripe.Charge.create") as mock_create:
mock_create.return_value = MagicMock(id="ch_test_123", status="succeeded")
result = charge_customer("cus_abc", 2000)
mock_create.assert_called_once_with(
customer="cus_abc",
amount=2000,
currency="usd"
)
patch as a Context Manager and Decorator
patch has two usage forms. The context manager form is recommended for its clarity:
import unittest
from unittest.mock import patch, MagicMock
from datetime import datetime
class TestReportGenerator(unittest.TestCase):
def test_report_uses_current_date(self):
fixed_date = datetime(2024, 1, 15, 10, 30, 0)
# Context manager - patch is active inside the `with` block only
with patch("report_generator.datetime") as mock_dt:
mock_dt.now.return_value = fixed_date
report = generate_report()
self.assertEqual(report.date, "2024-01-15")
@patch("report_generator.datetime") # decorator form - patch active for entire test
def test_report_title_format(self, mock_dt):
mock_dt.now.return_value = datetime(2024, 6, 1)
report = generate_report()
self.assertIn("June 2024", report.title)
Mock Assertions
from unittest.mock import Mock, MagicMock, call
class TestEmailService(unittest.TestCase):
def test_sends_welcome_email_on_registration(self):
mock_emailer = Mock()
# Was it called at all?
mock_emailer.send.assert_called_once()
# Was it called with the right arguments?
mock_emailer.send.assert_called_once_with(
subject="Welcome!",
template="welcome"
)
# How many times was it called?
self.assertEqual(mock_emailer.send.call_count, 1)
# Inspect the actual call arguments
args, kwargs = mock_emailer.send.call_args
def test_never_sends_email_if_already_registered(self):
mock_emailer = Mock()
# Should only have sent one email total
mock_emailer.send.assert_called_once()
def test_sends_multiple_emails_in_order(self):
mock_emailer = Mock()
process_order("order_123", emailer=mock_emailer)
expected_calls = [
]
mock_emailer.assert_has_calls(expected_calls, any_order=False)
side_effect for Dynamic Behavior
from unittest.mock import Mock
class TestRetryLogic(unittest.TestCase):
def test_retries_on_network_error(self):
mock_api = Mock()
# First two calls raise, third call succeeds
mock_api.fetch.side_effect = [
ConnectionError("network error"),
ConnectionError("network error"),
{"status": "ok", "data": [1, 2, 3]},
]
result = fetch_with_retry(mock_api, max_retries=3)
self.assertEqual(result["status"], "ok")
self.assertEqual(mock_api.fetch.call_count, 3)
def test_raises_after_max_retries(self):
mock_api = Mock()
mock_api.fetch.side_effect = ConnectionError("always fails")
with self.assertRaises(ConnectionError):
fetch_with_retry(mock_api, max_retries=3)
self.assertEqual(mock_api.fetch.call_count, 3)
:::note unittest.mock Is Part of the Standard Library
unittest.mock has been part of the Python standard library since Python 3.3. No installation required:
from unittest.mock import Mock, MagicMock, patch, patch.object, call, sentinel
If you are working in a stdlib-only environment or cannot install pytest-mock, unittest.mock provides everything you need for comprehensive mocking.
:::
Part 5 - TestSuite, TestLoader, and TextTestRunner
For most projects, running python -m unittest or pytest is sufficient. But in some situations - building CI tooling, creating custom test runners, integrating tests into a larger application - you need programmatic control over test discovery and execution.
import unittest
# Load tests from specific classes
loader = unittest.TestLoader()
suite = unittest.TestSuite()
suite.addTests(loader.loadTestsFromTestCase(TestBankAccount))
suite.addTests(loader.loadTestsFromTestCase(TestPaymentProcessor))
# Add a specific test method
suite.addTest(TestBankAccount("test_deposit_increases_balance"))
# Run with configurable verbosity
runner = unittest.TextTestRunner(verbosity=2, failfast=True)
result = runner.run(suite)
# Inspect results programmatically
print(f"Tests run: {result.testsRun}")
print(f"Failures: {len(result.failures)}")
print(f"Errors: {len(result.errors)}")
print(f"Skipped: {len(result.skipped)}")
# Exit with non-zero code on failure (for CI)
import sys
sys.exit(0 if result.wasSuccessful() else 1)
Discovery
# Discover all tests matching pattern in directory tree
suite = loader.discover(
start_dir="tests/",
pattern="test_*.py",
top_level_dir="."
)
Part 6 - Skipping Tests and Expected Failures
Skip Decorators
import unittest
import sys
import os
class TestPlatformSpecific(unittest.TestCase):
@unittest.skip("temporarily disabled pending refactor")
def test_legacy_endpoint(self):
# This test is skipped unconditionally
pass
@unittest.skipIf(sys.platform == "win32", "POSIX filesystem semantics required")
def test_symlink_handling(self):
# Skipped automatically on Windows
pass
@unittest.skipUnless(os.environ.get("INTEGRATION_TESTS"), "requires INTEGRATION_TESTS env var")
def test_database_integration(self):
# Only runs when INTEGRATION_TESTS is set - clean separation in CI
pass
@unittest.skipIf(sys.version_info < (3, 11), "requires Python 3.11+ tomllib")
def test_toml_parsing(self):
import tomllib
with open("pyproject.toml", "rb") as f:
config = tomllib.load(f)
self.assertIn("project", config)
Expected Failures
@unittest.expectedFailure
def test_known_bug_in_upstream_library(self):
# This test is expected to fail - marks a known issue
# If the test passes, it becomes an "unexpected success" (XPASS)
result = upstream_library.compute(edge_case_input)
self.assertEqual(result, expected_value)
@expectedFailure is the correct way to document a known bug without having it block CI. When the bug is fixed and the test starts passing, the test framework reports it as an unexpected success - your signal to remove the decorator.
Part 7 - Subtests for Parametrised Testing
Without subtests, a test method stops at the first assertion failure. If you want to test multiple inputs, a single bad case means you never see results for the rest:
class TestStringUtils(unittest.TestCase):
def test_title_case_without_subtest(self):
cases = [
("hello world", "Hello World"),
("ALREADY UPPER", "Already Upper"),
("mixed Case", "Mixed Case"),
("", ""), # if this fails...
("single", "Single"), # ...you never see this result
]
for input_str, expected in cases:
result = to_title_case(input_str)
self.assertEqual(result, expected) # stops at first failure
With self.subTest, each iteration is an independent sub-test. All cases run even when some fail:
class TestStringUtils(unittest.TestCase):
def test_title_case_with_subtest(self):
cases = [
("hello world", "Hello World"),
("ALREADY UPPER", "Already Upper"),
("mixed Case", "Mixed Case"),
("", ""),
("single", "Single"),
]
for input_str, expected in cases:
with self.subTest(input=input_str): # label appears in failure output
result = to_title_case(input_str)
self.assertEqual(result, expected)
Failure output with subtests:
FAIL: test_title_case_with_subtest (input='ALREADY UPPER')
FAIL: test_title_case_with_subtest (input='')
You see every failing case in one run, not just the first. This is the unittest equivalent of @pytest.mark.parametrize.
Part 8 - Running Tests
Command Line
# Discover and run all tests
python -m unittest
# Run a specific module
python -m unittest test_bank_account
# Run a specific class
python -m unittest test_bank_account.TestBankAccount
# Run a specific method
python -m unittest test_bank_account.TestBankAccount.test_deposit_increases_balance
# Verbose output - shows each test name and result
python -m unittest -v test_bank_account
# Stop on first failure
python -m unittest -f test_bank_account
# Discover tests in a directory
python -m unittest discover -s tests/ -p "test_*.py"
unittest.main()
if __name__ == "__main__":
unittest.main(verbosity=2)
unittest.main() calls sys.exit() after running, which is why it is always inside if __name__ == "__main__". It uses sys.argv to support the same options as python -m unittest.
When to Use unittest
unittest is the right choice in specific situations:
Stdlib-only environments. If you cannot install third-party packages (embedded systems, restricted corporate environments, minimal Docker images), unittest is the only option.
Java/JUnit background. Developers coming from Java find unittest's class-based, setUp/tearDown structure immediately familiar. The mapping from JUnit concepts to unittest is nearly one-to-one.
Existing unittest codebase. If a project already has a large unittest suite, continuing in unittest avoids mixing styles and leverages existing infrastructure.
Custom test runners. The TestSuite, TestLoader, and TestResult APIs give you programmatic control that pytest does not expose as cleanly.
For new projects with no constraints, pytest is generally preferred - see Lesson 02 for why. The good news: pytest can discover and run unittest.TestCase classes without modification. The two frameworks are fully compatible.
Key Takeaways
unittest.TestCasecreates a new instance for every test method. Tests share no instance state unless you explicitly usesetUpClass.- The lifecycle order is:
setUpClass→ (setUp→ test →tearDown) × N →tearDownClass.tearDownruns even when the test fails. assertRaisesas a context manager is strictly better than the method call form - it gives you the exception object for further assertions.- Patch at the import location (where the name is used), not the definition location. Patching at the wrong location has no effect.
unittest.mockis stdlib - no installation needed.self.subTestallows multiple parametrised cases within one test method without stopping at the first failure.@unittest.skipIfand@unittest.skipUnlessare the clean way to handle platform-specific or environment-specific tests in CI.@unittest.expectedFailuredocuments known bugs without blocking CI - and flags when the bug is fixed.
Graded Practice
Level 1 - Predict and Identify
Problem 1: What does setUpClass receive as its first argument? What is the difference between cls in setUpClass and self in setUp?
Show Answer
setUpClass receives the class itself as its first argument (conventionally named cls). It is decorated with @classmethod.
self in setUp is an instance of the class - a freshly created object for each test.
The distinction matters because:
- Attributes set on
clsinsetUpClass(e.g.,cls.db_connection) are accessible on all instances asself.db_connection - Attributes set on
selfinsetUpare isolated to that test's instance and disappear aftertearDown
@classmethod
def setUpClass(cls):
cls.db = create_connection() # shared across all tests in this class
def setUp(self):
self.transaction = self.db.begin() # fresh per test; self.db comes from cls.db
Problem 2: Predict the output:
import unittest
class TestOrder(unittest.TestCase):
@classmethod
def setUpClass(cls):
print("setUpClass")
def setUp(self):
print("setUp")
def test_a(self):
print("test_a")
def test_b(self):
print("test_b")
def tearDown(self):
print("tearDown")
@classmethod
def tearDownClass(cls):
print("tearDownClass")
Show Answer
setUpClass
setUp
test_a
tearDown
setUp
test_b
tearDown
tearDownClass
Test methods run in alphabetical order by default (test_a before test_b). The class-level methods run exactly once. The instance-level methods run once per test.
Level 2 - Debug and Fix
Problem 3: This test is supposed to verify that divide() raises ZeroDivisionError for zero input. It always passes, even when the function is completely broken. Find and fix the bug:
import unittest
def divide(a, b):
return "not implemented" # broken - returns string, not a number
class TestDivide(unittest.TestCase):
def test_zero_division(self):
try:
result = divide(10, 0)
except ZeroDivisionError:
pass # test passes if exception raised
# but also passes if no exception raised - the function just returns "not implemented"
Show Answer
The bug: the try/except swallows the ZeroDivisionError correctly, but there is no assertion in the "exception not raised" path. If divide(10, 0) returns "not implemented" (or anything without raising), the test falls through to the end of the method and passes silently.
Fixed version:
class TestDivide(unittest.TestCase):
def test_zero_division(self):
with self.assertRaises(ZeroDivisionError):
divide(10, 0)
# assertRaises fails if NO exception is raised - that is the missing assertion
The context manager form of assertRaises handles both cases correctly:
- If
ZeroDivisionErroris raised: test passes - If no exception is raised: test fails with
AssertionError: ZeroDivisionError not raised - If a different exception is raised: that exception propagates and the test errors
Problem 4: This mock is not working - the real requests.get is being called instead of the mock. Why?
# api_client.py
import requests
def get_user(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
return response.json()
# test_api_client.py
import unittest
from unittest.mock import patch
class TestAPIClient(unittest.TestCase):
def test_get_user(self):
with patch("requests.get") as mock_get:
mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}
result = get_user(1)
self.assertEqual(result["name"], "Alice")
Show Answer
The mock patches requests.get in the requests module itself. But api_client.py has already imported requests and calls requests.get through its own reference to the requests module.
Patching requests.get at the requests module level does work in this case - but only because api_client.py uses requests.get (going through the module object) rather than having imported get directly:
# If api_client.py had done:
from requests import get # <-- now get is a direct name in api_client
# Then you would need:
patch("api_client.get") # patch the name in api_client's namespace
The current test actually has the right patch target for how api_client.py is written. The more likely real bug is a missing import of get_user in the test. The corrected test:
from api_client import get_user # must import the function under test
class TestAPIClient(unittest.TestCase):
def test_get_user(self):
with patch("api_client.requests.get") as mock_get: # safer explicit form
mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}
result = get_user(1)
self.assertEqual(result["name"], "Alice")
Patching api_client.requests.get is always unambiguous: it patches the requests.get attribute as it is accessed through api_client's requests reference.
Level 3 - Design
Problem 5: You are building a CacheService class that wraps an external key-value store. The class has three methods: get(key), set(key, value, ttl), and delete(key). The external store client can be injected at construction time.
Design a complete unittest.TestCase subclass for this service. Your design must:
- Use
setUpto create a fresh mock client for each test - Test that
getreturnsNonefor missing keys, not raises - Test that
setpasses the correct arguments to the underlying client - Test that
deletereturnsTrueon success andFalsewhen the key did not exist - Use
assertRaisesto verify thatsetraisesValueErrorfor TTL values of zero or below - Use
self.subTestto test multiple invalid TTL values without stopping at the first failure
Show Answer
import unittest
from unittest.mock import Mock, MagicMock
class TestCacheService(unittest.TestCase):
def setUp(self):
self.mock_client = Mock()
self.cache = CacheService(client=self.mock_client)
def test_get_returns_none_for_missing_key(self):
self.mock_client.get.return_value = None
result = self.cache.get("missing_key")
self.assertIsNone(result)
self.mock_client.get.assert_called_once_with("missing_key")
def test_get_returns_value_for_existing_key(self):
self.mock_client.get.return_value = "cached_value"
result = self.cache.get("existing_key")
self.assertEqual(result, "cached_value")
def test_set_passes_correct_arguments(self):
self.cache.set("session:abc", {"user_id": 42}, ttl=3600)
self.mock_client.set.assert_called_once_with(
"session:abc",
{"user_id": 42},
ex=3600 # or whatever the client's TTL parameter is named
)
def test_delete_returns_true_on_success(self):
self.mock_client.delete.return_value = 1 # Redis convention: 1 = deleted
result = self.cache.delete("session:abc")
self.assertTrue(result)
def test_delete_returns_false_when_key_absent(self):
self.mock_client.delete.return_value = 0 # Redis convention: 0 = not found
result = self.cache.delete("nonexistent")
self.assertFalse(result)
def test_set_raises_for_invalid_ttl(self):
invalid_ttls = [0, -1, -100, -3600]
for ttl in invalid_ttls:
with self.subTest(ttl=ttl):
with self.assertRaises(ValueError):
self.cache.set("key", "value", ttl=ttl)
Key design decisions:
setUpcreates a freshMock()for each test - no state bleeds between testsassertIsNoneinstead ofassertEqual(result, None)for clarityassert_called_once_withverifies both that the call happened and that it received the correct argumentsself.subTest(ttl=ttl)ensures all invalid TTL values are tested even if one raises unexpectedly- The
Mock()client means no real network calls - tests are fast and deterministic
