# Testing with Pytest ## Basic Pytest Structure ```python # test_user.py import pytest from myapp.user import User, UserService # Simple test function def test_user_creation() -> None: user = User(id=1, name="Alice", email="alice@example.com") assert user.name == "Alice" assert user.is_active is True # Test with multiple assertions def test_user_validation() -> None: with pytest.raises(ValueError, match="Invalid email"): User(id=1, name="Alice", email="invalid") # Test class for grouping class TestUserService: def test_find_user(self) -> None: service = UserService() user = service.find(1) assert user is not None def test_create_user(self) -> None: service = UserService() user = service.create(name="Bob", email="bob@example.com") assert user.id > 0 ``` ## Fixtures for Setup/Teardown ```python # conftest.py - shared fixtures import pytest from typing import Iterator from myapp.database import Database, Session @pytest.fixture def db() -> Iterator[Database]: """Provide database instance with cleanup.""" database = Database("test.db") database.create_tables() yield database database.drop_tables() database.close() @pytest.fixture def db_session(db: Database) -> Iterator[Session]: """Provide database session with rollback.""" session = db.create_session() yield session session.rollback() session.close() @pytest.fixture def sample_user() -> User: """Provide test user.""" return User(id=1, name="Test User", email="test@example.com") # Using fixtures in tests def test_user_creation(db_session: Session, sample_user: User) -> None: db_session.add(sample_user) db_session.commit() retrieved = db_session.query(User).filter_by(id=1).first() assert retrieved.name == "Test User" # Fixture with parameters @pytest.fixture(params=["sqlite", "postgresql", "mysql"]) def db_engine(request: pytest.FixtureRequest) -> str: return request.param def test_connection(db_engine: str) -> None: # Test runs 3 times with different engines assert create_connection(db_engine) # Autouse fixture (runs automatically) @pytest.fixture(autouse=True) def reset_state() -> Iterator[None]: """Reset global state before each test.""" clear_caches() yield cleanup_temp_files() ``` ## Parametrize for Multiple Cases ```python import pytest # Parametrize test function @pytest.mark.parametrize( "input,expected", [ (2, 4), (3, 9), (4, 16), (-2, 4), ] ) def test_square(input: int, expected: int) -> None: assert square(input) == expected # Multiple parameters @pytest.mark.parametrize("base", [2, 10]) @pytest.mark.parametrize("exponent", [0, 1, 2]) def test_power(base: int, exponent: int) -> None: result = base ** exponent assert result >= 0 # Parametrize with IDs @pytest.mark.parametrize( "email,valid", [ ("user@example.com", True), ("invalid", False), ("@example.com", False), ("user@", False), ], ids=["valid", "no_at", "no_user", "no_domain"] ) def test_email_validation(email: str, valid: bool) -> None: assert is_valid_email(email) == valid # Parametrize with fixtures @pytest.fixture def user_factory(): def _make_user(name: str, active: bool = True) -> User: return User(name=name, active=active) return _make_user @pytest.mark.parametrize("name", ["Alice", "Bob", "Charlie"]) def test_user_names(user_factory, name: str) -> None: user = user_factory(name) assert user.name == name ``` ## Mocking and Patching ```python from unittest.mock import Mock, MagicMock, patch, AsyncMock, call import pytest # Mock object def test_api_call_with_mock() -> None: mock_client = Mock() mock_client.get.return_value = {"status": "ok"} service = ApiService(mock_client) result = service.fetch_data() mock_client.get.assert_called_once_with("/api/data") assert result["status"] == "ok" # Patch function/method def test_database_call() -> None: with patch("myapp.database.connect") as mock_connect: mock_connect.return_value = Mock() db = Database() db.connect() mock_connect.assert_called_once() # Patch as decorator @patch("myapp.user.send_email") def test_user_registration(mock_send_email: Mock) -> None: service = UserService() service.register("user@example.com") mock_send_email.assert_called_with( to="user@example.com", subject="Welcome" ) # Multiple patches @patch("myapp.api.requests.get") @patch("myapp.api.cache.get") def test_cached_api(mock_cache: Mock, mock_requests: Mock) -> None: mock_cache.return_value = None mock_requests.return_value.json.return_value = {"data": "value"} result = fetch_with_cache("key") mock_cache.assert_called_once_with("key") mock_requests.assert_called_once() # Mock side effects def test_retry_logic() -> None: mock_api = Mock() mock_api.call.side_effect = [ ConnectionError("Failed"), ConnectionError("Failed"), {"status": "ok"} ] result = retry_api_call(mock_api) assert result["status"] == "ok" assert mock_api.call.call_count == 3 # Async mock @pytest.mark.asyncio async def test_async_function() -> None: mock_db = AsyncMock() mock_db.fetch_user.return_value = User(id=1, name="Alice") service = AsyncUserService(mock_db) user = await service.get_user(1) mock_db.fetch_user.assert_awaited_once_with(1) assert user.name == "Alice" ``` ## Async Testing ```python import pytest import asyncio # Mark async test @pytest.mark.asyncio async def test_async_fetch() -> None: result = await fetch_data("https://api.example.com") assert result["status"] == "ok" # Async fixture @pytest.fixture async def async_db() -> AsyncIterator[AsyncDatabase]: db = AsyncDatabase() await db.connect() yield db await db.disconnect() @pytest.mark.asyncio async def test_async_query(async_db: AsyncDatabase) -> None: result = await async_db.query("SELECT * FROM users") assert len(result) > 0 # Test concurrent operations @pytest.mark.asyncio async def test_concurrent_requests() -> None: urls = ["http://example.com/1", "http://example.com/2"] results = await asyncio.gather(*[fetch(url) for url in urls]) assert len(results) == 2 ``` ## Pytest Markers ```python import pytest # Skip test @pytest.mark.skip(reason="Not implemented yet") def test_future_feature() -> None: pass # Conditional skip @pytest.mark.skipif(sys.version_info < (3, 11), reason="Requires Python 3.11+") def test_new_feature() -> None: pass # Expected failure @pytest.mark.xfail(reason="Known bug #123") def test_known_bug() -> None: assert buggy_function() == expected_value # Custom markers @pytest.mark.slow def test_slow_operation() -> None: time.sleep(5) assert True @pytest.mark.integration def test_integration() -> None: assert external_service.ping() # Run with: pytest -m "not slow" ``` ## Test Coverage ```python # Run with coverage # pytest --cov=myapp --cov-report=html --cov-report=term # conftest.py - coverage configuration def pytest_configure(config): config.addinivalue_line( "markers", "unit: mark test as unit test" ) # pytest.ini or pyproject.toml """ [tool.pytest.ini_options] minversion = "7.0" addopts = [ "--cov=myapp", "--cov-report=term-missing", "--cov-fail-under=90", "-ra", "--strict-markers", ] testpaths = ["tests"] """ ``` ## Property-Based Testing ```python from hypothesis import given, strategies as st # Property-based test @given(st.integers(), st.integers()) def test_addition_commutative(a: int, b: int) -> None: assert a + b == b + a @given(st.lists(st.integers())) def test_sorted_is_ordered(lst: list[int]) -> None: sorted_lst = sorted(lst) for i in range(len(sorted_lst) - 1): assert sorted_lst[i] <= sorted_lst[i + 1] # Custom strategies @given(st.emails()) def test_email_validation(email: str) -> None: assert "@" in email assert validate_email(email) # Composite strategies from hypothesis import strategies as st from hypothesis.strategies import composite @composite def users(draw) -> User: return User( id=draw(st.integers(min_value=1)), name=draw(st.text(min_size=1, max_size=50)), email=draw(st.emails()), age=draw(st.integers(min_value=18, max_value=120)) ) @given(users()) def test_user_creation(user: User) -> None: assert user.age >= 18 assert len(user.name) > 0 ``` ## Test Organization ```python # tests/ # conftest.py - Shared fixtures # test_user.py - User tests # test_api.py - API tests # integration/ # test_workflow.py - Integration tests # unit/ # test_models.py - Unit tests # Fixture factory pattern @pytest.fixture def user_factory(db_session: Session): created_users: list[User] = [] def _create_user( name: str = "Test User", email: str | None = None, **kwargs ) -> User: if email is None: email = f"{name.lower().replace(' ', '.')}@example.com" user = User(name=name, email=email, **kwargs) db_session.add(user) db_session.commit() created_users.append(user) return user yield _create_user # Cleanup for user in created_users: db_session.delete(user) db_session.commit() ``` ## Snapshot Testing ```python import pytest from syrupy.assertion import SnapshotAssertion def test_api_response(snapshot: SnapshotAssertion) -> None: response = api.get_user(1) assert response == snapshot def test_rendered_template(snapshot: SnapshotAssertion) -> None: html = render_template("user.html", user=get_user(1)) assert html == snapshot ```