Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
name: Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
# ─── 后端单元 / 集成测试 ────────────────────────────────────────────
backend:
name: Backend (pytest)
runs-on: ubuntu-latest

services:
postgres:
image: postgres:17
env:
POSTGRES_DB: archivenote_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10

defaults:
run:
working-directory: api

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Install dependencies (including test group)
run: uv sync --group test

- name: Run pytest
env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/archivenote_test
SECRET_KEY: ci-test-secret-key
run: uv run pytest --cov=app --cov-report=xml -q

- name: Upload coverage
uses: codecov/codecov-action@v5
if: always()
with:
files: api/coverage.xml
flags: backend

# ─── 前端单元测试 ──────────────────────────────────────────────────
frontend:
name: Frontend (vitest)
runs-on: ubuntu-latest

defaults:
run:
working-directory: web

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: web/package-lock.json

- name: Install dependencies
run: npm ci

- name: Run vitest
run: npm run test:coverage

- name: Upload coverage
uses: codecov/codecov-action@v5
if: always()
with:
files: web/coverage/lcov.info
flags: frontend

# ─── E2E 测试(需后端 + 前端均就绪)──────────────────────────────
e2e:
name: E2E (Playwright)
runs-on: ubuntu-latest
needs: [backend, frontend]

services:
postgres:
image: postgres:17
env:
POSTGRES_DB: archivenote
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: web/package-lock.json

- name: Install backend dependencies
working-directory: api
run: uv sync

- name: Start backend
working-directory: api
env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/archivenote
SECRET_KEY: ci-e2e-secret-key
run: |
uv run python -m alembic upgrade head
uv run uvicorn app.app:app --host 0.0.0.0 --port 2601 &
# wait for backend to be ready
for i in $(seq 1 20); do
curl -sf http://localhost:2601/api/hello && break || sleep 2
done

- name: Install frontend dependencies
working-directory: web
run: npm ci

- name: Install Playwright browsers
working-directory: web
run: npx playwright install --with-deps chromium

- name: Run Playwright tests
working-directory: .
env:
CI: true
VITE_API_BASE_URL: http://localhost:2601/api
run: npx playwright test

- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
15 changes: 15 additions & 0 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ dependencies = [
"pre-commit>=4.5.1",
]

[dependency-groups]
test = [
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"httpx>=0.28.0",
"pytest-cov>=6.0.0",
]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
filterwarnings = [
"ignore::DeprecationWarning",
]

[tool.black]
line-length = 88
target-version = ['py313']
Expand Down
Empty file added api/tests/__init__.py
Empty file.
108 changes: 108 additions & 0 deletions api/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""
pytest 测试配置与共享 fixtures。

重要:环境变量必须在导入任何 app 模块之前设置,
因为 database.py 和 jwt.py 在模块加载时就读取环境变量。
"""

import os
from contextlib import asynccontextmanager

import pytest
import pytest_asyncio

# ─── 在导入 app 模块前设置必要的环境变量 ───────────────────────────
TEST_DATABASE_URL = os.environ.get(
"TEST_DATABASE_URL",
"postgresql+asyncpg://postgres:postgres@localhost:5432/archivenote_test",
)
os.environ["DATABASE_URL"] = TEST_DATABASE_URL
os.environ.setdefault("SECRET_KEY", "test-secret-key-do-not-use-in-production")
# ───────────────────────────────────────────────────────────────────

from httpx import ASGITransport, AsyncClient # noqa: E402
from sqlalchemy.ext.asyncio import ( # noqa: E402
AsyncSession,
async_sessionmaker,
create_async_engine,
)

from app.app import app # noqa: E402
from app.database import Base, get_async_session # noqa: E402


# 替换 app lifespan,跳过测试中的数据库迁移
@asynccontextmanager
async def _noop_lifespan(app):
yield


app.router.lifespan_context = _noop_lifespan


# ─── Session 级别 fixtures(整个测试会话只创建一次表)────────────────


@pytest_asyncio.fixture(scope="session")
async def engine():
"""创建测试引擎,建立所有表;测试结束后清除。"""
_engine = create_async_engine(TEST_DATABASE_URL)
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield _engine
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await _engine.dispose()


# ─── Function 级别 fixtures(每个测试函数独立)──────────────────────


@pytest_asyncio.fixture(autouse=True)
async def cleanup_db(engine):
"""每个测试结束后清空所有表数据,保证测试隔离。"""
yield
async with engine.begin() as conn:
# reversed(sorted_tables) 保证先删子表再删父表
for table in reversed(Base.metadata.sorted_tables):
await conn.execute(table.delete())


@pytest_asyncio.fixture
async def db_session(engine):
"""每个测试提供独立的数据库 session。"""
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
yield session


@pytest_asyncio.fixture
async def client(db_session):
"""提供注入测试 session 的 HTTPX 异步客户端。"""

async def override_get_session():
yield db_session

app.dependency_overrides[get_async_session] = override_get_session
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
app.dependency_overrides.clear()


@pytest_asyncio.fixture
async def registered_user(client):
"""注册一个测试用户并返回注册响应(含 access_token)。"""
response = await client.post(
"/api/v1/auth/register",
data={"username": "testuser", "password": "testpassword123"},
)
assert response.status_code == 200, response.text
return response.json()


@pytest_asyncio.fixture
async def auth_headers(registered_user):
"""返回测试用户的 Bearer 认证头。"""
return {"Authorization": f"Bearer {registered_user['access_token']}"}
Loading
Loading