知

模块四 · 性能、测试与表达

第 15 / 17 节19 分钟阅读型

测试与部署:从 Vitest 到 nginx 上线

能测、能构建、能上线,才算一个完整的项目。这一节讲清三层测试的分工、组件测试的写法,以及分包、缓存头与 history fallback 这些上线必踩的坑。

读完这一节,你能做到

  1. 按测试金字塔决定"什么该写单测、什么该写组件测试、什么留给 E2E"。
  2. 用 Vitest + Vue Test Utils 测一个组合式函数与一个组件。
  3. 读懂一次生产构建的产物,做出合理的分包与缓存策略。
  4. 用 nginx 正确部署 history 路由,避免刷新 404。

一、三层测试各管什么

层次工具测什么数量建议运行速度
单元Vitest纯函数、组合式函数、工具库最多毫秒级
组件Vitest + Vue Test Utilsprops 渲染、事件、插槽、交互中等百毫秒
端到端Playwright / Cypress真实浏览器里的关键流程最少秒级

原则是"测试行为,不测试实现":

  • 组合式函数测输入输出,不测内部调用了几个 ref。
  • 组件测"渲染出什么、点了之后发出什么事件",不测内部 data 结构。
  • E2E 只覆盖登录、下单这类高价值主流程,不要用它补组件测试的缺口。

二、组合式函数的单测

组合式函数的好处之一就是可测:不依赖组件实例,直接调用即可。

// src/composables/useCounter.ts
import { ref, computed } from "vue";
export function useCounter(initial = 0, step = 1) {
  const count = ref(initial);
  const double = computed(() => count.value * 2);
  const inc = () => (count.value += step);
  return { count, double, inc };
}
// useCounter.spec.ts
import { describe, it, expect } from "vitest";
import { useCounter } from "@/composables/useCounter";

describe("useCounter", () => {
  it("按步长递增并同步派生值", () => {
    const { count, double, inc } = useCounter(1, 2);
    inc();
    expect(count.value).toBe(3);
    expect(double.value).toBe(6);
  });
});

三个要点:

  • 组合式函数里如果用到了 onMounted 这类生命周期,必须在组件上下文里调用,否则会有警告;这类函数要用组件测试而不是纯单测。
  • 依赖 inject 的函数要传默认值或在测试里 provide。
  • 涉及 nextTick 的断言要 await nextTick(),不要用 setTimeout。

三、组件测试:只关心契约

import { mount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import OrderRow from "@/components/OrderRow.vue";

describe("OrderRow", () => {
  const order = { id: "1", no: "SO-1", amount: 99, status: "paid" };

  it("渲染单号与金额", () => {
    const wrapper = mount(OrderRow, { props: { order } });
    expect(wrapper.text()).toContain("SO-1");
    expect(wrapper.text()).toContain("99");
  });

  it("点击退款按钮时发出事件并带上 id", async () => {
    const wrapper = mount(OrderRow, { props: { order } });
    await wrapper.get('[data-test="refund"]').trigger("click");
    expect(wrapper.emitted("refund")?.[0]).toEqual(["1"]);
  });

  it("已退款状态不显示退款按钮", () => {
    const wrapper = mount(OrderRow, {
      props: { order: { ...order, status: "refunded" } },
    });
    expect(wrapper.find('[data-test="refund"]').exists()).toBe(false);
  });
});

五条实践:

  1. 用 data-test 选择元素,不要依赖 class 与层级,样式一改测试就碎。
  2. 异步操作后 await:Vue 的更新是异步的,trigger 之后不 await 会读到旧 DOM。
  3. 断言事件用 emitted,不要断言父组件状态。
  4. 插槽用 slots 传入,验证渲染位置而不是字符串包含。
  5. 需要真实网络时用 MSW 或 vi.mock,不要 mock fetch 的返回值细节,那是在测实现。

覆盖率不是目标。80% 的行覆盖率里如果全是 getter,价值为零;关键判断分支(空态、错误态、权限)覆盖到比数字更重要。

四、构建:产物里有什么

跑完 npm run build,dist/ 通常包含:

dist/
├─ index.html                 # 入口,引用带 hash 的 JS/CSS
└─ assets/
   ├─ index-a1b2c3.js         # 业务代码
   ├─ vendor-4d5e6f.js        # 依赖(若手动分包)
   ├─ OrderList-7g8h9i.js     # 路由懒加载的 chunk
   └─ index-j1k2l3.css

三条分包策略:

策略做法适用
按路由component: () => import(...)默认就该做
按依赖manualChunks 把 vue、图表库单独拆出依赖体积大且更新频率低
按体积把 >200KB 的重组件改成异步富文本、图表、地图
build: {
  rollupOptions: {
    output: {
      manualChunks: {
        vue: ["vue", "vue-router", "pinia"],
        charts: ["echarts"],
      },
    },
  },
}

注意:分包不是越细越好。拆成几十个 chunk 会让请求数上升,HTTP/2 下影响小,但在弱网下仍会拖慢首屏。原则是把"很少变"和"经常变"分开,让缓存命中率最大化。

五、缓存头:比分包更容易被忽略

资源缓存策略原因
index.htmlno-cache(协商缓存)必须能拿到最新入口
assets/*.[hash].jsmax-age=31536000, immutable文件名带 hash,内容变则名变
图片/字体长缓存同上,靠 hash 或版本目录
API 响应业务决定不要用前端静态缓存策略

最常见的事故是"发了新版本,用户仍是旧页面":因为 index.html 被 CDN 缓存了。规则只有一条:入口 HTML 永不长缓存,带 hash 的静态资源永久缓存。

六、部署:history 路由必须配 fallback

createWebHistory() 下,用户直接访问 /orders/1 时,服务器会去找 orders/1 这个文件,找不到就 404。解决办法是把未命中的路径回退到 index.html:

server {
  listen 80;
  server_name example.com;
  root /var/www/intervue;
  index index.html;

  location /assets/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }

  location = /index.html {
    add_header Cache-Control "no-cache";
  }

  location / {
    try_files $uri $uri/ /index.html;
  }

  gzip on;
  gzip_types text/css application/javascript application/json image/svg+xml;
}

逐段解释:

  • location /assets/ 长缓存,靠文件名 hash 保证更新。
  • location = /index.html 关掉长缓存,保证发版后能拿到新入口。
  • try_files 是 fallback 的核心:文件存在就返回,否则交给 index.html 由前端路由接管。
  • gzip 至少要覆盖 JS/CSS/JSON,文本资源能压到 1/3 左右。

如果用了 hash 路由,则不需要 fallback,但代价是 URL 难看、SEO 不友好。

七、上线检查清单

  1. npm run build 无类型错误、无警告。
  2. npm run preview 本地验证产物,重点看动态 import 与静态资源路径。
  3. 环境变量确认:生产用的 VITE_API_BASE 不是测试地址。
  4. index.html 缓存头为 no-cache。
  5. history 路由 fallback 已配,直接访问深层链接不 404。
  6. sourcemap 策略明确:对内的错误监控需要,对外发布则不要暴露源码。
  7. 首屏资源体积与 LCP 有基线数字,发版后对比。

八、常见坑

  1. 只写组件测试,组合式函数零覆盖,重构时没有任何保护。
  2. 测试里断言 class 名与 DOM 层级,改样式就红一片。
  3. trigger 后不 await,断言到旧 DOM。
  4. 用 vi.mock 把整个请求模块替换掉,测试通过但线上接口改了没人知道。
  5. 分包过细,首屏请求数翻倍。
  6. index.html 被 CDN 长缓存,发版后用户看到旧页面。
  7. history 路由没配 fallback,刷新 404。
  8. 生产环境保留 sourcemap 并公开可访问,源码泄露。

九、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 给一个组合式函数写 3 个用例,覆盖正常、边界与异常输入。
  2. 给一个列表项组件写测试:渲染、点击发事件、空态不渲染按钮。
  3. 用 manualChunks 把 vue 与图表库拆开,对比构建前后 chunk 数量与首屏体积。
  4. 写一份 nginx 配置,部署后验证:首页、深层链接刷新、静态资源缓存头三项。

② 让 AI 写同一题

把第 2 题(组件测试)交给 AI,补一句 用 @vue/test-utils 按可访问名称查询,不测内部实现。

③ 对比要盯的点

  • 它 mock 掉 composable 了吗?mock 了就等于没测组件。
  • 断言是“某段文字出现”,还是“某个 class 存在”?后者绑死了实现。
  • nginx 配置它给 history 模式 fallback 了吗?不给就一刷新 404。

④ 一句话取舍

只改实现、不改行为时,它写的测试会红吗?

十、自测

  1. 三层测试各自适合测什么?
  2. 组件测试为什么必须 await trigger?
  3. 为什么 index.html 不能长缓存?
  4. history 模式刷新 404 的根因是什么?
  5. 分包的原则是什么?

参考答案要点:单元测逻辑、组件测契约、E2E 测主流程;Vue 更新是异步的;入口文件名不变,长缓存会锁死旧版本;服务器把路径当文件找;把少变与常变分开。

附:CI 流水线的最小结构

本地能跑不等于能上线。一个够用的流水线只要六步,顺序不能乱:

1. 安装依赖(严格按锁文件,缓存依赖目录)
2. lint(eslint + prettier --check)
3. 类型检查(vue-tsc --noEmit)
4. 单元测试(vitest run)
5. 构建(vite build)
6. 产物归档 / 部署

三条原则:

  • 先快后慢:lint 与类型检查最便宜,放在最前面,几秒钟就能拦下大部分问题。
  • 失败即停:第 3 步失败就不要跑第 5 步,浪费机器时间也掩盖首个错误。
  • 缓存依赖:把包管理器的全局缓存目录挂上,命中后安装从分钟级降到秒级。

E2E 放不放进来取决于团队:主流程 E2E 建议在合并前跑一次,而不是每次提交都跑。可以让它依赖“预览环境部署成功”这一事件,既真实又稳定。

还有一条容易被忽略:构建产物体积要设预算。在 CI 里对比本次与主干的 chunk 体积,超过阈值就提示,而不是等上线后才发现首屏多了 300KB。预算写进流水线,比写在文档里有效得多。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解