别再伪造数据了!Prisma 种子与 React 测试的“全栈一致性”救赎指南
大家好!
欢迎来到今天的研讨会,主题是“如何停止在 React 测试里对着假数据发呆,并开始拥抱真实的数据库灵魂”。
作为一名在全栈泥潭里摸爬滚打多年的老兵,我见过太多让人头秃的测试场景。你是不是也经历过这样的绝望:
你写了一个 User 组件,逻辑很简单:如果用户叫“管理员”,显示绿色的背景;如果是“普通用户”,显示灰色的。测试跑通了,你感觉人生巅峰。
第二天,后端同事吼你一声:“嘿,我们把数据结构改了,现在 User 下面嵌了一个 Profile 对象。” 你一运行测试,undefined 疯狂输出,测试直接给你甩脸子。
为什么?因为你的测试在用 JSON 文件 Mock 数据,而你后端用的是真实的数据库。
这时候,Mock 数据就成了一座活火山。 它在测试环境里可能是“管理员”,在生产环境里可能就变成了“用户”,或者干脆是个孤儿。这就是所谓的数据漂移。这就好比你叫外卖,外卖员送到了你家,但他并没有爬上楼,而是在楼下大喊了一声“到了”,然后转身就跑。
今天,我们要解决什么问题?我们要构建一套全栈测试环境下的一致性数据 Mock 注入方案。我们将使用 Prisma Seed 来注入数据,使用 Docker 来隔离环境,并配合 React Testing Library 做出能经受住真实数据冲击的测试。
准备好了吗?系好安全带,我们要开始“通下水道”了。
第一章:为什么你的测试像个“伪君子”?
在深入代码之前,先来聊聊为什么 Mock 数据在现在这种复杂的全栈架构下已经不适用了。
1. 关系的复杂性
以前的 API 就是个简单的键值对列表。现在的 API 呢?User -> Profile -> Account -> Settings。如果你在 Mock 里手动构建这个链条,你就是在写累赘的样板代码。而且,一旦 Schema 变动,你 Mock 的那一团乱麻就废了。
2. 边界条件的崩溃
Mock 数据通常只会包含 id: 1, name: "Alice"。但是,真实世界里充满了边界。比如邮箱格式不对、密码过期了、订单状态是“已发货”但库存是 0。你 Mock 的数据太“完美”了,测试通过了,上线后却炸了。
3. CI/CD 环境的噩梦
本地开发环境数据库里全是脏数据,测试环境数据库里又因为并发导致数据错乱。你敢信吗?两个开发者同时提交代码,因为数据库里多了一条记录,导致 A 的测试炸了,B 的测试却莫名其妙通过了。
我们要找的方案,是“确定性”。无论测试跑几次,它都应该看到一样的数据;无论在 CI 还是本地,它都应该看到一样的数据。这个方案,就叫 Prisma Seed。
第二章:Prisma Seed —— 数据库的“装修工”
Prisma Seed 是什么?简单来说,它就是 Prisma 官方提供的一个工具,允许你在测试或开发环境初始化时,通过编写 JavaScript/TypeScript 代码来向数据库插入数据。
这不仅仅是一个 INSERT INTO users VALUES ... 的命令,它是一个脚本。这意味着你拥有了编程的灵活性。
2.1 环境准备:Docker 是你的避难所
在写 Seed 脚本之前,我们必须解决环境隔离问题。如果你直接在本地数据库跑 Seed,那每次跑测试之前你都得手动清空表,想想都累。
我们要用 Docker。想象一下,Docker 容器就是一个个独立的玻璃盒子。测试 A 在盒子 1,测试 B 在盒子 2,互不干扰。
在你的 docker-compose.yml 里,我们要定义一个专用的测试数据库服务:
version: '3.8'
services:
# ... 你的其他服务 ...
# 这是我们的测试专用数据库
test-db:
image: postgres:14
container_name: my_app_test_db
environment:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
POSTGRES_DB: test_database
ports:
- "5433:5432" # 别占用默认的 5432,不然和本地开发冲突
# 在测试运行时才启动这个容器,平时别浪费资源
profiles:
- test
2.2 配置 Prisma 连接
接下来,我们需要告诉 Prisma,在测试模式下,使用哪个数据库。
在你的 .env.test 文件里:
DATABASE_URL="postgresql://test_user:test_password@localhost:5433/test_database"
然后,修改你的 package.json,让它在测试前自动重置数据库并运行 Seed。
{
"scripts": {
"test": "jest",
"test:setup": "prisma migrate deploy && prisma db seed",
"seed": "ts-node prisma/seed.ts"
}
}
注意 test:setup 这一行。它干了两件事:先部署最新的 Schema(确保数据库结构是最新的),然后运行 Seed(确保数据是最新的)。这就是我们的核心魔法。
第三章:编写 Seed 脚本 —— 从手写 INSERT 到编程生成
好了,现在轮到 Seed 脚本登场了。文件位置通常是 prisma/seed.ts。
3.1 基础版:机械重复
最简单的做法,就是写死一些数据。比如我们有个博客系统:
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
// 清空旧数据,防止重复(这个很重要!)
await prisma.post.deleteMany()
await prisma.user.deleteMany()
// 创建用户
const user = await prisma.user.create({
data: {
name: 'John Doe',
email: '[email protected]',
posts: {
create: {
title: 'Hello World',
content: 'This is a real post from the seed.',
},
},
},
})
// 创建另一个用户
const user2 = await prisma.user.create({
data: {
name: 'Jane Smith',
email: '[email protected]',
posts: {
create: [
{
title: 'Prisma Tips',
content: 'Very useful tips.',
},
{
title: 'My First Post',
content: 'Just getting started.',
},
],
},
},
})
console.log('Database seeded successfully!')
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})
这段代码没问题,但太死板。每次你想加个测试数据,都得手动改代码。而且,如果你的 Schema 里有几百条数据,你写个十年也写不完。
3.2 进阶版:数据工厂与循环
让我们搞点高级的。假设我们的 Schema 很复杂:User 有 Profile,Post 有 Tags,Comment 又属于 Post。
我们可以写一个辅助函数,自动生成符合格式的东西。这叫 Data Factory。
// prisma/seed.ts
const prisma = new PrismaClient()
// 1. 定义工厂函数
const createRandomUser = async () => {
const randomName = `User_${Math.random().toString(36).substring(7)}`
const randomEmail = `${randomName}@example.com`
// 创建用户
const user = await prisma.user.create({
data: {
name: randomName,
email: randomEmail,
profile: {
create: {
bio: `This is the bio of ${randomName}.`,
age: Math.floor(Math.random() * 50) + 18, // 随机年龄
},
},
},
})
return user
}
const createRandomPost = async (authorId: string) => {
return prisma.post.create({
data: {
title: `Post from ${new Date().toISOString()}`,
content: 'Lorem ipsum dolor sit amet.',
published: Math.random() > 0.5, // 随机发布状态
authorId: authorId,
},
})
}
const createRandomComment = async (postId: string, userId: string) => {
return prisma.comment.create({
data: {
content: 'Great post!',
authorId: userId,
postId: postId,
},
})
}
async function main() {
// 清空数据
await prisma.comment.deleteMany()
await prisma.post.deleteMany()
await prisma.user.deleteMany()
// 生成 10 个用户
const users = []
for (let i = 0; i < 10; i++) {
users.push(await createRandomUser())
}
// 为每个用户生成 2 到 5 篇文章
const posts = []
for (const user of users) {
const postCount = Math.floor(Math.random() * 4) + 2
for (let i = 0; i < postCount; i++) {
const post = await createRandomPost(user.id)
posts.push(post)
}
}
// 生成一些评论
for (const post of posts) {
const randomUser = users[Math.floor(Math.random() * users.length)]
await createRandomComment(post.id, randomUser.id)
}
console.log(`Database seeded with ${users.length} users, ${posts.length} posts!`)
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})
关键点: 这里的 createRandomUser 是异步的。在循环中调用它时,一定要用 await。如果你忘了 await,数据库里可能会有个空的 User 然后报错,因为后续的代码直接尝试操作 user.id,结果发现是个 undefined。
这种写法的好处是,你永远不用担心数据格式对不对,Schema 怎么变,工厂函数里只要改一个地方就行。
第四章:React 测试与数据库的“联姻”
现在,数据库里已经有了真实的数据(虽然名字叫 User_123,但结构是 100% 真实的)。接下来,我们要在 React 组件测试里用上它。
4.1 React Testing Library + MSW (Mock Service Worker) vs. 真实调用
有些老派做法会建议用 MSW(Mock Service Worker)来拦截 HTTP 请求。这很好,但 MSW 需要维护一堆 handlers。
我们的新方案是:直接调用真实的 API(或者模拟的 API 层),但不依赖外部 HTTP 请求。 这样我们测试的是真实的数据转换逻辑。
假设我们有一个 getUsers 的 API 函数:
// src/api/user.ts
import { prisma } from '@/db'
export async function getUsers() {
const users = await prisma.user.findMany({
include: { posts: true }, // 真实包含关系
})
return users
}
然后是我们的组件测试:
// src/components/__tests__/UserList.test.tsx
import { render, screen } from '@testing-library/react'
import UserList from '../UserList'
import { getUsers } from '@/api/user'
// 1. Mock API 函数
jest.mock('@/api/user', () => ({
getUsers: jest.fn(),
}))
describe('UserList Component', () => {
beforeEach(async () => {
// 2. 在每次测试前,先调用 Seed 脚本,确保数据库有数据
// 这里的命令对应 package.json 里的 seed 脚本
await exec('npx prisma db seed')
// 获取种子生成的用户数据
const users = await getUsers()
// 3. Mock 返回真实数据
// 这保证了你的组件拿到的数据,和你数据库里的一致
(getUsers as jest.Mock).mockResolvedValue(users)
})
test('renders a list of users with their posts', async () => {
render(<UserList />)
// 查找页面上的元素
expect(screen.getByText(/User_/i)).toBeInTheDocument()
// 验证数据关系是否正确
// 比如渲染的是 "User_123" 然后下面有 "Post title..."
// 如果 Seed 里没生成 Post,这里就会挂
})
})
这里有一个巨大的优势: 如果你的 Seed 生成了 1000 条数据,测试肯定会慢。所以,在 beforeEach 里,你可以加一个判断:如果数据库是空的,才运行 Seed。
let isDbEmpty = true
beforeEach(async () => {
const count = await prisma.user.count()
if (count === 0) {
await exec('npx prisma db seed')
isDbEmpty = false
}
// ... 其余测试逻辑
})
第五章:清理的艺术 —— 如何优雅地“扫墓”
种子数据是给测试用的,测试跑完,数据就得消失。如果不清除,下一次测试就会混杂着上一次的数据,导致测试结果不可复现。
我们在写 Seed 脚本的时候,通常会把 deleteMany 放在最后。但是在测试环境里,有时候你只想更新某一条数据,不想重置整个数据库。
这时候,我们需要一个更精细的清理策略。
5.1 Truncate vs. Delete
- DELETE:逐行删除。就像拔牙,一颗一颗来,慢,而且会产生大量的日志。
- TRUNCATE:直接清空表,重置自增 ID。就像把文件拖进回收站然后清空,快如闪电,且不记录单行删除日志。
在测试中,如果数据量不大,使用 prisma.$executeRawUnsafe('TRUNCATE TABLE "User", "Post" CASCADE;') 是最快的手段。CASCADE 关键字非常重要,它表示先删子表(Post)的数据,再删主表(User)的数据,防止外键报错。
5.2 自动化清理 Hook
我们可以封装一个函数,在测试文件里随时调用:
export async function cleanDatabase() {
// 先删 Comment(最底层)
await prisma.comment.deleteMany()
// 再删 Post
await prisma.post.deleteMany()
// 再删 User
await prisma.user.deleteMany()
// 重置序列(PostgreSQL 需要这个,不然下次插入 ID 会从 10000 开始)
await prisma.$executeRawUnsafe('TRUNCATE "User", "Post" RESTART IDENTITY CASCADE;')
}
然后,在 beforeEach 里调用它。这就形成了一个闭环:Seed 初始化 -> 测试运行 -> Clean 清理 -> 下一轮测试。
第六章:CI/CD 中的全栈测试流水线
现在的关键问题是:在 GitHub Actions 或者 GitLab CI 里,怎么运行这些复杂的 Seed 脚本?
通常 CI 环境里并没有 Docker Compose。我们有两个选择:
选项 A:在 CI 里启动 Docker 容器
在 .github/workflows/test.yml 里,使用 services 定义数据库服务,然后运行测试。这是最正统的做法,但配置比较繁琐。
选项 B:在 CI 里直接连接数据库(推荐新手)
既然 Docker 有时候太重,我们可以直接让 CI 连接到云数据库。
- CI 环境变量:在 GitHub 设置里加一个
CI_DATABASE_URL。 - 切换环境:在你的测试脚本里判断是否是 CI 环境。
// test-setup.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function setupTestDatabase() {
// 判断是否是 CI 环境
const isCI = process.env.CI === 'true'
const url = isCI ? process.env.CI_DATABASE_URL : process.env.DATABASE_URL
if (!url) throw new Error('Database URL missing')
// 如果是 CI,直接执行 Seed,不需要 reset,因为是空库
if (isCI) {
await exec('npx prisma db seed')
} else {
// 本地开发,重置并 Seed
await exec('npx prisma migrate deploy')
await exec('npx prisma db seed')
}
}
这样,你的 Seed 脚本无论是在本地的 Docker 里跑,还是在云端裸连的数据库里跑,都能工作得完美无缺。
第七章:实战案例 —— 完整的“电商订单”测试
理论讲累了,我们来点刺激的。假设我们要测试一个订单详情页。
这个页面需要展示:
- 订单号。
- 用户的头像和名字。
- 订单里的商品列表(包含商品名称、价格)。
- 商品对应的库存状态。
Schema 定义(简化版):
model Order {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
items OrderItem[]
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String
product Product @relation(fields: [productId], references: [id])
quantity Int
}
model Product {
id String @id @default(cuid())
name String
price Decimal
stock Int
}
Seed 脚本设计:
我们要生成一个复杂的场景:
- 创建一个用户
Alice。 - 创建几个商品,其中
Item 1库存为 0,Item 2库存充足。 - 创建一个订单,包含
Item 1(库存为 0) 和Item 2(库存充足)。
// prisma/seed.ts
async function main() {
// 清理
await prisma.orderItem.deleteMany()
await prisma.order.deleteMany()
await prisma.product.deleteMany()
await prisma.user.deleteMany()
// 1. 创建用户
const alice = await prisma.user.create({
data: { name: 'Alice', email: '[email protected]' }
})
// 2. 创建商品:库存充足的
const goodProduct = await prisma.product.create({
data: { name: 'Super Laptop', price: 999.99, stock: 10 }
})
// 3. 创建商品:库存耗尽的
const soldOutProduct = await prisma.product.create({
data: { name: 'Old Mouse', price: 9.99, stock: 0 }
})
// 4. 创建订单
const order = await prisma.order.create({
data: {
userId: alice.id,
items: {
create: [
{ productId: goodProduct.id, quantity: 1 },
{ productId: soldOutProduct.id, quantity: 5 } // 买了5个没货的
]
}
}
})
console.log(`Order #${order.id} created with ${order.items.length} items`)
}
main()
.then(async () => await prisma.$disconnect())
.catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1) })
React 组件测试:
import { render, screen } from '@testing-library/react'
import OrderDetails from './OrderDetails'
import { getOrder } from '@/api/order'
jest.mock('@/api/order')
describe('OrderDetails Component', () => {
beforeEach(async () => {
await exec('npx prisma db seed')
const order = await getOrder('clxxx123') // 假设 Seed 生成了这个 ID
(getOrder as jest.Mock).mockResolvedValue(order)
})
test('shows product status correctly', async () => {
render(<OrderDetails orderId="clxxx123" />)
// 检查商品名称
expect(screen.getByText('Old Mouse')).toBeInTheDocument()
expect(screen.getByText('Super Laptop')).toBeInTheDocument()
// 检查库存警告!
// 这是一个真实场景:如果库存为0,UI应该显示红色警告
const warning = screen.getByText(/Out of Stock/i)
expect(warning).toBeInTheDocument()
})
})
如果你直接在 Mock 数据里把 stock 改成 0,你可能觉得无所谓。但如果你用了 Seed,系统里的库存逻辑(比如自动扣减、库存锁定机制)都是真实的。如果这里的逻辑没写好,测试就会 Fail,从而帮你发现了一个潜在的 Bug。
第八章:高级技巧与避坑指南
这部分是“老鸟”的经验之谈。别急着去写代码,先听听这些坑。
8.1 不要在生产环境运行 Seed!
这是死罪。Prisma Seed 是用来初始化环境的,不是用来跑生意的。请务必在 package.json 的 scripts 里加个注释,或者使用 --only-if-needed 参数,防止有人手滑在服务器上执行 npm run seed。
8.2 数据一致性的悖论
如果你在 Seed 里随机生成数据,比如随机生成用户的邮箱。那么,测试代码里 expect(screen.getByText('[email protected]')) 就会不稳定。有时候 Alice 的邮箱是 [email protected],有时候是 [email protected]。
解决方案: 在 Seed 里使用固定的种子字符串,或者使用 faker 库配合固定种子。
import { faker } from '@faker-js/faker'
faker.seed(123) // 固定种子,保证每次生成的名字都一样
const user = {
name: faker.person.fullName(),
email: faker.internet.email(),
}
8.3 性能陷阱
如果 Seed 脚本创建了几万条数据,每次测试跑 beforeEach 都要执行一遍,测试速度会慢到让你想砸键盘。
优化方案:
- Docker Volume:如果你在本地跑测试,开启 Docker Volume 挂载,这样数据库的初始化只需要在第一次启动容器时做一次,后续直接复用。
- 选择性清理:不是每次测试都全量清理。有些测试只需要用户,不需要评论。只在全局清理或者依赖关系改变时才全量清理。
第九章:总结与展望
好了,今天的讲座接近尾声。
我们讨论了什么?
我们讨论了如何摆脱脆弱的 JSON Mock,拥抱强大的 Prisma Seed。
我们讨论了如何利用 Docker 和环境隔离,构建全栈测试的一致性数据流。
我们讨论了如何在 React 测试中直接调用真实的数据库逻辑,从而捕捉那些 Mock 数据无法发现的 Bug。
为什么这很重要?
因为代码是逻辑,数据是血肉。Mock 数据只是骨架,而真实数据(即使是种子数据)才是有灵魂的躯干。
当你看到一个组件在测试里因为数据结构稍微变了一点而挂掉时,不要只想着改 Mock。去检查你的 Seed 脚本,去检查你的 Schema,去让你的测试变得强壮。
最后送给大家一句话:
“永远不要相信你的 Mock。相信你的数据库,除非它确实在骗你。”
希望这堂课能让你在未来的全栈开发中,写出更少 Bug、跑得更稳、更自信的代码。谢谢大家!
(如果你能复现上面的代码,并且看到测试通过,记得给自己倒杯咖啡,这可是实打实的进步。)