Lesson 9: Software Testing
Learning Objectives
By the end of this lesson, you will be able to:
- Write effective unit tests that verify individual components in isolation using appropriate testing frameworks and assertion techniques
- Design and implement integration tests that verify how multiple components work together in realistic scenarios
- Apply test-driven development (TDD) by writing tests before implementation to drive design and ensure comprehensive test coverage
- Structure test suites following the test pyramid to balance speed, reliability, and coverage across different testing levels
- Use testing best practices including test doubles, clear assertions, and meaningful test names to create maintainable test suites
Introduction
Software testing is not an afterthought—it’s a fundamental engineering practice that distinguishes professional development from amateur coding. Testing provides confidence that code works correctly, catches bugs before they reach production, and serves as living documentation of how code should behave. Well-tested code is easier to refactor, easier to understand, and dramatically reduces the cost of fixing bugs [1].
The statistics are compelling: bugs found in production cost 10-100 times more to fix than bugs caught during development. Companies that invest in comprehensive testing ship higher-quality software, respond to changing requirements more quickly, and spend less time firefighting production issues [2]. Testing isn’t about perfection—it’s about building confidence that your software does what it’s supposed to do.
Modern software testing encompasses multiple levels, each serving different purposes. Unit tests verify individual functions and classes in isolation. Integration tests check that components work together correctly. End-to-end tests validate complete user workflows. Each level trades off speed for realism, and successful testing strategies use all levels appropriately [3].
Test-driven development (TDD) takes testing further by writing tests before implementation. This practice might seem backwards, but it produces better-designed code, comprehensive test coverage, and clearer requirements. Whether you practice strict TDD or write tests alongside code, understanding testing principles transforms how you approach software development [4].
Core Content
Unit Testing: Verifying Components in Isolation
Unit tests are the foundation of any testing strategy. They verify that individual units of code—typically functions, methods, or classes—work correctly in isolation from dependencies [1].
Characteristics of Good Unit Tests:
- Fast: Run in milliseconds, enabling frequent execution
- Isolated: Test one thing at a time without dependencies on other units
- Repeatable: Produce same results every time
- Self-validating: Pass or fail clearly without manual interpretation
- Timely: Written at or near the time of implementation
Basic Unit Test Structure:
Most unit tests follow the Arrange-Act-Assert (AAA) pattern:
import pytest
from calculator import Calculator
def test_addition():
# Arrange: Set up test data and dependencies
calc = Calculator()
a = 5
b = 3
# Act: Execute the code being tested
result = calc.add(a, b)
# Assert: Verify the result
assert result == 8
def test_division_by_zero():
# Arrange
calc = Calculator()
# Act & Assert: Verify exception is raised
with pytest.raises(ZeroDivisionError):
calc.divide(10, 0)
def test_multiply_negative_numbers():
# Arrange
calc = Calculator()
# Act
result = calc.multiply(-3, -4)
# Assert
assert result == 12Testing Different Scenarios:
Comprehensive unit tests cover normal cases, edge cases, and error conditions:
from user_validator import UserValidator
class TestUserValidator:
"""Test suite for UserValidator using pytest"""
def setup_method(self):
"""Set up test fixtures before each test"""
self.validator = UserValidator()
def test_valid_email(self):
"""Valid email passes validation"""
assert self.validator.validate_email("[email protected]") is True
def test_email_without_at_symbol(self):
"""Email without @ fails validation"""
assert self.validator.validate_email("userexample.com") is False
def test_email_without_domain(self):
"""Email without domain fails validation"""
assert self.validator.validate_email("user@") is False
def test_empty_email(self):
"""Empty email fails validation"""
assert self.validator.validate_email("") is False
def test_none_email(self):
"""None email fails validation"""
assert self.validator.validate_email(None) is False
def test_email_with_special_characters(self):
"""Email with valid special characters passes"""
assert self.validator.validate_email("[email protected]") is True
def test_password_minimum_length(self):
"""Password must be at least 8 characters"""
assert self.validator.validate_password("short") is False
assert self.validator.validate_password("longenough") is True
def test_password_requires_uppercase(self):
"""Password must contain uppercase letter"""
assert self.validator.validate_password("alllowercase123") is False
assert self.validator.validate_password("HasUppercase123") is True
def test_password_requires_number(self):
"""Password must contain number"""
assert self.validator.validate_password("NoNumbersHere") is False
assert self.validator.validate_password("HasNumber123") is TrueParameterized Tests:
When testing multiple inputs, parameterized tests reduce duplication:
import pytest
@pytest.mark.parametrize("input,expected", [
("[email protected]", True),
("[email protected]", True),
("[email protected]", True),
("userexample.com", False),
("user@", False),
("@example.com", False),
("", False),
(None, False),
])
def test_email_validation(input, expected):
"""Test email validation with multiple inputs"""
validator = UserValidator()
assert validator.validate_email(input) == expected
@pytest.mark.parametrize("password,should_pass", [
("ValidPass123", True),
("AnotherGood1", True),
("short", False), # Too short
("nouppercase123", False), # No uppercase
("NoNumbers", False), # No numbers
("", False), # Empty
])
def test_password_validation(password, should_pass):
"""Test password validation with multiple inputs"""
validator = UserValidator()
assert validator.validate_password(password) == should_passTesting with Fixtures:
Fixtures provide reusable test data and setup:
import pytest
from database import Database
from user_repository import UserRepository
@pytest.fixture
def database():
"""Create test database"""
db = Database(":memory:") # In-memory SQLite
db.create_tables()
yield db
db.close()
@pytest.fixture
def user_repository(database):
"""Create repository with test database"""
return UserRepository(database)
@pytest.fixture
def sample_user():
"""Provide sample user data"""
return {
"username": "testuser",
"email": "[email protected]",
"age": 25
}
def test_create_user(user_repository, sample_user):
"""Test user creation"""
user_id = user_repository.create(sample_user)
assert user_id is not None
# Verify user was saved
user = user_repository.find_by_id(user_id)
assert user["username"] == sample_user["username"]
assert user["email"] == sample_user["email"]
def test_find_nonexistent_user(user_repository):
"""Test finding user that doesn't exist"""
user = user_repository.find_by_id(99999)
assert user is NoneMocking and Test Doubles
Unit tests should be isolated from external dependencies like databases, APIs, or file systems. Test doubles replace real dependencies with controlled versions [5].
Types of Test Doubles:
- Stub: Provides predefined responses
- Mock: Records interactions and can verify they happened correctly
- Fake: Has working implementation but uses shortcuts (in-memory database)
- Spy: Wraps real object to record interactions
Mocking External Dependencies:
from unittest.mock import Mock, patch, MagicMock
import requests
from weather_service import WeatherService
def test_get_temperature_success():
"""Test successful API call"""
# Create mock response
mock_response = Mock()
mock_response.json.return_value = {
"temperature": 72,
"condition": "sunny"
}
mock_response.status_code = 200
# Patch requests.get to return mock
with patch('requests.get', return_value=mock_response) as mock_get:
service = WeatherService()
temp = service.get_temperature("New York")
# Verify result
assert temp == 72
# Verify API was called correctly
mock_get.assert_called_once_with(
"https://api.weather.com/v1/forecast",
params={"city": "New York"}
)
def test_get_temperature_api_failure():
"""Test handling of API failure"""
# Mock failed response
mock_response = Mock()
mock_response.status_code = 500
with patch('requests.get', return_value=mock_response):
service = WeatherService()
temp = service.get_temperature("New York")
# Should return None on failure
assert temp is None
def test_get_temperature_timeout():
"""Test handling of timeout"""
# Mock timeout exception
with patch('requests.get', side_effect=requests.Timeout):
service = WeatherService()
temp = service.get_temperature("New York")
assert temp is NoneMocking Database Operations:
from unittest.mock import Mock
from user_service import UserService
def test_get_user_by_email():
"""Test retrieving user by email"""
# Create mock repository
mock_repo = Mock()
mock_repo.find_by_email.return_value = {
"id": 1,
"email": "[email protected]",
"username": "testuser"
}
# Create service with mocked dependency
service = UserService(mock_repo)
user = service.get_user_by_email("[email protected]")
# Verify result
assert user is not None
assert user["email"] == "[email protected]"
# Verify mock was called correctly
mock_repo.find_by_email.assert_called_once_with("[email protected]")
def test_create_user_duplicate_email():
"""Test that duplicate email raises error"""
# Mock repository that finds existing user
mock_repo = Mock()
mock_repo.find_by_email.return_value = {
"id": 1,
"email": "[email protected]"
}
service = UserService(mock_repo)
with pytest.raises(ValueError, match="Email already exists"):
service.create_user({
"email": "[email protected]",
"username": "newuser"
})
# Verify create was not called
mock_repo.create.assert_not_called()Integration Testing: Verifying Component Interactions
Integration tests verify that multiple components work together correctly. They test interfaces between modules, database interactions, and external service integrations [3].
Integration Test Characteristics:
- Realistic: Use real databases, file systems, or external services
- Slower: Take seconds to minutes, not milliseconds
- Fewer: Less numerous than unit tests
- Broader: Test multiple components together
Database Integration Tests:
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Base, User, Order
from repositories import UserRepository, OrderRepository
@pytest.fixture(scope="function")
def test_db():
"""Create test database for each test"""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
def test_user_order_relationship(test_db):
"""Test relationship between users and orders"""
# Create user
user_repo = UserRepository(test_db)
user = user_repo.create({
"username": "john_doe",
"email": "[email protected]"
})
# Create orders for user
order_repo = OrderRepository(test_db)
order1 = order_repo.create({
"user_id": user.id,
"total": 99.99,
"status": "pending"
})
order2 = order_repo.create({
"user_id": user.id,
"total": 149.99,
"status": "completed"
})
# Verify relationship
user_orders = order_repo.find_by_user_id(user.id)
assert len(user_orders) == 2
assert set(o.id for o in user_orders) == {order1.id, order2.id}
# Verify user can access orders through relationship
retrieved_user = user_repo.find_by_id(user.id)
assert len(retrieved_user.orders) == 2
def test_transaction_rollback(test_db):
"""Test that failed transactions roll back correctly"""
user_repo = UserRepository(test_db)
# Create user
user = user_repo.create({
"username": "test_user",
"email": "[email protected]"
})
# Attempt operation that should fail
try:
with test_db.begin_nested():
user_repo.update(user.id, {"email": "invalid"})
# Simulate constraint violation
raise ValueError("Simulated error")
except ValueError:
test_db.rollback()
# Verify user unchanged
retrieved = user_repo.find_by_id(user.id)
assert retrieved.email == "[email protected]"API Integration Tests:
import pytest
from fastapi.testclient import TestClient
from main import app
from database import get_db, Base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture(scope="function")
def test_client():
"""Create test client with test database"""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
TestingSessionLocal = sessionmaker(bind=engine)
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
yield client
app.dependency_overrides.clear()
def test_create_and_retrieve_user(test_client):
"""Test full user creation and retrieval workflow"""
# Create user via API
response = test_client.post("/api/users", json={
"username": "john_doe",
"email": "[email protected]",
"password": "SecurePass123"
})
assert response.status_code == 201
data = response.json()
assert "id" in data
user_id = data["id"]
# Retrieve user via API
response = test_client.get(f"/api/users/{user_id}")
assert response.status_code == 200
data = response.json()
assert data["username"] == "john_doe"
assert data["email"] == "[email protected]"
assert "password" not in data # Password shouldn't be returned
def test_create_duplicate_user_fails(test_client):
"""Test that duplicate emails are rejected"""
user_data = {
"username": "user1",
"email": "[email protected]",
"password": "Pass123"
}
# Create first user
response = test_client.post("/api/users", json=user_data)
assert response.status_code == 201
# Attempt to create duplicate
user_data["username"] = "user2" # Different username, same email
response = test_client.post("/api/users", json=user_data)
assert response.status_code == 400
assert "email already exists" in response.json()["detail"].lower()
def test_authentication_flow(test_client):
"""Test complete authentication workflow"""
# Register user
test_client.post("/api/users", json={
"username": "auth_user",
"email": "[email protected]",
"password": "SecurePass123"
})
# Login
response = test_client.post("/api/auth/login", json={
"email": "[email protected]",
"password": "SecurePass123"
})
assert response.status_code == 200
token = response.json()["access_token"]
# Access protected endpoint
response = test_client.get(
"/api/users/me",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
assert response.json()["email"] == "[email protected]"
# Try without token
response = test_client.get("/api/users/me")
assert response.status_code == 401Test-Driven Development (TDD)
TDD is a development methodology where you write tests before writing implementation code. The cycle is: write a failing test, write minimal code to pass it, refactor for quality [4].
The TDD Cycle (Red-Green-Refactor):
- Red: Write a failing test for the next bit of functionality
- Green: Write minimal code to make the test pass
- Refactor: Improve code quality while keeping tests green
TDD Example: Building a Shopping Cart
Step 1: Write First Test (Red)
import pytest
from shopping_cart import ShoppingCart
def test_new_cart_is_empty():
"""A new shopping cart should be empty"""
cart = ShoppingCart()
assert cart.item_count() == 0
assert cart.total() == 0Run test: FAILS (ShoppingCart doesn’t exist)
Step 2: Write Minimal Implementation (Green)
# shopping_cart.py
class ShoppingCart:
def __init__(self):
self.items = []
def item_count(self):
return 0
def total(self):
return 0Run test: PASSES
Step 3: Write Next Test (Red)
def test_add_item_increases_count():
"""Adding an item should increase item count"""
cart = ShoppingCart()
cart.add_item("Apple", 0.99, quantity=1)
assert cart.item_count() == 1Run test: FAILS (add_item doesn’t exist)
Step 4: Implement Feature (Green)
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, name, price, quantity=1):
self.items.append({
"name": name,
"price": price,
"quantity": quantity
})
def item_count(self):
return len(self.items)
def total(self):
return sum(item["price"] * item["quantity"] for item in self.items)Run tests: ALL PASS
Step 5: Continue with More Tests
def test_add_multiple_items():
"""Should handle multiple items"""
cart = ShoppingCart()
cart.add_item("Apple", 0.99, quantity=2)
cart.add_item("Banana", 0.59, quantity=3)
assert cart.item_count() == 2
assert cart.total() == pytest.approx(3.75)
def test_remove_item():
"""Should remove items from cart"""
cart = ShoppingCart()
cart.add_item("Apple", 0.99)
cart.add_item("Banana", 0.59)
cart.remove_item("Apple")
assert cart.item_count() == 1
assert cart.total() == pytest.approx(0.59)
def test_apply_discount():
"""Should apply percentage discount"""
cart = ShoppingCart()
cart.add_item("Item", 100.0)
cart.apply_discount(10) # 10% discount
assert cart.total() == pytest.approx(90.0)Benefits of TDD:
- Better design: Writing tests first forces you to think about interfaces
- Comprehensive coverage: Every piece of functionality has tests
- Confidence: Refactoring is safe because tests verify behavior
- Documentation: Tests show how code is meant to be used
- Faster debugging: Tests pinpoint exactly what broke
The Test Pyramid
The test pyramid is a strategy for balancing different types of tests. It suggests having many unit tests, fewer integration tests, and even fewer end-to-end tests [3].
/\
/ \
/ E2E \ Few: Slow, brittle, expensive
/--------\
/ Integr \ Some: Moderate speed and cost
/------------\
/ Unit \ Many: Fast, stable, cheap
/________________\Test Pyramid Guidelines:
Unit Tests (70%):
- Fast (milliseconds per test)
- Isolated (mock dependencies)
- Numerous (hundreds to thousands)
- Test single units of code
Integration Tests (20%):
- Moderate speed (seconds per test)
- Test component interactions
- Use real databases, services
- Fewer than unit tests
End-to-End Tests (10%):
- Slow (minutes per test)
- Test complete workflows
- Use real system
- Fewest in number
Why This Shape?
- Speed: Fast tests run frequently during development
- Reliability: Lower-level tests are more stable
- Cost: Unit tests are cheapest to write and maintain
- Debugging: Failures in unit tests pinpoint problems quickly
Anti-Pattern: The Ice Cream Cone
________
/ \
/ Manual \ Many: Slow, expensive, manual
/------------\
/ E2E \ Many: Slow, brittle
/----------------\
/ Integration \ Some
/--------------------\
/ Unit \ Few: Missing foundationThis inverted pyramid has too many high-level tests and few unit tests. It’s slow, expensive, and hard to maintain.
Common Pitfalls
Pitfall 1: Testing Implementation Instead of Behavior
Testing internal implementation details makes tests fragile—they break when you refactor even though behavior hasn’t changed.
# Bad - testing implementation
def test_user_repository_uses_correct_sql():
repo = UserRepository(db)
repo.find_by_email("[email protected]")
assert "SELECT * FROM users WHERE email = ?" in repo.last_query
# Good - testing behavior
def test_user_repository_finds_user_by_email():
repo = UserRepository(db)
user = repo.create({"email": "[email protected]", "name": "Test"})
found = repo.find_by_email("[email protected]")
assert found.id == user.idBest Practice: Test observable behavior and outcomes, not internal implementation. This allows refactoring without breaking tests [1].
Pitfall 2: Insufficient Test Coverage
Testing only happy paths misses edge cases and error conditions where bugs often hide.
Best Practice: Test normal cases, boundary conditions, edge cases, and error paths. Use code coverage tools to identify untested code, but remember 100% coverage doesn’t guarantee bug-free code [2].
Pitfall 3: Slow Test Suites
Test suites that take minutes to run discourage frequent testing and slow development.
Best Practice: Follow the test pyramid—most tests should be fast unit tests. Use parallel test execution. Run relevant tests during development, full suite before pushing. Keep integration tests focused [3].
Pitfall 4: Overly Complex Tests
Tests with complex setup, multiple assertions, or convoluted logic are hard to understand and maintain.
Best Practice: Each test should verify one behavior. Use fixtures for complex setup. If a test is hard to write, the code under test might be poorly designed [4].
Pitfall 5: Ignoring Failing Tests
Leaving broken tests in the suite (especially with “skip” markers) erodes test suite value.
Best Practice: All tests should pass always. Fix or remove broken tests immediately. Flaky tests should be fixed or removed—they provide no value and waste time [5].
Summary
Software testing is essential for building reliable, maintainable software. Comprehensive testing catches bugs early, enables confident refactoring, and serves as living documentation of system behavior.
Unit tests verify individual components in isolation. They should be fast, focused, and follow the Arrange-Act-Assert pattern. Write unit tests for normal cases, edge cases, and error conditions. Use parameterized tests to reduce duplication and fixtures for reusable test setup.
Test doubles (mocks, stubs, fakes) isolate units from dependencies. Mock external services, databases, and file systems to keep unit tests fast and reliable. Verify not just return values but also that dependencies were called correctly.
Integration tests verify that components work together correctly. They use real databases and services, run slower than unit tests, and test realistic workflows. Write fewer integration tests than unit tests, focusing on critical interactions and data flows.
Test-driven development writes tests before implementation in red-green-refactor cycles. TDD produces better-designed code, comprehensive test coverage, and clearer requirements. Even if not practicing strict TDD, understanding the cycle improves testing practices.
The test pyramid balances test types: many fast unit tests form the foundation, some integration tests verify interactions, and few end-to-end tests validate complete workflows. This shape optimizes for speed, reliability, and maintainability.
Testing best practices include testing behavior not implementation, achieving meaningful coverage beyond percentage metrics, keeping tests simple and focused, maintaining fast test suites, and ensuring all tests pass always. Good tests are as important as good code—invest time in making them clear and maintainable.
Testing transforms software development from hoping code works to knowing it works. The confidence that comes from comprehensive testing enables rapid iteration, safe refactoring, and successful collaboration. Professional developers don’t ask whether to write tests—they ask which tests provide the most value.
Practice Quiz
Question 1: Your test suite has 200 unit tests and 50 integration tests. The entire suite takes 10 minutes to run. Developers complain that waiting for tests slows them down. What’s wrong with this setup, and how would you fix it?
Answer: The test suite is too slow, suggesting the tests aren’t properly categorized or aren’t following the test pyramid. If tests are truly unit tests (isolated, mocked dependencies), 200 should run in seconds, not minutes.
Problems:
- “Unit tests” might actually be integration tests (hitting database, external services)
- Tests might not be isolated (depending on each other, shared state)
- Missing parallelization for integration tests
Solutions:
# 1. Separate fast and slow tests
# pytest.ini
[pytest]
markers =
unit: Fast isolated tests
integration: Slower tests with dependencies
# Mark tests appropriately
@pytest.mark.unit
def test_calculate_discount():
# Pure function, no dependencies
pass
@pytest.mark.integration
def test_save_to_database():
# Hits real database
pass
# Run only unit tests during development
# $ pytest -m unit # Runs in seconds
# Run all tests before commit
# $ pytest # Full suite- Fix slow unit tests by mocking dependencies
- Run integration tests in parallel:
pytest -n auto - Run relevant tests first: Use pytest’s
--lf(last failed) and--ff(failed first) flags
Target: Unit tests in seconds, integration tests in under a minute [3].
Question 2: You’re writing tests for a function that calls an external payment API. How should you structure your tests to cover both the code’s logic and the actual API integration?
Answer: Write both unit tests (with mocked API) and integration tests (with real API), serving different purposes:
Unit Tests (Most tests):
from unittest.mock import Mock, patch
import pytest
@patch('payment_service.stripe_api.charge')
def test_process_payment_success(mock_charge):
"""Test payment processing logic without hitting API"""
# Mock successful API response
mock_charge.return_value = {
"id": "ch_123",
"status": "succeeded"
}
service = PaymentService()
result = service.process_payment(100.0, "tok_visa")
assert result["success"] is True
assert result["transaction_id"] == "ch_123"
mock_charge.assert_called_once()
@patch('payment_service.stripe_api.charge')
def test_process_payment_card_declined(mock_charge):
"""Test handling of declined card"""
mock_charge.side_effect = CardDeclinedError("Insufficient funds")
service = PaymentService()
result = service.process_payment(100.0, "tok_visa")
assert result["success"] is False
assert "declined" in result["error"].lower()
@patch('payment_service.stripe_api.charge')
def test_process_payment_network_error(mock_charge):
"""Test handling of network issues"""
mock_charge.side_effect = NetworkError("Connection timeout")
service = PaymentService()
result = service.process_payment(100.0, "tok_visa")
assert result["success"] is False
# Should implement retry logicIntegration Tests (Fewer tests, real API in test mode):
@pytest.mark.integration
def test_real_payment_flow():
"""Test with real Stripe API in test mode"""
service = PaymentService(api_key=TEST_API_KEY)
# Use Stripe test card
result = service.process_payment(100.0, "tok_visa")
assert result["success"] is True
assert "transaction_id" in resultUnit tests verify logic, error handling, and edge cases (fast, many tests). Integration tests verify the integration actually works (slow, few tests, use test mode of real API) [5].
Question 3: A developer writes this test. What’s wrong with it, and how should it be fixed?
def test_user_service():
service = UserService()
user = service.create_user("john", "[email protected]")
assert user.id == 1
retrieved = service.get_user(1)
assert retrieved.username == "john"
service.update_user(1, username="john_updated")
updated = service.get_user(1)
assert updated.username == "john_updated"
service.delete_user(1)
deleted = service.get_user(1)
assert deleted is NoneAnswer: Multiple violations of testing best practices:
Problems:
- Tests multiple behaviors in one test (create, get, update, delete)
- Tests depend on order of operations
- Unclear what’s being tested (name doesn’t describe behavior)
- Hard to debug (which assertion failed?)
- Assumes database state (user.id == 1)
Fixed version:
class TestUserService:
def setup_method(self):
"""Reset database before each test"""
self.service = UserService()
self.service.clear_all() # Start with clean slate
def test_create_user_returns_user_with_id(self):
"""Creating a user should return user with assigned ID"""
user = self.service.create_user("john", "[email protected]")
assert user.id is not None
assert isinstance(user.id, int)
def test_get_user_returns_created_user(self):
"""Should retrieve user by ID"""
created = self.service.create_user("john", "[email protected]")
retrieved = self.service.get_user(created.id)
assert retrieved is not None
assert retrieved.username == "john"
assert retrieved.id == created.id
def test_update_user_changes_username(self):
"""Updating user should change username"""
user = self.service.create_user("john", "[email protected]")
self.service.update_user(user.id, username="john_updated")
updated = self.service.get_user(user.id)
assert updated.username == "john_updated"
def test_delete_user_removes_user(self):
"""Deleting user should make it unretrievable"""
user = self.service.create_user("john", "[email protected]")
self.service.delete_user(user.id)
deleted = self.service.get_user(user.id)
assert deleted is None
def test_get_nonexistent_user_returns_none(self):
"""Getting user that doesn't exist should return None"""
result = self.service.get_user(99999)
assert result is NoneEach test verifies one behavior, tests are independent, names describe what’s tested, and failures pinpoint exactly what broke [1][4].
Question 4: Your team debates whether to aim for 100% code coverage. What should you recommend and why?
Answer: Don’t aim for 100% coverage as a goal. Coverage is a useful metric but shouldn’t be the target.
Reasoning:
- Coverage measures what’s executed, not what’s tested:
def divide(a, b):
return a / b # 100% coverage but doesn't test division by zero
def test_divide():
assert divide(10, 2) == 5 # Test passes, coverage is 100%- High coverage doesn’t guarantee quality:
def test_process_payment():
process_payment(100) # No assertions! Still counts as coverage- Diminishing returns: Getting from 80% to 100% often means testing trivial code (getters, setters, constructors) that provides little value
Recommendation:
# Aim for meaningful coverage targets:
# - 80-90% overall coverage (good balance)
# - 100% for critical business logic
# - Lower for trivial code (getters, configuration)
# - Focus on behavior coverage, not line coverage
# Use coverage to find gaps:
# $ pytest --cov=src --cov-report=html
# Review HTML report to find:
# - Untested error handling
# - Untested edge cases
# - Complex logic without tests
# NOT to chase arbitrary percentage targetsBetter metrics:
- Do all critical paths have tests?
- Are edge cases and errors tested?
- Do tests catch real bugs?
- Can we refactor confidently?
Coverage is a tool for finding what’s not tested, not a measure of test quality [2].
Question 5: You’re implementing TDD for a new feature. What’s the correct sequence of steps, and why is this order important?
Answer: The TDD cycle is Red-Green-Refactor, and the order is crucial:
Correct sequence:
- Red: Write a failing test
def test_calculate_shipping_for_domestic():
"""Test domestic shipping calculation"""
calculator = ShippingCalculator()
cost = calculator.calculate(
weight=5.0,
destination="US",
origin="US"
)
assert cost == 10.0 # $2 per pound for domesticRun: FAILS (code doesn’t exist)
- Green: Write minimal code to pass
class ShippingCalculator:
def calculate(self, weight, destination, origin):
if destination == origin == "US":
return weight * 2.0
return 0 # Placeholder for internationalRun: PASSES
- Refactor: Improve code quality
class ShippingCalculator:
DOMESTIC_RATE = 2.0
def calculate(self, weight, destination, origin):
if self._is_domestic(destination, origin):
return weight * self.DOMESTIC_RATE
return 0
def _is_domestic(self, destination, origin):
return destination == origin == "US"Run: STILL PASSES
- Repeat: Add next test
def test_calculate_shipping_for_international():
"""Test international shipping calculation"""
calculator = ShippingCalculator()
cost = calculator.calculate(
weight=5.0,
destination="UK",
origin="US"
)
assert cost == 25.0 # $5 per pound for internationalWhy this order matters:
- Red first ensures test can fail - confirms test actually tests something
- Minimal implementation prevents over-engineering
- Refactor only after tests pass - ensures refactoring doesn’t break functionality
- Iterative approach builds feature incrementally with continuous validation
Wrong approach:
- Writing all tests first (can’t verify they fail)
- Writing implementation before tests (might not be testable)
- Skipping refactor (accumulates technical debt) [4]
References
[1] Meszaros, G. (2007). xUnit Test Patterns: Refactoring Test Code. Addison-Wesley. URL: http://xunitpatterns.com/, Quote: “Unit tests should be fast, isolated, repeatable, self-validating, and timely. Good unit tests follow the Arrange-Act-Assert pattern and test one behavior at a time. Tests are as important as production code and deserve the same level of care in design and maintenance.”
[2] Humble, J., & Farley, D. (2010). Continuous Delivery: Reliable Software Releases through Build, Test, and Deployment Automation. Addison-Wesley. URL: https://www.oreilly.com/library/view/continuous-delivery-reliable/9780321670250/, Quote: “Automated testing is the foundation of continuous delivery. Tests provide confidence that software works correctly and that changes don’t break existing functionality. The cost of fixing bugs increases exponentially the later they’re found—bugs found in production cost 10-100 times more to fix than bugs caught during development.”
[3] Cohn, M. (2009). Succeeding with Agile: Software Development Using Scrum. Addison-Wesley. URL: https://www.mountaingoatsoftware.com/books/succeeding-with-agile-software-development-using-scrum, Quote: “The test pyramid suggests having many unit tests at the base, fewer integration tests in the middle, and even fewer end-to-end tests at the top. This shape optimizes for speed and reliability—unit tests are fast and pinpoint failures, while higher-level tests provide confidence that the system works as a whole.”
[4] Beck, K. (2002). Test Driven Development: By Example. Addison-Wesley. URL: https://www.oreilly.com/library/view/test-driven-development/0321146530/, Quote: “Test-driven development follows the red-green-refactor cycle: write a failing test (red), write minimal code to pass it (green), then improve code quality while keeping tests passing (refactor). This discipline produces better-designed code, comprehensive test coverage, and greater confidence in making changes.”
[5] Freeman, S., & Pryce, N. (2009). Growing Object-Oriented Software, Guided by Tests. Addison-Wesley. URL: http://www.growing-object-oriented-software.com/, Quote: “Mocking and test doubles enable true unit testing by isolating the code under test from its dependencies. Use mocks to verify interactions, stubs to provide predefined responses, and fakes for lightweight implementations. Testing behavior rather than implementation details creates tests that support refactoring rather than resist it.”