Prisma 种子(Seeds)与 React 自动化测试:构建全栈测试环境下的一致性数据 Mock 注入方案

别再伪造数据了!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 连接到云数据库。

  1. CI 环境变量:在 GitHub 设置里加一个 CI_DATABASE_URL
  2. 切换环境:在你的测试脚本里判断是否是 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 里跑,还是在云端裸连的数据库里跑,都能工作得完美无缺。


第七章:实战案例 —— 完整的“电商订单”测试

理论讲累了,我们来点刺激的。假设我们要测试一个订单详情页

这个页面需要展示:

  1. 订单号。
  2. 用户的头像和名字。
  3. 订单里的商品列表(包含商品名称、价格)。
  4. 商品对应的库存状态。

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 脚本设计:

我们要生成一个复杂的场景:

  1. 创建一个用户 Alice
  2. 创建几个商品,其中 Item 1 库存为 0,Item 2 库存充足。
  3. 创建一个订单,包含 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.jsonscripts 里加个注释,或者使用 --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 都要执行一遍,测试速度会慢到让你想砸键盘。

优化方案:

  1. Docker Volume:如果你在本地跑测试,开启 Docker Volume 挂载,这样数据库的初始化只需要在第一次启动容器时做一次,后续直接复用。
  2. 选择性清理:不是每次测试都全量清理。有些测试只需要用户,不需要评论。只在全局清理或者依赖关系改变时才全量清理。

第九章:总结与展望

好了,今天的讲座接近尾声。

我们讨论了什么?
我们讨论了如何摆脱脆弱的 JSON Mock,拥抱强大的 Prisma Seed。
我们讨论了如何利用 Docker 和环境隔离,构建全栈测试的一致性数据流。
我们讨论了如何在 React 测试中直接调用真实的数据库逻辑,从而捕捉那些 Mock 数据无法发现的 Bug。

为什么这很重要?

因为代码是逻辑,数据是血肉。Mock 数据只是骨架,而真实数据(即使是种子数据)才是有灵魂的躯干。

当你看到一个组件在测试里因为数据结构稍微变了一点而挂掉时,不要只想着改 Mock。去检查你的 Seed 脚本,去检查你的 Schema,去让你的测试变得强壮。

最后送给大家一句话:
“永远不要相信你的 Mock。相信你的数据库,除非它确实在骗你。”

希望这堂课能让你在未来的全栈开发中,写出更少 Bug、跑得更稳、更自信的代码。谢谢大家!

(如果你能复现上面的代码,并且看到测试通过,记得给自己倒杯咖啡,这可是实打实的进步。)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注