Skip to main content

Python unittest Framework Practice Problems & Exercises

Practice: unittest Framework

11 problems4 Easy4 Medium3 Hard45–60 min
← Back to lesson

Easy

#1Your First TestCaseEasy
unittestassertEqualassertTrue

Task: Write three test methods inside TestAdd that verify the add function works for positive integers, zero, and negative integers.

Why it matters: Every test class must inherit from unittest.TestCase. Test methods must start with test_ — the test runner discovers them automatically.

Solution:

import unittest

def add(a, b):
return a + b

class TestAdd(unittest.TestCase):
def test_positive(self):
self.assertEqual(add(2, 3), 5)

def test_zero(self):
self.assertEqual(add(0, 0), 0)

def test_negative(self):
self.assertEqual(add(-1, -1), -2)

if __name__ == '__main__':
unittest.main()
import unittest

def add(a, b):
  return a + b

class TestAdd(unittest.TestCase):
  def test_positive(self):
      # assert add(2, 3) equals 5
      pass

  def test_zero(self):
      # assert add(0, 0) equals 0
      pass

  def test_negative(self):
      # assert add(-1, -1) equals -2
      pass

if __name__ == '__main__':
  unittest.main()
Expected Output
...\n----------------------------------------------------------------------\nRan 3 tests in 0.001s\n\nOK
Hints

Hint 1: Subclass `unittest.TestCase` and name every test method starting with `test_`.

Hint 2: Use `assertEqual(a, b)` to check equality, `assertTrue(expr)` for boolean checks.

Hint 3: Run with `unittest.main()` or `python -m unittest`.


#2assertRaises — Catching ExceptionsEasy
unittestassertRaisesexceptions

Task: Complete the two test methods. test_normal checks a valid division result. test_zero_divisor asserts that dividing by zero raises ZeroDivisionError.

Solution:

import unittest

def divide(a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero")
return a / b

class TestDivide(unittest.TestCase):
def test_normal(self):
self.assertEqual(divide(10, 2), 5.0)

def test_zero_divisor(self):
with self.assertRaises(ZeroDivisionError):
divide(10, 0)

if __name__ == '__main__':
unittest.main()
import unittest

def divide(a, b):
  if b == 0:
      raise ZeroDivisionError("Cannot divide by zero")
  return a / b

class TestDivide(unittest.TestCase):
  def test_normal(self):
      pass  # assert divide(10, 2) equals 5.0

  def test_zero_divisor(self):
      pass  # assert ZeroDivisionError is raised

if __name__ == '__main__':
  unittest.main()
Expected Output
..\n----------------------------------------------------------------------\nRan 2 tests in 0.001s\n\nOK
Hints

Hint 1: Use `self.assertRaises(ExceptionType)` as a context manager with the `with` statement.

Hint 2: The code inside the `with` block should raise the expected exception.

Hint 3: You can also pass a callable: `self.assertRaises(ValueError, int, "abc")`.


#3setUp and tearDown LifecycleEasy
unittestsetUptearDownfixtures

Task: Implement setUp to initialise self.items = [] and tearDown to clear it. Fill in both test methods to confirm each test starts with a fresh empty list.

Why it matters: Each test must be independent. setUp/tearDown guarantee isolation — no test pollutes another's state.

Solution:

import unittest

class TestWithFixtures(unittest.TestCase):
def setUp(self):
print("Setting up")
self.items = []

def tearDown(self):
print("Tearing down")
self.items.clear()

def test_append(self):
self.items.append(1)
self.assertEqual(len(self.items), 1)

def test_empty_on_start(self):
self.assertEqual(self.items, [])

if __name__ == '__main__':
unittest.main(verbosity=0)
import unittest

class TestWithFixtures(unittest.TestCase):
  def setUp(self):
      print("Setting up")
      # create self.items = []

  def tearDown(self):
      print("Tearing down")
      # clear self.items

  def test_append(self):
      # append 1 to self.items, assert length is 1
      pass

  def test_empty_on_start(self):
      # assert self.items is empty at start of each test
      pass

if __name__ == '__main__':
  unittest.main(verbosity=0)
Expected Output
Setting up\nTearing down\nSetting up\nTearing down\n..\n----------------------------------------------------------------------\nRan 2 tests in 0.001s\n\nOK
Hints

Hint 1: `setUp` runs before each individual test method; `tearDown` runs after each one.

Hint 2: Use `setUp` to create shared state (e.g., a list or object) so tests stay independent.

Hint 3: `tearDown` is called even if the test fails — good for cleanup.


#4Assertion Methods SamplerEasy
unittestassertionsassertInassertIsNone

Task: Fill in all four test methods using the most appropriate assertion for each check.

Solution:

import unittest

class TestAssertions(unittest.TestCase):
def test_membership(self):
fruits = ['apple', 'banana', 'cherry']
self.assertIn('banana', fruits)

def test_none_check(self):
result = None
self.assertIsNone(result)

def test_float_approx(self):
self.assertAlmostEqual(0.1 + 0.2, 0.3, places=2)

def test_type_check(self):
self.assertIsInstance(42, int)

if __name__ == '__main__':
unittest.main()
import unittest

class TestAssertions(unittest.TestCase):
  def test_membership(self):
      fruits = ['apple', 'banana', 'cherry']
      # assert 'banana' is in fruits

  def test_none_check(self):
      result = None
      # assert result is None

  def test_float_approx(self):
      # assert 0.1 + 0.2 is approximately 0.3 (2 decimal places)
      pass

  def test_type_check(self):
      # assert isinstance(42, int)
      pass

if __name__ == '__main__':
  unittest.main()
Expected Output
....\n----------------------------------------------------------------------\nRan 4 tests in 0.001s\n\nOK
Hints

Hint 1: Use `assertIn(a, b)` to check membership, like `assertIn(3, [1, 2, 3])`.

Hint 2: `assertIsNone(x)` is cleaner than `assertEqual(x, None)`.

Hint 3: `assertAlmostEqual(a, b, places=2)` handles floating-point imprecision.


Medium

#5Testing a BankAccount ClassMedium
unittestclass-testingsetUpassertRaises

Task: Write four tests for BankAccount: initial balance, valid deposit, valid withdrawal, and overdraft raising ValueError.

Solution:

import unittest

class BankAccount:
def __init__(self, balance=0):
self.balance = balance

def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self.balance += amount

def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount

class TestBankAccount(unittest.TestCase):
def setUp(self):
self.account = BankAccount(balance=100)

def test_initial_balance(self):
self.assertEqual(self.account.balance, 100)

def test_deposit(self):
self.account.deposit(50)
self.assertEqual(self.account.balance, 150)

def test_withdraw(self):
self.account.withdraw(30)
self.assertEqual(self.account.balance, 70)

def test_overdraft_raises(self):
with self.assertRaises(ValueError):
self.account.withdraw(200)

if __name__ == '__main__':
unittest.main()
import unittest

class BankAccount:
  def __init__(self, balance=0):
      self.balance = balance

  def deposit(self, amount):
      if amount <= 0:
          raise ValueError("Deposit must be positive")
      self.balance += amount

  def withdraw(self, amount):
      if amount > self.balance:
          raise ValueError("Insufficient funds")
      self.balance -= amount

class TestBankAccount(unittest.TestCase):
  def setUp(self):
      self.account = BankAccount(balance=100)

  def test_initial_balance(self):
      pass

  def test_deposit(self):
      pass

  def test_withdraw(self):
      pass

  def test_overdraft_raises(self):
      pass

if __name__ == '__main__':
  unittest.main()
Expected Output
....\n----------------------------------------------------------------------\nRan 4 tests in 0.001s\n\nOK
Hints

Hint 1: Create the `BankAccount` instance in `setUp` so every test gets a fresh account.

Hint 2: Test the happy path (deposit, withdraw) and the error path (overdraft) separately.

Hint 3: Use `assertRaises` as a context manager to catch `ValueError` on overdraft.


#6setUpClass and tearDownClassMedium
unittestsetUpClasstearDownClassclassmethod

Task: Implement setUpClass to connect the fake DB and tearDownClass to close it. Fill in the three test methods.

Why it matters: Class-level fixtures are crucial when setup is expensive (real DB, network connection, ML model load) — you don't want to repeat it for every test.

Solution:

import unittest

class FakeDatabase:
def __init__(self):
self.connected = False
def connect(self):
self.connected = True
print("DB connected")
def close(self):
self.connected = False
print("DB closed")
def query(self, sql):
return [1, 2, 3]

class TestDatabase(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.db = FakeDatabase()
cls.db.connect()

@classmethod
def tearDownClass(cls):
cls.db.close()

def test_connected(self):
self.assertTrue(self.db.connected)

def test_query_returns_list(self):
result = self.db.query("SELECT 1")
self.assertIsInstance(result, list)

def test_query_has_results(self):
result = self.db.query("SELECT 1")
self.assertEqual(len(result), 3)

if __name__ == '__main__':
unittest.main(verbosity=0)
import unittest

class FakeDatabase:
  def __init__(self):
      self.connected = False
  def connect(self):
      self.connected = True
      print("DB connected")
  def close(self):
      self.connected = False
      print("DB closed")
  def query(self, sql):
      return [1, 2, 3]

class TestDatabase(unittest.TestCase):
  @classmethod
  def setUpClass(cls):
      cls.db = FakeDatabase()
      # connect the database

  @classmethod
  def tearDownClass(cls):
      # close the database
      pass

  def test_connected(self):
      pass  # assert db is connected

  def test_query_returns_list(self):
      pass  # assert query returns a list

  def test_query_has_results(self):
      pass  # assert query has 3 items

if __name__ == '__main__':
  unittest.main(verbosity=0)
Expected Output
DB connected\n...\nDB closed\n----------------------------------------------------------------------\nRan 3 tests in 0.001s\n\nOK
Hints

Hint 1: Decorate with `@classmethod` and receive `cls` instead of `self`.

Hint 2: `setUpClass` runs once before all tests in the class — use for expensive setup like DB connections.

Hint 3: `tearDownClass` runs once after all tests complete.


#7Skipping Tests and Expected FailuresMedium
unittestskipskipIfexpectedFailure

Task: Add the correct decorators to each of the three test methods as described in the comments.

Solution:

import unittest
import sys

class TestSkipping(unittest.TestCase):
@unittest.skip("Not implemented yet")
def test_not_implemented_yet(self):
self.fail("Not done")

@unittest.skipIf(sys.version_info >= (3, 12), "Requires Python < 3.12")
def test_needs_older_python(self):
self.assertTrue(True)

@unittest.expectedFailure
def test_known_bug(self):
self.assertEqual(1, 2)

if __name__ == '__main__':
unittest.main(verbosity=0)
import unittest
import sys

class TestSkipping(unittest.TestCase):
  # skip this test unconditionally
  def test_not_implemented_yet(self):
      self.fail("Not done")

  # skip only on Python 3.12+
  def test_needs_older_python(self):
      self.assertTrue(True)

  # mark as expected failure
  def test_known_bug(self):
      self.assertEqual(1, 2)  # bug: always fails

if __name__ == '__main__':
  unittest.main(verbosity=0)
Expected Output
xss\n----------------------------------------------------------------------\nRan 3 tests in 0.001s\n\nOK (skipped=2, expected failures=1)
Hints

Hint 1: Use `@unittest.skip("reason")` to unconditionally skip a test.

Hint 2: `@unittest.skipIf(condition, reason)` skips when the condition is True.

Hint 3: `@unittest.expectedFailure` marks a test that is known to fail — it passes the suite if it does fail.


#8Subtest — Parametrised Checks in One MethodMedium
unittestsubTestparametrised

Task: Inside test_various_inputs, iterate over cases and use self.subTest to assert each is_palindrome call returns the expected value.

Solution:

import unittest

def is_palindrome(s):
s = s.lower().replace(" ", "")
return s == s[::-1]

class TestPalindrome(unittest.TestCase):
def test_various_inputs(self):
cases = [
("racecar", True),
("hello", False),
("A man a plan a canal Panama", True),
("", True),
("No lemon no melon", True),
]
for word, expected in cases:
with self.subTest(word=word):
self.assertEqual(is_palindrome(word), expected)

if __name__ == '__main__':
unittest.main()
import unittest

def is_palindrome(s):
  s = s.lower().replace(" ", "")
  return s == s[::-1]

class TestPalindrome(unittest.TestCase):
  def test_various_inputs(self):
      cases = [
          ("racecar", True),
          ("hello", False),
          ("A man a plan a canal Panama", True),
          ("", True),
          ("No lemon no melon", True),
      ]
      for word, expected in cases:
          # use subTest here
          pass

if __name__ == '__main__':
  unittest.main()
Expected Output
.\n----------------------------------------------------------------------\nRan 1 test in 0.001s\n\nOK
Hints

Hint 1: Use `with self.subTest(i=i)` (or any label) inside a loop.

Hint 2: If one sub-test fails, the loop continues — you see all failures at once rather than stopping at the first.

Hint 3: SubTests are ideal for testing a function against a table of input/output pairs.


Hard

#9Building a TestSuite ManuallyHard
unittestTestSuiteTestLoaderTestRunner

Task: Implement build_suite to create a suite containing only test_add and test_multiply from TestMath, then run it with TextTestRunner.

Why it matters: Manual suites let CI pipelines run targeted subsets — e.g., smoke tests only, or tests for a specific module — without running the full suite.

Solution:

import unittest

class TestMath(unittest.TestCase):
def test_add(self):
self.assertEqual(1 + 1, 2)

def test_multiply(self):
self.assertEqual(3 * 4, 12)

def test_subtract(self):
self.assertEqual(5 - 3, 2)

def build_suite():
suite = unittest.TestSuite()
suite.addTest(TestMath("test_add"))
suite.addTest(TestMath("test_multiply"))
return suite

if __name__ == '__main__':
runner = unittest.TextTestRunner(verbosity=0)
runner.run(build_suite())
import unittest

class TestMath(unittest.TestCase):
  def test_add(self):
      self.assertEqual(1 + 1, 2)

  def test_multiply(self):
      self.assertEqual(3 * 4, 12)

  def test_subtract(self):
      self.assertEqual(5 - 3, 2)

# Build a suite that runs only test_add and test_multiply
# (not test_subtract)
def build_suite():
  suite = unittest.TestSuite()
  # add only the two desired tests
  return suite

if __name__ == '__main__':
  runner = unittest.TextTestRunner(verbosity=0)
  runner.run(build_suite())
Expected Output
Ran 2 tests in 0.001s\n\nOK
Hints

Hint 1: Use `unittest.TestSuite()` and `suite.addTest(TestClass("method_name"))` to add individual tests.

Hint 2: `unittest.TestLoader().loadTestsFromTestCase(Class)` loads all tests from a class at once.

Hint 3: Run the suite with `unittest.TextTestRunner(verbosity=2).run(suite)`.


#10Custom TestResult — Collecting FailuresHard
unittestTestResultcustom-runner

Task: Implement addFailure in CollectingResult so it stores the test's short description in self.custom_failures and prints CUSTOM: <name> FAILED.

Solution:

import unittest

class CollectingResult(unittest.TestResult):
def __init__(self):
super().__init__()
self.custom_failures = []

def addFailure(self, test, err):
super().addFailure(test, err)
name = test.shortDescription() or str(test)
self.custom_failures.append(name)
print(f"CUSTOM: {test._testMethodName} FAILED")

class TestSample(unittest.TestCase):
def test_pass(self):
self.assertEqual(1, 1)

def test_fail(self):
self.assertEqual(1, 2)

if __name__ == '__main__':
suite = unittest.TestLoader().loadTestsFromTestCase(TestSample)
result = CollectingResult()
suite.run(result)
print(f"Total failures recorded: {len(result.custom_failures)}")
import unittest

class CollectingResult(unittest.TestResult):
  def __init__(self):
      super().__init__()
      self.custom_failures = []

  def addFailure(self, test, err):
      super().addFailure(test, err)
      # record test name in self.custom_failures
      # print: CUSTOM: <test_name> FAILED

class TestSample(unittest.TestCase):
  def test_pass(self):
      self.assertEqual(1, 1)

  def test_fail(self):
      self.assertEqual(1, 2)

if __name__ == '__main__':
  suite = unittest.TestLoader().loadTestsFromTestCase(TestSample)
  result = CollectingResult()
  suite.run(result)
  print(f"Total failures recorded: {len(result.custom_failures)}")
Expected Output
CUSTOM: test_fail FAILED\nTotal failures recorded: 1
Hints

Hint 1: Subclass `unittest.TestResult` and override `addFailure(test, err)` to hook into failure reporting.

Hint 2: `err` is a tuple of `(type, value, traceback)` — same as `sys.exc_info()`.

Hint 3: Pass your custom result to `suite.run(result)` instead of using `TextTestRunner`.


#11Integration Test with File I/O and CleanupHard
unittesttempfileintegrationaddCleanup

Task: Fill in the three test methods using make_temp_file() as shown. Verify file creation, correct round-trip reading, and an empty file case.

Why it matters: Real integration tests touch the filesystem. addCleanup ensures temp files are always removed — even when tests fail — keeping the test environment clean.

Solution:

import unittest
import tempfile
import os

def write_lines(filepath, lines):
with open(filepath, 'w') as f:
for line in lines:
f.write(line + '\n')

def read_lines(filepath):
with open(filepath, 'r') as f:
return [l.rstrip('\n') for l in f]

class TestFileIO(unittest.TestCase):
def make_temp_file(self):
fd, path = tempfile.mkstemp(suffix='.txt')
os.close(fd)
self.addCleanup(os.unlink, path)
return path

def test_write_creates_file(self):
path = self.make_temp_file()
write_lines(path, ['hello', 'world'])
self.assertTrue(os.path.exists(path))

def test_read_returns_correct_lines(self):
path = self.make_temp_file()
write_lines(path, ['alpha', 'beta', 'gamma'])
self.assertEqual(read_lines(path), ['alpha', 'beta', 'gamma'])

def test_empty_file(self):
path = self.make_temp_file()
write_lines(path, [])
self.assertEqual(read_lines(path), [])

if __name__ == '__main__':
unittest.main()
import unittest
import tempfile
import os

def write_lines(filepath, lines):
  with open(filepath, 'w') as f:
      for line in lines:
          f.write(line + '
')

def read_lines(filepath):
  with open(filepath, 'r') as f:
      return [l.rstrip('
') for l in f]

class TestFileIO(unittest.TestCase):
  def make_temp_file(self):
      fd, path = tempfile.mkstemp(suffix='.txt')
      os.close(fd)
      self.addCleanup(os.unlink, path)
      return path

  def test_write_creates_file(self):
      path = self.make_temp_file()
      write_lines(path, ['hello', 'world'])
      # assert the file exists
      pass

  def test_read_returns_correct_lines(self):
      path = self.make_temp_file()
      write_lines(path, ['alpha', 'beta', 'gamma'])
      # assert read_lines returns the same list
      pass

  def test_empty_file(self):
      path = self.make_temp_file()
      write_lines(path, [])
      # assert read_lines returns []
      pass

if __name__ == '__main__':
  unittest.main()
Expected Output
...\n----------------------------------------------------------------------\nRan 3 tests in 0.005s\n\nOK
Hints

Hint 1: Use `tempfile.NamedTemporaryFile(delete=False)` to create a real temp file, then register cleanup with `self.addCleanup(os.unlink, path)`.

Hint 2: `addCleanup` is called after `tearDown` even if the test raises — more reliable than `try/finally`.

Hint 3: Test both the file write path and the file read path in separate test methods to keep them focused.

© 2026 EngineersOfAI. All rights reserved.