- 🔒 Subtypes must be substitutable for their base type
- 🐛 A subclass shouldn't break promises the parent makes
- 🚫 No surprises: no new exceptions, stricter args, weaker return guarantees
- 💡 Split hierarchies when the "is-a" relationship leaks
This presentation is designed for desktop or projector displays.
← Back to curriculumPython Object Oriented Programming
Course 9
← → to navigate
Where we are in the journey
Which SOLID principle does this code violate?
class Bird: def fly(self): ... class Penguin(Bird): def fly(self): raise NotImplementedError("Penguins can't fly")
A) Single Responsibility
B) Liskov Substitution
C) Interface Segregation
D) Dependency Inversion
Penguin breaks its parent's contract — code expecting Bird crashes on Penguin. Subtypes must be substitutable for their base type.
Recap: Lecture 07
class Bird: ... class FlyingBird(Bird): def fly(self): ... class Sparrow(FlyingBird): ... class Penguin(Bird): ...
What problem does the Singleton pattern solve?
A) Creating many objects fast
B) Guaranteeing exactly one instance with global access
C) Hiding implementation details
D) Adding behavior at runtime
Singleton ensures a class has only one instance and provides a global access point — used for shared resources like config, logging, and database connections.
Recap: Lecture 08
__new__ to control creationclass Config: _instance = None def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance
What does the else clause do in a try/except?
try: value = int(user_input) except ValueError: print("Not a number") else: print(f"Got {value}")
A) Runs when an exception is caught
B) Runs only if no exception was raised
C) Always runs, like finally
D) Runs only if try raises
The else block runs only when the try completed without raising. Use it for code that should run only on the "happy path".
Recap: Lecture 06
try — code that may raiseexcept — catches a specific exceptionelse — runs only when try succeededfinally — always runs, for cleanuptry: data = parse(raw) except ValueError as e: log.error(e) else: save(data) finally: cleanup()
unittest and pytestWhy Testing Matters
"Code without tests is broken by design." — Jacob Kaplan-Moss
🚨
Tests fire safety before there's a fire. Cheap sensor → early warning → saved home.
🏭
Every product checked before shipping. Defects caught on the line, not by customers.
🚗
Simulate disaster, fix the design. You don't fix the dummy — you fix the car.
Joseph Juran
1904 – 2008
Testing is woven through every phase — not a single step.
Software testing is the process of verifying and validating software to ensure it meets specified requirements.
"Are we building the product right?"
"Are we building the right product?"
Why Test? Six reasons
Testing pays for itself — many times over
🐛
Bugs caught during development are cheaper to fix — before they reach users.
💰
Bugs in production cost 10-100x more to fix than bugs during coding.
🔧
Change code without fear of breaking things. Green tests = freedom.
😄
Fewer crashes → less churn → more loyal customers.
🏆
Buggy software damages trust. Reliable software builds it.
🔒
Exposes vulnerabilities before attackers do.
The earlier you catch a bug, the cheaper it is.
Types of Testing
Functional vs non-functional — and the pyramid
What the system does — features, behavior, business logic.
"Can the user place an order?"
How the system does it — performance, security, usability.
"Can it handle 10,000 users at once?"
⚡
Speed and response time under normal load.
📊
Behavior under expected peak traffic.
🔥
Breaking points — what happens past the limit?
👥
Can users actually accomplish their goals?
🔒
Auth, injection, data leaks, attacks.
🌐
Browsers, OSes, devices, API versions.
✓
One function or class in isolation.
🔗
Multiple components working together.
✅
Meets business requirements & user stories.
↶
Old features still work after a change.
🎬
The whole system from user input to output.
Pyramid, not ice cream cone — most tests should be unit tests.
Unit Testing
The foundation of the pyramid
Arrange — Act — Assert. Three lines, three responsibilities.
def test_rectangle_area(): rect = Rectangle(10, 20) # Arrange area = rect.area() # Act assert area == 200 # Assert
Same idea, different vocabulary. BDD (Behavior-Driven Development) uses Given / When / Then — it maps 1:1 onto AAA.
📋 AAA (unit tests)
def test_withdraw_reduces_balance(): # Arrange account = Account(100) # Act account.withdraw(30) # Assert assert account.balance == 70
📝 Given / When / Then (BDD)
def test_withdraw_reduces_balance(): # Given an account with balance 100 account = Account(100) # When I withdraw 30 account.withdraw(30) # Then balance should be 70 assert account.balance == 70
The function (math_utils.py):
def add(x, y): return x + y
The test (test_math_utils.py):
import unittest from math_utils import add class TestAdd(unittest.TestCase): def test_add_positive(self): self.assertEqual(add(2, 3), 5) def test_add_zero(self): self.assertEqual(add(0, 0), 0)
Run it:
$ python -m unittest test_math_utils.py
..
----------------------------------------
Ran 2 tests in 0.001s
OK
The class under test:
class Rectangle: def __init__(self, width, height): self.width = width self.height = height def area(self): return self.width * self.height
The test:
class TestRectangle(unittest.TestCase): def test_area(self): rect = Rectangle(10, 20) self.assertEqual(rect.area(), 200) def test_zero_dimensions(self): rect = Rectangle(0, 5) self.assertEqual(rect.area(), 0)
Output:
.. Ran 2 tests in 0.001s OK
The function that raises:
def divide(x, y): if y == 0: raise ValueError("Cannot divide by zero") return x / y
Asserting the exception:
class TestDivide(unittest.TestCase): def test_divide_by_zero(self): with self.assertRaises(ValueError): divide(10, 0) def test_divide_normal(self): self.assertEqual(divide(10, 2), 5)
Output:
.. Ran 2 tests in 0.001s OK
pytest vs unittest
Two ways to write tests in Python
unittest.TestCaseassertEqual, assertTrue, assertRaises...pip install pytestassertunittest:
import unittest from rectangle import Rectangle class TestRectangle(unittest.TestCase): def test_area(self): rect = Rectangle(10, 20) self.assertEqual(rect.area(), 200)
pytest:
from rectangle import Rectangle def test_area(): rect = Rectangle(10, 20) assert rect.area() == 200
✅ Same behavior, half the code.
The function:
def is_prime(n): if n < 2: return False for i in range(2, int(n ** 0.5) + 1): if n % i == 0: return False return True
pytest test functions:
def test_primes(): assert is_prime(2) assert is_prime(13) def test_non_primes(): assert not is_prime(4) assert not is_prime(9) def test_negatives(): assert not is_prime(-5)
Output:
===== 3 passed in 0.02s =====
Without parametrize — repetitive:
def test_is_prime_2(): assert is_prime(2) == True def test_is_prime_4(): assert is_prime(4) == False def test_is_prime_13(): assert is_prime(13) == True
With parametrize — DRY:
import pytest @pytest.mark.parametrize("n,expected", [ (2, True), (4, False), (13, True), ]) def test_is_prime(n, expected): assert is_prime(n) == expected
Output:
test_is_prime[2-True] PASSED test_is_prime[4-False] PASSED test_is_prime[13-True] PASSED ===== 3 passed in 0.02s =====
The problem — duplicated setup:
def test_area(): rect = Rectangle(10, 20) assert rect.area() == 200 def test_perimeter(): rect = Rectangle(10, 20) assert rect.perimeter() == 60
Fixture extracts setup:
import pytest @pytest.fixture def rect(): return Rectangle(10, 20) def test_area(rect): assert rect.area() == 200 def test_perimeter(rect): assert rect.perimeter() == 60
Why fixtures?
function / class / module / sessionyield for teardown after the test$ pytest tests/ tests/test_math.py .F =============== FAILURES =============== _______ test_divide_positive _______ def test_divide_positive(): > assert divide(1, 2) == 0.6 E assert 0.5 == 0.6 E + where 0.5 = divide(1, 2) tests/test_math.py:12: AssertionError ========== 1 failed, 1 passed in 0.02s ==========
pytest tells you exactly what failed and why.
Mocking
"Don't test the world. Test your code."
Test one thing, not the whole ecosystem.
Simulate errors, timeouts, edge cases on demand.
No real DB or network calls — milliseconds, not seconds.
No flakiness from external systems.
The function under test (user_service.py):
def get_user_by_id(db, user_id): user = db.get_user(user_id) if user is None: raise ValueError(f"User {user_id} not found") return user
Test with mocker:
def test_get_user_success(mocker): fake_db = mocker.Mock() fake_db.get_user.return_value = {"id": 1, "name": "Alice"} user = get_user_by_id(fake_db, 1) assert user["name"] == "Alice" fake_db.get_user.assert_called_once_with(1)
Test the error path:
def test_get_user_not_found(mocker): fake_db = mocker.Mock() fake_db.get_user.return_value = None with pytest.raises(ValueError): get_user_by_id(fake_db, 99)
test_divide_raises_on_zero, not test_1None, negatives, max valuesTest-Driven Development
Red. Green. Refactor.
TDD is a development practice where you write tests before the code they test.
Three steps. Endless cycles.
Write a failing test first.
Make the failing test pass — fast and ugly is fine.
Improve the code without changing behavior.
🎬 Live Coding
Let's build a Car class
Three requirements — three full TDD cycles
Requirements
ValueError
🔴 RED → 🟢 GREEN → 🔵 REFACTOR — one requirement at a time
🔴 RED — failing tests first:
def test_car_starts_at_zero(): assert Car().speed == 0 def test_negative_raises(): with pytest.raises(ValueError): Car().set_speed(-10)
🟢 GREEN — minimum code:
class Car: def __init__(self): self.speed = 0 def set_speed(self, value): if value < 0: raise ValueError("negative") self.speed = value
🔵 REFACTOR — use a property:
@property def speed(self) -> int: return self._speed @speed.setter def speed(self, value: int) -> None: if value < 0: raise ValueError("Speed cannot be negative") self._speed = value
✅ Pros
❌ Cons
🤖 AI & Testing
Copilot, Claude, Cursor — should you let them write your tests?
✅ Great use cases
⚠️ Where AI struggles
✅ Pros
❌ Cons
Design Patterns (Part 2)
Strategy, Decorator, Factory Method, Facade — and more!