Bài 13: Testing - Fixtures, Mocking & TDD
Mục Tiêu Bài Học
Sau khi hoàn thành bài này, bạn sẽ:
- ✅ Sử dụng pytest fixtures
- ✅ Mock external dependencies
- ✅ Đo test coverage
- ✅ Hiểu TDD (Test-Driven Development)
- ✅ Write testable code
- ✅ Advanced testing patterns
pytest Fixtures
Fixtures provide reusable setup code for tests.
Basic Fixtures
# conftest.py (pytest discovers automatically)import pytest @pytest.fixturedef sample_list(): """Fixture providing sample list.""" return [1, 2, 3, 4, 5] @pytest.fixturedef sample_user(): """Fixture providing sample user.""" return { 'id': 1, 'username': 'alice', 'email': '[email protected]' } # test_fixtures.pydef test_sum_with_fixture(sample_list): """Test using fixture.""" assert sum(sample_list) == 15 def test_user_fixture(sample_user): """Test with user fixture.""" assert sample_user['username'] == 'alice' assert '@' in sample_user['email'] # Multiple fixturesdef test_multiple_fixtures(sample_list, sample_user): """Test with multiple fixtures.""" assert len(sample_list) == 5 assert sample_user['id'] == 1
Fixture Scopes
import pytest # Function scope (default) - run for each test@pytest.fixture(scope="function")def func_fixture(): print("\nSetup function fixture") yield "function" print("\nTeardown function fixture") # Class scope - run once per class@pytest.fixture(scope="class")def class_fixture(): print("\nSetup class fixture") yield "class" print("\nTeardown class fixture") # Module scope - run once per module@pytest.fixture(scope="module")def module_fixture(): print("\nSetup module fixture") yield "module" print("\nTeardown module fixture") # Session scope - run once per session@pytest.fixture(scope="session")def session_fixture(): print("\nSetup session fixture") yield "session" print("\nTeardown session fixture") # Usagedef test_one(func_fixture): assert func_fixture == "function" def test_two(func_fixture): assert func_fixture == "function"
Fixtures with Setup/Teardown
import pytestimport tempfileimport os @pytest.fixturedef temp_file(): """Create temporary file.""" # Setup fd, path = tempfile.mkstemp() # Provide to test yield path # Teardown os.close(fd) os.remove(path) def test_with_temp_file(temp_file): """Test using temporary file.""" with open(temp_file, 'w') as f: f.write("test data") with open(temp_file, 'r') as f: content = f.read() assert content == "test data" # Database fixture example@pytest.fixturedef db_connection(): """Database connection fixture.""" # Setup conn = {"status": "connected"} print("\nDatabase connected") yield conn # Teardown print("\nDatabase disconnected") conn["status"] = "disconnected" def test_db_operation(db_connection): """Test database operation.""" assert db_connection["status"] == "connected"
Parametrized Fixtures
import pytest @pytest.fixture(params=[1, 2, 3])def number(request): """Parametrized fixture.""" return request.param def test_square(number): """Test runs 3 times with different numbers.""" result = number ** 2 assert result == number * number # More complex parametrization@pytest.fixture(params=[ {"name": "alice", "age": 25}, {"name": "bob", "age": 30},])def user_data(request): """Parametrized user data.""" return request.param def test_user_data(user_data): """Test with different user data.""" assert isinstance(user_data["name"], str) assert user_data["age"] > 0
Mocking
Mocking replaces real objects with fake ones for testing.
unittest.mock Basics
from unittest.mock import Mock, patch # Create mock objectmock = Mock() # Configure return valuemock.return_value = 42assert mock() == 42 # Configure methodmock.get_data.return_value = {"status": "success"}assert mock.get_data() == {"status": "success"} # Check if calledmock()assert mock.calledassert mock.call_count == 2 # Check call argumentsmock.process(1, 2, key="value")mock.process.assert_called_with(1, 2, key="value")mock.process.assert_called_once()
Patching Functions
from unittest.mock import patchimport requests # Function to testdef get_user(user_id): """Fetch user from API.""" response = requests.get(f"https://api.example.com/users/{user_id}") return response.json() # Test with patch@patch('requests.get')def test_get_user(mock_get): """Test get_user with mocked requests.""" # Configure mock mock_response = Mock() mock_response.json.return_value = {"id": 1, "name": "Alice"} mock_get.return_value = mock_response # Call function user = get_user(1) # Assertions assert user["name"] == "Alice" mock_get.assert_called_once_with("https://api.example.com/users/1") # Context manager syntaxdef test_get_user_context(): """Test with context manager.""" with patch('requests.get') as mock_get: mock_response = Mock() mock_response.json.return_value = {"id": 1, "name": "Bob"} mock_get.return_value = mock_response user = get_user(1) assert user["name"] == "Bob"
Patching Class Methods
from unittest.mock import patch, Mock class Database: def connect(self): # Real connection logic pass def query(self, sql): # Real query logic pass class UserService: def __init__(self, db): self.db = db def get_user(self, user_id): self.db.connect() result = self.db.query(f"SELECT * FROM users WHERE id={user_id}") return result # Test with mocked databasedef test_user_service(): """Test UserService with mocked Database.""" mock_db = Mock() mock_db.query.return_value = {"id": 1, "name": "Alice"} service = UserService(mock_db) user = service.get_user(1) assert user["name"] == "Alice" mock_db.connect.assert_called_once() mock_db.query.assert_called_once()
patch.object
from unittest.mock import patch class EmailService: def send_email(self, to, subject, body): # Real email sending print(f"Sending email to {to}") return True class NotificationService: def __init__(self): self.email_service = EmailService() def notify_user(self, user_email, message): return self.email_service.send_email( user_email, "Notification", message ) # Test with patch.objectdef test_notification_service(): """Test NotificationService with mocked email.""" service = NotificationService() with patch.object(service.email_service, 'send_email', return_value=True): result = service.notify_user("[email protected]", "Hello") assert result is True service.email_service.send_email.assert_called_once_with( "[email protected]", "Notification", "Hello" )
pytest-mock
# Install: pip install pytest-mock # Using mocker fixture (pytest-mock)def test_with_mocker(mocker): """Test using pytest-mock.""" mock = mocker.patch('requests.get') mock.return_value.json.return_value = {"data": "test"} # Your test code import requests response = requests.get("http://example.com") assert response.json() == {"data": "test"} # Spy - partial mockdef test_with_spy(mocker): """Test with spy (calls real method).""" mock_list = [1, 2, 3] spy = mocker.spy(mock_list, 'append') mock_list.append(4) assert 4 in mock_list spy.assert_called_once_with(4)
Test Coverage
Coverage measures how much code is tested.
Installation and Usage
# Install coveragepip install coverage # Run with coveragecoverage run -m pytest # Generate reportcoverage report # HTML reportcoverage html# Open htmlcov/index.html # pytest-cov (easier)pip install pytest-covpytest --cov=myproject tests/pytest --cov=myproject --cov-report=html tests/
Coverage Example
# calculator.pydef add(a, b): return a + b def subtract(a, b): return a - b def multiply(a, b): return a * b def divide(a, b): if b == 0: raise ValueError("Cannot divide by zero") return a / b # test_calculator.pydef test_add(): assert add(2, 3) == 5 def test_multiply(): assert multiply(3, 4) == 12 # Run: pytest --cov=calculator test_calculator.py# Coverage report will show:# - add() covered# - multiply() covered # - subtract() not covered# - divide() not covered
Aiming for Coverage
# test_calculator_full.py - better coverageimport pytestfrom calculator import add, subtract, multiply, divide def test_add(): assert add(2, 3) == 5 assert add(-1, 1) == 0 def test_subtract(): assert subtract(5, 3) == 2 def test_multiply(): assert multiply(3, 4) == 12 def test_divide(): assert divide(10, 2) == 5 def test_divide_by_zero(): with pytest.raises(ValueError): divide(10, 0) # Now coverage is 100%!
Test-Driven Development (TDD)
TDD workflow: Write test first → Write code → Refactor.
TDD Cycle (Red-Green-Refactor)
# Step 1: RED - Write failing testdef test_calculate_discount(): """Test discount calculation.""" assert calculate_discount(100, 0.1) == 90 assert calculate_discount(100, 0) == 100 assert calculate_discount(100, 0.5) == 50 # Test fails - calculate_discount doesn't exist # Step 2: GREEN - Write minimal code to passdef calculate_discount(price, discount): return price * (1 - discount) # Test passes! # Step 3: REFACTOR - Improve codedef calculate_discount(price, discount): """Calculate discounted price. Args: price: Original price discount: Discount rate (0-1) Returns: Discounted price Raises: ValueError: If discount is invalid """ if not 0 <= discount <= 1: raise ValueError("Discount must be between 0 and 1") return price * (1 - discount) # Add more testsdef test_calculate_discount_invalid(): """Test invalid discount raises error.""" with pytest.raises(ValueError): calculate_discount(100, -0.1) with pytest.raises(ValueError): calculate_discount(100, 1.5)
TDD Example: Shopping Cart
# Step 1: Write tests firstimport pytest def test_cart_starts_empty(): """Test cart starts empty.""" cart = ShoppingCart() assert cart.total() == 0 assert len(cart.items) == 0 def test_add_item(): """Test adding item to cart.""" cart = ShoppingCart() cart.add_item("Apple", 1.50, 2) assert cart.total() == 3.00 def test_remove_item(): """Test removing item from cart.""" cart = ShoppingCart() cart.add_item("Apple", 1.50, 2) cart.remove_item("Apple") assert cart.total() == 0 # Step 2: Implement to pass testsclass ShoppingCart: def __init__(self): self.items = [] def add_item(self, name, price, quantity): self.items.append({ 'name': name, 'price': price, 'quantity': quantity }) def remove_item(self, name): self.items = [item for item in self.items if item['name'] != name] def total(self): return sum(item['price'] * item['quantity'] for item in self.items) # Step 3: Add more tests and refinedef test_cart_with_multiple_items(): """Test cart with multiple different items.""" cart = ShoppingCart() cart.add_item("Apple", 1.50, 2) cart.add_item("Banana", 0.75, 3) assert cart.total() == 5.25 def test_apply_discount(): """Test applying discount to cart.""" cart = ShoppingCart() cart.add_item("Apple", 10.00, 1) cart.apply_discount(0.1) # 10% discount assert cart.total() == 9.00
Integration Testing
# Integration test exampleimport pytestimport tempfileimport osimport json class DataStore: def __init__(self, filepath): self.filepath = filepath def save(self, data): with open(self.filepath, 'w') as f: json.dump(data, f) def load(self): with open(self.filepath, 'r') as f: return json.load(f) @pytest.fixturedef temp_datastore(): """Fixture for temporary data store.""" fd, path = tempfile.mkstemp(suffix='.json') store = DataStore(path) yield store os.close(fd) os.remove(path) def test_save_and_load_integration(temp_datastore): """Integration test for save and load.""" data = {"users": [{"id": 1, "name": "Alice"}]} # Save temp_datastore.save(data) # Load loaded_data = temp_datastore.load() # Verify assert loaded_data == data
Best Practices
# 1. Test behaviors, not implementation# Gooddef test_user_can_login(): user = login("alice", "password123") assert user.is_authenticated() # Baddef test_login_calls_database_query(): # Too specific pass # 2. Keep tests independent# Good - each test sets up its own datadef test_a(): data = create_test_data() assert process(data) == expected def test_b(): data = create_test_data() assert process(data) == expected # 3. Use descriptive test namesdef test_user_cannot_login_with_wrong_password(): # Clear pass # 4. Follow AAA patterndef test_example(): # Arrange user = User("alice") # Act result = user.get_greeting() # Assert assert result == "Hello, alice" # 5. Mock external dependencies@patch('requests.get')def test_api_call(mock_get): # Don't make real API calls in tests mock_get.return_value.json.return_value = {"data": "test"} result = fetch_data() assert result == {"data": "test"} # 6. Aim for high coverage (but not 100% obsession)# Focus on critical paths# 80-90% coverage is often good # 7. Run tests often# - Before commit# - In CI/CD pipeline# - After pull request
Bài Tập Thực Hành
Bài 1: TDD Blog Post
Use TDD to create BlogPost class:
- Create post
- Add comments
- Like/unlike
- Test all behaviors first
Bài 2: Mock API Client
Test API client with mocks:
- Mock requests library
- Test error handling
- Test retry logic
- Verify calls
Bài 3: Fixture-based Tests
Create reusable fixtures:
- Database fixture
- User fixture
- Product fixture
- Test with all fixtures
Bài 4: Coverage Improvement
Improve test coverage:
- Find untested code
- Write missing tests
- Aim for 90%+ coverage
- Generate HTML report
Bài 5: Integration Test Suite
Build integration test suite:
- Test multiple components
- Use fixtures for setup
- Test real interactions
- Measure coverage
Tóm Tắt
✅ Fixtures: Reusable setup code, scopes (function/class/module/session)
✅ Mocking: Replace dependencies, unittest.mock, patch, Mock
✅ Coverage: Measure tested code, pytest-cov, aim for 80-90%
✅ TDD: Red-Green-Refactor cycle, write tests first
✅ Integration tests: Test multiple components together
✅ Best practices: Test behaviors, AAA pattern, mock externals
Đã Hoàn Thành Testing Module!
🎉 Bài 13 hoàn thành với đầy đủ 2 phần:
- Part 1: unittest & pytest Basics
- Part 2: Fixtures, Mocking & TDD
Bài Tiếp Theo
Bài 14: Virtual Environments Deep Dive - venv, virtualenv, pyenv, pipenv, poetry! 🚀
Remember:
- Use fixtures for reusable setup
- Mock external dependencies
- Aim for high coverage
- Follow TDD when appropriate
- Test behaviors, not implementation! 🎯