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! 🎯