# 

# 03-9. 測試資料庫操作

&gt; ⏱️ **閱讀時間：** 18 分鐘
&gt; 🎯 **難度：** ⭐⭐⭐ (進階)

---

## 🤔 一句話解釋

**測試資料庫操作需要隔離的測試環境，確保測試不會影響真實資料，且每次測試結果可預測。**

---

## 🎯 測試策略

```
┌─────────────────────────────────────────────────────────┐
│                    測試金字塔                            │
├─────────────────────────────────────────────────────────┤
│                    ┌─────┐                              │
│                   /  E2E  \     少量端對端測試           │
│                  /─────────\                            │
│                 / Integration\   整合測試               │
│                /───────────────\                        │
│               /    Unit Tests   \  大量單元測試          │
│              /───────────────────\                      │
└─────────────────────────────────────────────────────────┘
```

### 資料庫測試類型

| 類型 | 說明 | 速度 |
|------|------|------|
| **Mock** | 不實際連接資料庫 | 最快 |
| **SQLite 記憶體** | 使用記憶體資料庫 | 快 |
| **測試資料庫** | 使用獨立的測試 DB | 中等 |
| **容器化** | Docker 啟動測試 DB | 較慢 |

---

## 📦 安裝測試工具

```bash
pip install pytest pytest-asyncio pytest-cov httpx
pip install factory-boy faker  # 測試資料生成
```

---

## 🔧 測試環境設定

### pytest 設定

```python
# conftest.py
import pytest
import asyncio
from typing import AsyncGenerator, Generator
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from httpx import AsyncClient, ASGITransport

from app.main import app
from app.database import Base, get_db
from app.models import User, Post

# 測試用資料庫 URL
TEST_DATABASE_URL = &#34;sqlite&#43;aiosqlite:///:memory:&#34;

# 建立測試 Engine
test_engine = create_async_engine(
    TEST_DATABASE_URL,
    connect_args={&#34;check_same_thread&#34;: False},
    poolclass=StaticPool,  # SQLite 記憶體需要使用 StaticPool
)

# 測試 Session 工廠
TestSessionLocal = sessionmaker(
    test_engine,
    class_=AsyncSession,
    expire_on_commit=False,
    autoflush=False,
    autocommit=False,
)


@pytest.fixture(scope=&#34;session&#34;)
def event_loop() -&gt; Generator:
    &#34;&#34;&#34;建立事件迴圈&#34;&#34;&#34;
    loop = asyncio.get_event_loop_policy().new_event_loop()
    yield loop
    loop.close()


@pytest.fixture(scope=&#34;function&#34;)
async def db_session() -&gt; AsyncGenerator[AsyncSession, None]:
    &#34;&#34;&#34;每個測試函數使用獨立的資料庫 Session&#34;&#34;&#34;
    # 建立資料表
    async with test_engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

    # 建立 Session
    async with TestSessionLocal() as session:
        yield session

    # 清理資料表
    async with test_engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)


@pytest.fixture(scope=&#34;function&#34;)
async def client(db_session: AsyncSession) -&gt; AsyncGenerator[AsyncClient, None]:
    &#34;&#34;&#34;測試用 HTTP 客戶端&#34;&#34;&#34;

    # 覆蓋依賴項
    async def override_get_db():
        yield db_session

    app.dependency_overrides[get_db] = override_get_db

    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url=&#34;http://test&#34;
    ) as ac:
        yield ac

    app.dependency_overrides.clear()
```

### 使用 PostgreSQL 測試

```python
# conftest.py（PostgreSQL 版本）
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker

# 測試資料庫
TEST_DATABASE_URL = &#34;postgresql&#43;asyncpg://test:test@localhost:5432/test_db&#34;

test_engine = create_async_engine(TEST_DATABASE_URL)
TestSessionLocal = sessionmaker(
    test_engine,
    class_=AsyncSession,
    expire_on_commit=False,
)


@pytest.fixture(scope=&#34;function&#34;)
async def db_session():
    &#34;&#34;&#34;使用交易隔離測試&#34;&#34;&#34;
    async with test_engine.connect() as conn:
        # 開始交易
        trans = await conn.begin()

        # 建立 Session（使用同一個連線）
        async with AsyncSession(bind=conn) as session:
            yield session

        # 回滾交易（不影響資料庫）
        await trans.rollback()
```

---

## 📝 Repository 測試

### 測試基本 CRUD

```python
# tests/test_user_repository.py
import pytest
from sqlalchemy.ext.asyncio import AsyncSession

from app.models import User
from app.repositories.user_repository import UserRepository


@pytest.mark.asyncio
class TestUserRepository:
    &#34;&#34;&#34;User Repository 測試&#34;&#34;&#34;

    async def test_create_user(self, db_session: AsyncSession):
        &#34;&#34;&#34;測試建立使用者&#34;&#34;&#34;
        repo = UserRepository(db_session)

        user = User(
            username=&#34;testuser&#34;,
            email=&#34;test@example.com&#34;,
            hashed_password=&#34;hashed&#34;
        )
        created = await repo.create(user)

        assert created.id is not None
        assert created.username == &#34;testuser&#34;
        assert created.email == &#34;test@example.com&#34;

    async def test_get_by_id(self, db_session: AsyncSession):
        &#34;&#34;&#34;測試根據 ID 查詢&#34;&#34;&#34;
        repo = UserRepository(db_session)

        # 先建立
        user = User(
            username=&#34;testuser&#34;,
            email=&#34;test@example.com&#34;,
            hashed_password=&#34;hashed&#34;
        )
        created = await repo.create(user)

        # 再查詢
        found = await repo.get_by_id(created.id)

        assert found is not None
        assert found.id == created.id
        assert found.username == &#34;testuser&#34;

    async def test_get_by_id_not_found(self, db_session: AsyncSession):
        &#34;&#34;&#34;測試查詢不存在的使用者&#34;&#34;&#34;
        repo = UserRepository(db_session)

        found = await repo.get_by_id(99999)

        assert found is None

    async def test_get_by_email(self, db_session: AsyncSession):
        &#34;&#34;&#34;測試根據 email 查詢&#34;&#34;&#34;
        repo = UserRepository(db_session)

        user = User(
            username=&#34;testuser&#34;,
            email=&#34;test@example.com&#34;,
            hashed_password=&#34;hashed&#34;
        )
        await repo.create(user)

        found = await repo.get_by_email(&#34;test@example.com&#34;)

        assert found is not None
        assert found.email == &#34;test@example.com&#34;

    async def test_exists_by_email(self, db_session: AsyncSession):
        &#34;&#34;&#34;測試 email 是否存在&#34;&#34;&#34;
        repo = UserRepository(db_session)

        # 不存在
        assert await repo.exists_by_email(&#34;test@example.com&#34;) is False

        # 建立後存在
        user = User(
            username=&#34;testuser&#34;,
            email=&#34;test@example.com&#34;,
            hashed_password=&#34;hashed&#34;
        )
        await repo.create(user)

        assert await repo.exists_by_email(&#34;test@example.com&#34;) is True

    async def test_update_user(self, db_session: AsyncSession):
        &#34;&#34;&#34;測試更新使用者&#34;&#34;&#34;
        repo = UserRepository(db_session)

        user = User(
            username=&#34;testuser&#34;,
            email=&#34;test@example.com&#34;,
            hashed_password=&#34;hashed&#34;
        )
        created = await repo.create(user)

        # 更新
        created.username = &#34;newname&#34;
        updated = await repo.update(created)

        assert updated.username == &#34;newname&#34;

        # 重新查詢確認
        found = await repo.get_by_id(created.id)
        assert found.username == &#34;newname&#34;

    async def test_delete_user(self, db_session: AsyncSession):
        &#34;&#34;&#34;測試刪除使用者&#34;&#34;&#34;
        repo = UserRepository(db_session)

        user = User(
            username=&#34;testuser&#34;,
            email=&#34;test@example.com&#34;,
            hashed_password=&#34;hashed&#34;
        )
        created = await repo.create(user)

        # 刪除
        result = await repo.delete(created.id)
        assert result is True

        # 確認已刪除
        found = await repo.get_by_id(created.id)
        assert found is None

    async def test_delete_nonexistent_user(self, db_session: AsyncSession):
        &#34;&#34;&#34;測試刪除不存在的使用者&#34;&#34;&#34;
        repo = UserRepository(db_session)

        result = await repo.delete(99999)
        assert result is False

    async def test_get_all_with_pagination(self, db_session: AsyncSession):
        &#34;&#34;&#34;測試分頁查詢&#34;&#34;&#34;
        repo = UserRepository(db_session)

        # 建立多個使用者
        for i in range(10):
            user = User(
                username=f&#34;user{i}&#34;,
                email=f&#34;user{i}@example.com&#34;,
                hashed_password=&#34;hashed&#34;
            )
            await repo.create(user)

        # 測試分頁
        page1 = await repo.get_all(skip=0, limit=5)
        assert len(page1) == 5

        page2 = await repo.get_all(skip=5, limit=5)
        assert len(page2) == 5

        # 確保不重複
        page1_ids = {u.id for u in page1}
        page2_ids = {u.id for u in page2}
        assert page1_ids.isdisjoint(page2_ids)
```

---

## 🏗️ Service 測試

### 使用 Mock Repository

```python
# tests/test_user_service.py
import pytest
from unittest.mock import AsyncMock, MagicMock

from app.services.user_service import UserService, CreateUserDTO
from app.models import User


@pytest.mark.asyncio
class TestUserService:
    &#34;&#34;&#34;User Service 測試&#34;&#34;&#34;

    async def test_create_user_success(self):
        &#34;&#34;&#34;測試成功建立使用者&#34;&#34;&#34;
        # Mock Repository
        mock_repo = AsyncMock()
        mock_repo.exists_by_email.return_value = False
        mock_repo.exists_by_username.return_value = False
        mock_repo.create.return_value = User(
            id=1,
            username=&#34;testuser&#34;,
            email=&#34;test@example.com&#34;,
            hashed_password=&#34;hashed&#34;
        )

        service = UserService(mock_repo)
        data = CreateUserDTO(
            username=&#34;testuser&#34;,
            email=&#34;test@example.com&#34;,
            password=&#34;password123&#34;
        )

        user = await service.create_user(data)

        assert user.id == 1
        assert user.username == &#34;testuser&#34;
        mock_repo.exists_by_email.assert_called_once_with(&#34;test@example.com&#34;)
        mock_repo.exists_by_username.assert_called_once_with(&#34;testuser&#34;)
        mock_repo.create.assert_called_once()

    async def test_create_user_email_exists(self):
        &#34;&#34;&#34;測試 email 已存在&#34;&#34;&#34;
        mock_repo = AsyncMock()
        mock_repo.exists_by_email.return_value = True

        service = UserService(mock_repo)
        data = CreateUserDTO(
            username=&#34;testuser&#34;,
            email=&#34;existing@example.com&#34;,
            password=&#34;password123&#34;
        )

        with pytest.raises(ValueError, match=&#34;Email already registered&#34;):
            await service.create_user(data)

    async def test_create_user_username_exists(self):
        &#34;&#34;&#34;測試 username 已存在&#34;&#34;&#34;
        mock_repo = AsyncMock()
        mock_repo.exists_by_email.return_value = False
        mock_repo.exists_by_username.return_value = True

        service = UserService(mock_repo)
        data = CreateUserDTO(
            username=&#34;existing&#34;,
            email=&#34;test@example.com&#34;,
            password=&#34;password123&#34;
        )

        with pytest.raises(ValueError, match=&#34;Username already taken&#34;):
            await service.create_user(data)

    async def test_get_user_found(self):
        &#34;&#34;&#34;測試取得存在的使用者&#34;&#34;&#34;
        mock_repo = AsyncMock()
        mock_repo.get_by_id.return_value = User(
            id=1,
            username=&#34;testuser&#34;,
            email=&#34;test@example.com&#34;,
            hashed_password=&#34;hashed&#34;
        )

        service = UserService(mock_repo)
        user = await service.get_user(1)

        assert user is not None
        assert user.id == 1
        mock_repo.get_by_id.assert_called_once_with(1)

    async def test_get_user_not_found(self):
        &#34;&#34;&#34;測試取得不存在的使用者&#34;&#34;&#34;
        mock_repo = AsyncMock()
        mock_repo.get_by_id.return_value = None

        service = UserService(mock_repo)
        user = await service.get_user(99999)

        assert user is None
```

---

## 🌐 API 整合測試

### 測試端點

```python
# tests/test_user_api.py
import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
class TestUserAPI:
    &#34;&#34;&#34;User API 整合測試&#34;&#34;&#34;

    async def test_create_user(self, client: AsyncClient):
        &#34;&#34;&#34;測試建立使用者 API&#34;&#34;&#34;
        response = await client.post(
            &#34;/users&#34;,
            json={
                &#34;username&#34;: &#34;testuser&#34;,
                &#34;email&#34;: &#34;test@example.com&#34;,
                &#34;password&#34;: &#34;password123&#34;
            }
        )

        assert response.status_code == 200
        data = response.json()
        assert data[&#34;username&#34;] == &#34;testuser&#34;
        assert data[&#34;email&#34;] == &#34;test@example.com&#34;
        assert &#34;id&#34; in data
        assert &#34;password&#34; not in data  # 密碼不應該返回

    async def test_create_user_invalid_email(self, client: AsyncClient):
        &#34;&#34;&#34;測試無效的 email&#34;&#34;&#34;
        response = await client.post(
            &#34;/users&#34;,
            json={
                &#34;username&#34;: &#34;testuser&#34;,
                &#34;email&#34;: &#34;invalid-email&#34;,
                &#34;password&#34;: &#34;password123&#34;
            }
        )

        assert response.status_code == 422  # Validation error

    async def test_create_user_duplicate_email(self, client: AsyncClient):
        &#34;&#34;&#34;測試重複的 email&#34;&#34;&#34;
        # 建立第一個使用者
        await client.post(
            &#34;/users&#34;,
            json={
                &#34;username&#34;: &#34;user1&#34;,
                &#34;email&#34;: &#34;test@example.com&#34;,
                &#34;password&#34;: &#34;password123&#34;
            }
        )

        # 嘗試建立重複 email 的使用者
        response = await client.post(
            &#34;/users&#34;,
            json={
                &#34;username&#34;: &#34;user2&#34;,
                &#34;email&#34;: &#34;test@example.com&#34;,
                &#34;password&#34;: &#34;password123&#34;
            }
        )

        assert response.status_code == 400
        assert &#34;Email already registered&#34; in response.json()[&#34;detail&#34;]

    async def test_get_users(self, client: AsyncClient):
        &#34;&#34;&#34;測試取得使用者列表&#34;&#34;&#34;
        # 建立幾個使用者
        for i in range(3):
            await client.post(
                &#34;/users&#34;,
                json={
                    &#34;username&#34;: f&#34;user{i}&#34;,
                    &#34;email&#34;: f&#34;user{i}@example.com&#34;,
                    &#34;password&#34;: &#34;password123&#34;
                }
            )

        response = await client.get(&#34;/users&#34;)

        assert response.status_code == 200
        data = response.json()
        assert len(data) == 3

    async def test_get_user_by_id(self, client: AsyncClient):
        &#34;&#34;&#34;測試根據 ID 取得使用者&#34;&#34;&#34;
        # 建立使用者
        create_response = await client.post(
            &#34;/users&#34;,
            json={
                &#34;username&#34;: &#34;testuser&#34;,
                &#34;email&#34;: &#34;test@example.com&#34;,
                &#34;password&#34;: &#34;password123&#34;
            }
        )
        user_id = create_response.json()[&#34;id&#34;]

        # 查詢使用者
        response = await client.get(f&#34;/users/{user_id}&#34;)

        assert response.status_code == 200
        data = response.json()
        assert data[&#34;id&#34;] == user_id
        assert data[&#34;username&#34;] == &#34;testuser&#34;

    async def test_get_user_not_found(self, client: AsyncClient):
        &#34;&#34;&#34;測試查詢不存在的使用者&#34;&#34;&#34;
        response = await client.get(&#34;/users/99999&#34;)

        assert response.status_code == 404
        assert &#34;not found&#34; in response.json()[&#34;detail&#34;].lower()

    async def test_update_user(self, client: AsyncClient):
        &#34;&#34;&#34;測試更新使用者&#34;&#34;&#34;
        # 建立使用者
        create_response = await client.post(
            &#34;/users&#34;,
            json={
                &#34;username&#34;: &#34;testuser&#34;,
                &#34;email&#34;: &#34;test@example.com&#34;,
                &#34;password&#34;: &#34;password123&#34;
            }
        )
        user_id = create_response.json()[&#34;id&#34;]

        # 更新使用者
        response = await client.patch(
            f&#34;/users/{user_id}&#34;,
            json={&#34;username&#34;: &#34;newname&#34;}
        )

        assert response.status_code == 200
        data = response.json()
        assert data[&#34;username&#34;] == &#34;newname&#34;

    async def test_delete_user(self, client: AsyncClient):
        &#34;&#34;&#34;測試刪除使用者&#34;&#34;&#34;
        # 建立使用者
        create_response = await client.post(
            &#34;/users&#34;,
            json={
                &#34;username&#34;: &#34;testuser&#34;,
                &#34;email&#34;: &#34;test@example.com&#34;,
                &#34;password&#34;: &#34;password123&#34;
            }
        )
        user_id = create_response.json()[&#34;id&#34;]

        # 刪除使用者
        response = await client.delete(f&#34;/users/{user_id}&#34;)
        assert response.status_code == 200

        # 確認已刪除
        get_response = await client.get(f&#34;/users/{user_id}&#34;)
        assert get_response.status_code == 404
```

---

## 🏭 測試資料工廠

### 使用 Factory Boy

```python
# tests/factories.py
import factory
from factory import fuzzy
from faker import Faker

from app.models import User, Post
from app.database import SessionLocal

fake = Faker()


class SQLAlchemyModelFactory(factory.Factory):
    &#34;&#34;&#34;SQLAlchemy Model 工廠基礎類別&#34;&#34;&#34;

    class Meta:
        abstract = True

    @classmethod
    def _create(cls, model_class, *args, **kwargs):
        &#34;&#34;&#34;同步建立（用於 Fixture）&#34;&#34;&#34;
        return model_class(*args, **kwargs)


class UserFactory(SQLAlchemyModelFactory):
    &#34;&#34;&#34;User 測試資料工廠&#34;&#34;&#34;

    class Meta:
        model = User

    username = factory.LazyFunction(lambda: fake.user_name()[:50])
    email = factory.LazyFunction(fake.email)
    hashed_password = factory.LazyFunction(
        lambda: fake.sha256()
    )
    is_active = True


class PostFactory(SQLAlchemyModelFactory):
    &#34;&#34;&#34;Post 測試資料工廠&#34;&#34;&#34;

    class Meta:
        model = Post

    title = factory.LazyFunction(lambda: fake.sentence()[:200])
    content = factory.LazyFunction(fake.text)
    author = factory.SubFactory(UserFactory)
```

### 在測試中使用工廠

```python
# tests/test_with_factories.py
import pytest
from sqlalchemy.ext.asyncio import AsyncSession

from tests.factories import UserFactory, PostFactory
from app.repositories.user_repository import UserRepository


@pytest.mark.asyncio
class TestWithFactories:

    async def test_user_factory(self, db_session: AsyncSession):
        &#34;&#34;&#34;使用工廠建立測試資料&#34;&#34;&#34;
        # 建立使用者
        user = UserFactory()
        db_session.add(user)
        await db_session.commit()

        assert user.id is not None
        assert user.username is not None
        assert user.email is not None

    async def test_create_multiple_users(self, db_session: AsyncSession):
        &#34;&#34;&#34;建立多個測試使用者&#34;&#34;&#34;
        users = [UserFactory() for _ in range(5)]
        db_session.add_all(users)
        await db_session.commit()

        repo = UserRepository(db_session)
        all_users = await repo.get_all()

        assert len(all_users) == 5

    async def test_user_with_posts(self, db_session: AsyncSession):
        &#34;&#34;&#34;建立使用者和文章&#34;&#34;&#34;
        user = UserFactory()
        posts = [PostFactory(author=user) for _ in range(3)]

        db_session.add(user)
        db_session.add_all(posts)
        await db_session.commit()

        # 驗證關聯
        await db_session.refresh(user)
        assert len(user.posts) == 3
```

---

## 🐳 Docker 測試環境

### docker-compose.test.yml

```yaml
version: &#39;3.8&#39;

services:
  test-db:
    image: postgres:15
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: test_db
    ports:
      - &#34;5433:5432&#34;
    tmpfs:
      - /var/lib/postgresql/data  # 使用記憶體加速
```

### 測試設定

```python
# conftest.py（Docker 版本）
import pytest
import subprocess
import time

@pytest.fixture(scope=&#34;session&#34;, autouse=True)
def docker_compose():
    &#34;&#34;&#34;啟動測試資料庫容器&#34;&#34;&#34;
    subprocess.run(
        [&#34;docker-compose&#34;, &#34;-f&#34;, &#34;docker-compose.test.yml&#34;, &#34;up&#34;, &#34;-d&#34;],
        check=True
    )

    # 等待資料庫就緒
    time.sleep(3)

    yield

    # 清理
    subprocess.run(
        [&#34;docker-compose&#34;, &#34;-f&#34;, &#34;docker-compose.test.yml&#34;, &#34;down&#34;, &#34;-v&#34;],
        check=True
    )
```

---

## 📊 測試覆蓋率

### 執行測試

```bash
# 執行所有測試
pytest

# 執行特定測試檔案
pytest tests/test_user_repository.py

# 執行特定測試類別
pytest tests/test_user_repository.py::TestUserRepository

# 執行特定測試函數
pytest tests/test_user_repository.py::TestUserRepository::test_create_user

# 顯示詳細輸出
pytest -v

# 顯示 print 輸出
pytest -s

# 測試覆蓋率
pytest --cov=app --cov-report=html

# 只跑非同步測試
pytest -m asyncio
```

### pytest.ini 設定

```ini
[pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
markers =
    asyncio: mark test as async
    slow: mark test as slow
```

---

## ✅ 重點總結

### 測試層級

| 層級 | 測試對象 | 資料庫 |
|------|----------|--------|
| Unit | Repository/Service | Mock 或 SQLite |
| Integration | API | 測試資料庫 |
| E2E | 完整流程 | 測試資料庫 |

### 測試要點

1. **隔離性**：每個測試獨立，不互相影響
2. **可重複**：多次執行結果一致
3. **快速**：使用記憶體資料庫或交易回滾
4. **完整**：涵蓋正常和異常情況

### 常用 Fixture

```python
@pytest.fixture
async def db_session():  # 資料庫 Session
    ...

@pytest.fixture
async def client():  # HTTP 客戶端
    ...

@pytest.fixture
def user_factory():  # 測試資料工廠
    ...
```

---

## 🎤 面試這樣答

### Q: 如何測試資料庫操作？

**答案：**

&gt; 測試資料庫操作有幾種策略：
&gt;
&gt; 1. **使用測試資料庫**：獨立的測試 DB，每次測試前清空
&gt; 2. **交易回滾**：每個測試在交易中執行，結束後回滾
&gt; 3. **SQLite 記憶體**：快速但可能有 SQL 差異
&gt;
&gt; ```python
&gt; @pytest.fixture
&gt; async def db_session():
&gt;     async with test_engine.connect() as conn:
&gt;         trans = await conn.begin()
&gt;         async with AsyncSession(bind=conn) as session:
&gt;             yield session
&gt;         await trans.rollback()  # 測試後回滾
&gt; ```
&gt;
&gt; 重點是確保測試**隔離**、**可重複**、**快速**。

---

**上一篇：** [03-8. 多資料庫支援](./03-8)
**下一篇：** [04-1. 認證基礎](./04-1)

---

最後更新：2025-12-17


---

> 作者: luk  
> URL: https://yoru-karu-blog-lalaluk-52581ac5e0cef170a3c8922c19182ecb6f7bd604.gitlab.io/posts/tutorial/fastapi/03-9/  

