服务端面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 05月27日 20:20

Deno 生态有哪些主流库和工具?

Deno 2.x 的生态已从早期的 deno.land/x 过渡到 JSR(JavaScript Registry)作为主力包注册表,同时通过 npm: 前缀直接引用约 98% 的 npm 包。Web 框架首选 Hono(轻量、跨 Deno/Bun/Cloudflare Workers 运行时)和 Oak(Koa 风格中间件),Fresh 是官方全栈框架但社区活跃度一般。数据库层 Drizzle ORM 是目前最成熟的 TypeScript ORM,支持 PostgreSQL/MySQL/SQLite。Deno KV 是内置键值存储,零依赖适合轻量场景。认证用 djwt 处理 JWT,@deno/kv-oauth 做 OAuth2。工具链全部内置——fmt、lint、test、compile、doc 一条命令搞定,Deno 2 还新增了 pack、bump-version、ci 等子命令。部署用 Deno Deploy,免费额度每天 10 万请求、35+ 边缘节点零冷启动。追问JSR 和 npm 有什么区别?JSR 是 Deno 主导的 TypeScript 优先注册表,源码直接发 TS 无需编译,自动为 npm 生态生成兼容包。npm 以 CommonJS/编译后 JS 为主。Deno 两个都能用:JSR 用 jsr: 前缀,npm 用 npm: 前缀。新项目优先发 JSR,兼容两边的开发者。Hono 和 Oak 怎么选?Hono 更轻量、跨运行时,中间件生态丰富,适合 API 和边缘计算场景。Oak 是 Deno 专属框架,API 接近 Koa 学习成本低,但生态不如 Hono。新项目建议 Hono,已有 Koa 经验的团队可以快速上手 Oak。Deno KV 能替代 Redis 吗?轻量场景可以:键值读写、原子操作、版本控制都支持,零依赖开箱即用。但不支持 TTL 自动过期、没有 pub/sub、查询只有前缀扫描。需要过期策略或发布订阅时还是得接 Redis。从 Node.js 迁移到 Deno 难吗?Deno 2 已支持 ~98% 的 npm 包,大部分依赖加 npm: 前缀就能跑。主要差异:文件系统用 Deno.readTextFile 而非 fs.readFile;运行时需要 --allow-net 等权限标志。依赖 sharp、bcrypt 等 Node 原生模块的项目可能有兼容问题,建议先用 deno info 检查依赖树。Deno Deploy 和传统服务器部署哪个好?Deno Deploy 适合无状态 API 和边缘计算,全球 35+ 节点低延迟,免费额度 generous。限制:WebSocket 连接有超时、无持久文件系统、不支持长驻进程。传统 VPS/容器部署更灵活,适合需要完整运行时的项目。两者不冲突,API 层 Deploy + 重计算层 VPS 是常见搭配。
服务端阅读 05月27日 20:17

Deno 的部署和运维有哪些最佳实践?

核心实践概览Deno 生产部署的关键在于:容器化打包、权限最小化、健康检查三板斧,以及选择合适的部署平台。面试中考察的重点不是你会写多少 Dockerfile,而是你能否说清每个配置背后的取舍。容器化部署怎么选?Deno 官方提供 denoland/deno 镜像,生产环境推荐多阶段构建:先用 deno compile 将 TypeScript 编译为单文件二进制,再用精简基础镜像(如 debian:slim)运行。这样做的好处是最终镜像小、启动快、不暴露源码。注意:Deno 2.x 已发布,不要再写 denoland/deno:1.38.0 这种过时版本。编译命令也有所变化,建议查阅最新文档。容器内务必以非 root 用户运行,Dockerfile 末尾加 USER deno 是基本操作。权限控制为什么重要?Deno 和 Node.js 最大的架构差异就是默认安全。生产环境中坚决不用 -A(全量授权),而是按需授予:网络访问用 --allow-net=api.example.com 限定域名文件读取用 --allow-read=/app/data 限定路径环境变量用 --allow-env=PORT,DB_URL 限定变量名面试中如果只答出 --allow-net 这种粗粒度权限,说明你没在生产环境用过 Deno。部署平台如何选择?三种主流方案:Deno Deploy:官方边缘计算平台,零配置部署,适合轻量 API 和 SSR 应用。2026 年 2 月已 GA,Classic 版将在 7 月下线,需要迁移到新平台。容器化自建:Docker + K8s,适合需要精细控制或有复杂依赖的场景。配置资源限制(requests/limits)、存活探针和就绪探针是基本要求。PaaS 托管:Railway、Vercel 等平台,适合快速上线,但定制空间有限。选型依据:流量规模、延迟要求、团队运维能力。不要为了用 Deno Deploy 而用,数据库连接密集型场景自建集群更稳。健康检查和监控怎么做?健康检查端点是生产标配:/health 返回进程存活状态,用于 K8s livenessProbe/ready 返回服务就绪状态(依赖是否连上),用于 readinessProbe监控方面,结构化日志(JSON 格式)比 console.log 更有利于日志平台解析。Deno 2.x 支持 Deno.memoryUsage() 获取内存指标,配合定时上报可以排查内存泄漏。CI/CD 有什么坑?Deno 项目的 CI 流程比 Node.js 简单——不需要 npm install,deno cache 一步搞定依赖。但要注意:deno lint 和 deno fmt --check 必须加,Deno 内置了这些工具没有理由不用Docker 构建时先 COPY deno.json 再 deno cache,利用层缓存加速构建镜像 tag 用 git SHA 而不是 latest,否则回滚时找不到版本追问方向Deno Deploy 和 Cloudflare Workers 在架构上有什么区别?(冷启动、运行时限制、KV 存储)deno compile 编译出的二进制在 Alpine 镜像里能直接跑吗?(不能,Alpine 用 musl,需要静态编译或用 glibc 镜像)Deno 的权限系统在容器内被绕过怎么办?(容器本身是安全边界,Deno 权限是应用层防护,两者互补)
服务端阅读 05月27日 20:14

Consul 生产环境部署和运维有哪些关键要点?

Consul 生产部署的核心难点在于保证集群高可用的同时兼顾安全与性能。以下是实战中必须关注的要点。集群架构:3-5 个 Server 节点是底线Server 节点数量必须是奇数(3 或 5),因为 Consul 使用 Raft 协议,需要多数派达成共识才能提交写入。3 节点容忍 1 台宕机,5 节点容忍 2 台。Server 节点应跨可用区部署,避免单机房故障导致整体不可用。Client 节点与业务同机部署,负责本地健康检查和请求转发,单个数据中心建议不超过 5000 个 Client。安全配置:TLS + ACL + Gossip 加密缺一不可生产环境必须启用三项安全机制:RPC 通信走 TLS 双向认证,Gossip 协议使用 encryptkey 加密,ACL 默认策略设为 deny 并按最小权限分配 Token。Bootstrap Token 权限极大,务必妥善保管,类似数据库 root 密码。启用 ACL 后注意开启 enabletoken_persistence,避免节点重启后 Token 丢失导致集群通信中断。性能调优:关注磁盘 IO 和 Raft 参数Consul 写入性能主要受磁盘 IO 制约,Server 节点务必使用 SSD。关键参数 raftmultiplier 建议设为 1(最高性能模式),默认值 5 会让选举超时时间偏长,在低延迟内网环境下没有必要。snapshotinterval 可从默认 1 天缩短到 30s,避免 Raft 日志无限增长。读多写少的场景可将一致性模式设为 stale,让所有 Server 都能响应查询,减轻 Leader 压力。常见故障:Leader 丢失和服务注册不同步Leader 频繁选举通常是因为节点间网络不稳定或磁盘 IO 阻塞了 Raft 心跳。排查时先用 consul operator raft list-peers 确认集群成员状态,再检查网络延迟和磁盘 iops。服务注册后其他节点看不到,多半是 Client Agent 的缓存未刷新,可通过 DNS stale 读或适当降低 stalereadtime 缓解。Agent 宕机后其上的健康检查不会被其他节点接管,这是 Consul 的设计限制,需通过外部监控补偿。追问:滚动更新时如何避免 no leader 错误?调整 leavedraintime(默认 5s)让 Server 在优雅退出时留出缓冲时间,配合 rpcholdtimeout(默认 7s)使客户端在选举期间自动重试,基本可以消除滚动更新导致的瞬时不可用。
服务端阅读 05月27日 20:14

如何使用 deno compile 编译可执行文件?

什么是 deno compile?deno compile 是 Deno 内置的编译工具,能把 TypeScript/JavaScript 代码连同运行时一起打包成独立可执行文件。目标机器不需要安装 Deno,直接运行即可。它的底层原理是把代码和依赖绑定为 eszip 格式,再注入到精简版 Deno 运行时(denort)二进制中——所以它并不是真正编译成机器码,而是"打包+嵌入"。基本编译命令deno compile --allow-net --allow-read app.ts编译时必须指定权限标志,运行时无法再修改。输出文件默认与源文件同名,可用 --output 自定义:deno compile --allow-net --output=myapp app.ts交叉编译与目标平台一条命令即可编译到其他平台:deno compile --target=x86_64-unknown-linux-gnu --output=myapp-linux app.tsdeno compile --target=aarch64-apple-darwin --output=myapp-mac app.tsdeno compile --target=x86_64-pc-windows-msvc --output=myapp.exe app.ts这在 CI/CD 中做批量发布时很实用,不用准备多台构建机器。三个注意事项文件体积大。 编译产物通常 50-100 MB,因为包含了完整 V8 引擎和 Deno 运行时。对体积敏感的场景可用 upx 压缩,但稳定性需自行验证。动态导入不会自动打包。 静态分析能识别的动态 import 会被包含,但运行时拼装的路径不会。需要用 --include 显式声明:deno compile --include=./plugins/plugin.ts app.tsWeb Worker 的代码同理,也需 --include 手动加入。权限编译时锁定。 --allow-net 等标志在编译时写死,运行时无法突破,也无法降级。这意味着如果将来需要新权限,必须重新编译。deno compile 与 Node.js 打包工具对比| 维度 | deno compile | pkg (Node) | nexe (Node) ||------|-------------|------------|-------------|| 配置复杂度 | 零配置,一条命令 | 需要 package.json 和配置 | 需要配置和构建脚本 || 跨平台编译 | 原生支持 --target | 有限支持 | 有限支持 || 产物体积 | 50-100 MB | 40-80 MB | 40-70 MB || 维护状态 | Deno 官方维护 | 社区维护,更新慢 | 社区维护,更新慢 |deno compile 的核心优势不在体积,而在于零配置和官方长期维护。面试追问方向deno compile 的产物为什么体积大?能否压缩?——因为嵌入了 V8 和 denort,upx 可压缩但有兼容风险。动态导入的模块为什么不会被打包?如何解决?——编译器只做静态分析,运行时才能确定路径的 import 会被跳过,用 --include 显式包含。编译时锁定的权限是否意味着安全性更强?——是,但也丧失了灵活性,需根据场景取舍。
服务端阅读 05月27日 20:12

Deno 如何处理模块导入和依赖管理?

核心答案Deno 采用 URL 直接导入模块,没有 package.json 和 node_modules,依赖管理通过 Import Maps、deps.ts 文件或 deno.json 的 imports 字段集中管理,模块全局缓存于本地。导入方式Deno 支持 URL 导入、相对路径导入和 Import Maps 别名导入三种方式:// URL 直接导入(指定版本)import { serve } from "https://deno.land/std@0.208.0/http/server.ts";// 相对路径导入(必须带扩展名)import { utils } from "./utils.ts";// 通过 Import Maps 使用别名import { Application } from "oak";Import Maps 在 deno.json 中配置,可将 URL 映射为简短别名,是目前推荐的做法。依赖管理方案早期 Deno 推荐 deps.ts 模式——将所有远程依赖集中到一个文件重新导出,应用代码只从 deps.ts 导入。现在更推荐使用 deno.json 的 imports 字段,本质上是标准 Import Maps:{ "imports": { "oak": "jsr:@oak/oak@^12.6.1", "std/": "https://deno.land/std@0.208.0/" }}JSR(jsr.io)是 Deno 推出的现代包注册表,支持 TypeScript 原生发布,配合 deno add jsr:@oak/oak 直接安装依赖。与 Node.js 的关键区别无 node_modules:模块下载后全局缓存,不污染项目目录无 package.json:用 deno.json 或 deps.ts 替代必须带扩展名:本地导入必须写 .ts,与浏览器行为一致权限控制:导入远程模块需要 --allow-net,运行时受沙箱约束版本锁定deno.lock 文件记录依赖的精确版本和完整性哈希,类似 npm 的 package-lock.json。CI 环境中用 deno install --frozen 确保依赖不可变。追问:URL 导入有什么安全隐患?如何规避?URL 导入指向的代码可能被篡改(供应链攻击)。规避方式:始终锁定版本号、使用 lock 文件校验哈希、优先从 JSR 等可信注册表安装、配置 DENO_AUTH_TOKENS 访问私有仓库。
服务端阅读 05月27日 20:12

Deno 标准库有哪些常用模块?

核心回答Deno 标准库(@std)已稳定至 v1,现通过 JSR 分发,包含 37 个独立包。最常用的有:@std/fs(文件系统)、@std/path(路径处理)、@std/http(HTTP 服务)、@std/assert(断言测试)、@std/async(异步工具)、@std/encoding(编解码)、@std/collections(集合操作)、@std/fmt(格式化输出)。导入方式已从 deno.land/std 迁移至 JSR:// 旧方式(已不推荐)import { serve } from "https://deno.land/std@0.208.0/http/server.ts";// 新方式(JSR)import { serve } from "@std/http";模块详解@std/fs 提供 ensureDir、copy、walk 等文件操作,比手动调用 Deno API 更安全——自动处理目录不存在等边界情况。@std/path 等价于 Node.js 的 path 模块,提供 join、resolve、basename 等,跨平台兼容。@std/http 用于快速搭建 HTTP 服务,serve() 接收 Request/Response 标准对象,无需第三方框架。@std/assert 是 Deno 测试的标配断言库,assertEquals、assertThrows 覆盖绝大多数场景。@std/async 提供 delay、retry、debounce 等异步工具,retry 支持指数退避策略。@std/encoding 处理 base64、hex、varint 等编解码,是网络和存储场景的基础依赖。注意事项部分模块仍标记为 UNSTABLE(0.x 版本),如 @std/log、@std/datetime、@std/io,生产环境慎用。标准库各包独立版本管理,成熟度不同,导入前需确认稳定状态。追问方向Deno 标准库和 Node.js 内置模块相比,设计理念有什么不同?(提示:ESM-only、无全局污染、独立版本)@std/http 能否替代 Oak 等框架?什么场景下需要引入第三方框架?(提示:路由、中间件、请求校验)标准库模块的 UNSTABLE 标记意味着什么?如何判断能否用于生产?(提示:语义化版本、JSR 标注)
服务端阅读 05月27日 20:11

什么是ERC-20代币标准?

核心答案ERC-20是以太坊上同质化代币的接口标准(EIP-20),由Fabian Vogelsteller于2015年提出。它规定了6个必须方法和2个事件,保证所有代币合约与钱包、DApp、交易所兼容。必须实现的6个方法:totalSupply() — 返回代币总量balanceOf(address) — 查询某地址余额transfer(address, uint256) — 直接转账approve(address, uint256) — 授权第三方使用额度allowance(address, address) — 查询已授权额度transferFrom(address, address, uint256) — 用授权额度代为转账2个必须事件:Transfer 和 Approval。可选方法:name()、symbol()、decimals()(默认18)。生产环境推荐继承OpenZeppelin的ERC20合约,不要自己写底层逻辑。approve的竞态问题先approve(A, 100),再改成approve(A, 50),A在第二笔交易上链前用transferFrom转走100,然后第二笔生效后A还能再转50——总共150而非预期的50。解法:先approve(spender, 0)再设新值,或用OpenZeppelin的SafeERC20。为什么没有接收回调ERC-20转账时合约不会通知接收方,代币可能被锁死在合约里。ERC-223和ERC-777通过tokensReceived钩子解决了这个问题,但兼容性风险导致主流仍用ERC-20。向合约转账前务必确认它实现了IERC20Receiver。与其他标准的区别ERC-721是NFT标准,每个token唯一;ERC-1155用单合约管理多类代币,省Gas;ERC-4626是金库代币标准,用于收益聚合。ERC-20只管同质化代币,是最基础也是应用最广的标准。面试追问方向transfer和transferFrom的区别和典型使用场景?decimals为什么默认18?跟wei的精度有什么关系?用OpenZeppelin发一个可铸造、可销毁的ERC-20要继承哪些模块?
服务端阅读 05月27日 20:11

Deno 的权限系统是如何工作的?

Deno 采用"默认拒绝"的安全模型——脚本启动时没有任何权限,必须通过命令行标志显式授权才能访问文件、网络、环境变量等资源。这套权限系统是 Deno 区别于 Node.js 的核心安全特性。核心机制权限以 --allow-* 标志授予,支持通配和精确指定两种模式:# 通配:允许所有网络访问deno run --allow-net app.ts# 精确:只允许访问指定域名deno run --allow-net=api.example.com app.ts主要权限标志包括 --allow-read、--allow-write、--allow-net、--allow-env、--allow-run、--allow-sys、--allow-hrtime 和 --allow-ffi,均可通过 = 指定白名单。Deno 1.36 起还支持 --deny-* 黑名单,优先级高于 --allow-*,可在宽泛授权下排除特定资源。运行时权限查询代码中可通过 Deno.permissions API 检查和请求权限:const status = await Deno.permissions.query({ name: "net" });// status.state → "granted" | "prompt" | "denied"const req = await Deno.permissions.request({ name: "read", path: "/tmp" });// 运行时弹出交互提示也可调用 Deno.permissions.revoke() 主动放弃已获权限,实现最小权限的动态收缩。子进程权限的陷阱--allow-run 授予的子进程不在 Deno 沙箱内运行,它继承宿主系统的完整权限,不受 Deno 权限标志约束。因此只应允许运行明确可信的命令,如 --allow-run=git,curl。与 Node.js 的关键区别Node.js 默认拥有全部系统权限,依赖 fs 模块即可读写任意文件,安全边界完全依赖操作系统层面。Deno 则从运行时层面强制权限隔离,每个资源访问请求都需经过权限检查,恶意依赖无法静默越权。追问权限白名单和黑名单同时存在时,哪个优先? --deny-* 优先。即使 --allow-read=/app 已授权,--deny-read=/app/secret 仍会阻止对该目录的访问。如何在 CI 中自动处理权限提示? 使用 --no-prompt 标志,未授权的访问直接抛出 PermissionDenied 错误而非交互提示,适合自动化流水线。
服务端阅读 05月27日 20:04

Hardhat 如何支持 TypeScript 和类型安全?

Hardhat 对 TypeScript 的支持不是"能用"级别,而是"原生级"。初始化项目时直接选 TypeScript 模板,配置文件、部署脚本、测试文件全部 .ts,编译合约后还能自动生成类型定义——你调用合约方法时,编辑器会告诉你参数类型对不对、返回值是什么。智能合约一旦部署上链就很难改,类型检查能在编译阶段把低级错误拦住,这个价值不需要多解释。下面从项目搭建到实际开发,把 Hardhat + TypeScript 的完整链路走一遍。项目初始化:选 TypeScript 模板mkdir my-project && cd my-projectnpm init -ynpx hardhat init# 选择 "Create a TypeScript project"Hardhat 会自动生成 hardhat.config.ts、tsconfig.json,并安装必要的 TypeScript 依赖:npm install --save-dev ts-node typescript @types/node @types/mocha如果你用的是 @nomicfoundation/hardhat-toolbox(推荐),这些依赖已经包含在内,不用手动装。一个默认生成的 hardhat.config.ts 长这样:import { HardhatUserConfig } from "hardhat/config";import "@nomicfoundation/hardhat-toolbox";const config: HardhatUserConfig = { solidity: "0.8.24",};export default config;HardhatUserConfig 这个类型会帮你检查配置项有没有写错——比如把 solidity 拼成 solididy,TypeScript 直接报红。Hardhat Toolbox:一个包装搞定类型安全@nomicfoundation/hardhat-toolbox 是 Hardhat 官方推荐的插件合集,包含了 TypeChain、Ethers.js、Chai 匹配器等,装一个包就把类型安全的环境搭好:npm install --save-dev @nomicfoundation/hardhat-toolbox在 hardhat.config.ts 中引入后,执行编译:npx hardhat compile你会看到类似输出:Compiled 1 Solidity file successfullyGenerating typings for: 1 artifacts in dir: typechain-types for target: ethers-v6Successfully generated 3 typings!这就是 TypeChain 在工作——它读取合约编译产出的 ABI,自动生成 TypeScript 类型定义文件,放在 typechain-types/ 目录下。合约交互:从"盲调"到"类型安全"假设你有一个简单的 Solidity 合约:// contracts/Lock.sol// SPDX-License-Identifier: UNLICENSEDpragma solidity ^0.8.24;contract Lock { uint public unlockTime; address public owner; constructor(uint256 _unlockTime) payable { require(block.timestamp < _unlockTime, "Unlock time should be in the future"); unlockTime = _unlockTime; owner = msg.sender; }}没有 TypeChain 时你用 JavaScript 或裸 TypeScript 调用合约方法,没有任何类型提示:// 没有类型安全——参数类型、返回值全靠猜const lock = await ethers.getContractAt("Lock", address);const time = await lock.unlockTime(); // 返回什么类型?不知道有 TypeChain 后import { Lock } from "../typechain-types";const LockFactory = await ethers.getContractFactory("Lock");const lock = await LockFactory.deploy(futureTimestamp) as Lock;// 编辑器自动补全,参数类型和返回值都有提示const time: bigint = await lock.unlockTime();const owner: string = await lock.owner();区别很明显:unlockTime() 返回 bigint 而不是 any,owner() 返回 string——如果后续代码把 owner 当数字用,编译阶段就能发现。测试中的类型安全Hardhat 的 TypeScript 项目用 Mocha + Chai + Ethers.js 做测试,配合 TypeChain 生成的类型,测试代码也能享受完整的类型检查:import { expect } from "chai";import { ethers } from "hardhat";import { Lock } from "../typechain-types";import { time } from "@nomicfoundation/hardhat-network-helpers";describe("Lock", function () { let lock: Lock; beforeEach(async function () { const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60; const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS; const LockFactory = await ethers.getContractFactory("Lock"); lock = await LockFactory.deploy(unlockTime, { value: ethers.parseEther("1") }); }); it("should set the right unlockTime", async function () { const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60; const expectedTime = BigInt(await time.latest()) + BigInt(ONE_YEAR_IN_SECS); expect(await lock.unlockTime()).to.be.closeTo(expectedTime, 2n); });});注意 lock 变量的类型是 Lock,不是 any——你在测试里调一个不存在的方法,TypeScript 会直接报错,不用等运行时才发现拼写错误。跑测试时加 --typecheck 可以在执行前做一轮完整类型检查:npx hardhat test --typecheck建议在 CI 或 pre-commit hook 里加上这个标志,确保类型问题不会溜进代码库。配置文件的类型安全hardhat.config.ts 本身就是类型安全的大本营。HardhatUserConfig 类型会约束你写正确的配置结构:import { HardhatUserConfig } from "hardhat/config";import "@nomicfoundation/hardhat-toolbox";const config: HardhatUserConfig = { solidity: { version: "0.8.24", settings: { optimizer: { enabled: true, runs: 200, }, }, }, networks: { sepolia: { url: process.env.SEPOLIA_RPC_URL || "", accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], }, },};export default config;如果你把 optimizer.enabled 写成 "yes" 而不是 true,TypeScript 立刻报错。这种在配置层面的类型保护,避免了"部署到测试网怎么都不对,最后发现是配置拼错"的尴尬。环境变量的类型定义在项目根目录创建 hardhat.config.d.ts,给环境变量加类型:declare namespace NodeJS { interface ProcessEnv { SEPOLIA_RPC_URL: string; PRIVATE_KEY: string; ETHERSCAN_API_KEY: string; }}这样 process.env.SEPOLIA_RPC_URL 在编辑器里就不会被推断为 string | undefined,省去到处写 ! 非空断言。部署脚本的类型安全部署脚本是类型安全最容易出现缺口的地方。正确的做法是给部署脚本加上类型:// scripts/deploy.tsimport { ethers } from "hardhat";import { Lock } from "../typechain-types";async function main() { const unlockTime = Math.floor(Date.now() / 1000) + 60 * 60; // 1 小时后 const LockFactory = await ethers.getContractFactory("Lock"); const lock: Lock = await LockFactory.deploy(unlockTime, { value: ethers.parseEther("0.001"), }); await lock.waitForDeployment(); console.log("Lock deployed to:", await lock.getAddress());}main().catch((error) => { console.error(error); process.exitCode = 1;});waitForDeployment() 是 ethers v6 的写法——v5 用的是 deployed(),已经在 v6 中被移除。如果你在网上抄到 v5 的代码直接用,TypeScript 会直接报方法不存在,这恰好是类型安全帮你挡住的一个常见坑。常见踩坑和解决方案1. 全局变量 vs 显式导入JavaScript 项目里 Hardhat 会把 ethers 等对象注入全局作用域,但 TypeScript 项目必须显式导入:// ✅ 正确import { ethers } from "hardhat";// ❌ TypeScript 中不存在全局 ethers如果你从 JS 项目迁移过来,记得把所有全局引用改成 import。2. ethers v5 和 v6 的写法差异2024 年后 hardhat-toolbox 默认使用 ethers v6,关键差异:| 操作 | ethers v5 | ethers v6 ||------|-----------|-----------|| 等待部署完成 | await contract.deployed() | await contract.waitForDeployment() || 获取合约地址 | contract.address | await contract.getAddress() || 解析 ETH 单位 | ethers.utils.parseEther("1") | ethers.parseEther("1") || BigInt 转换 | value.toNumber() | Number(value) 或直接用 bigint |如果项目里混用了 v5 和 v6 的写法,TypeScript 的类型检查会帮你发现不兼容的调用——前提是你装的是 v6 版本的类型定义。3. TypeChain 生成文件要不要提交到 Git建议把 typechain-types/ 加入 .gitignore,让它在每次编译时重新生成。这样合约改动后类型定义总是最新的,不会出现代码和类型不同步的问题。4. 类型检查只在显式请求时执行Hardhat 默认运行任务时不做类型检查(为了速度)。你可以在 hardhat.config.ts 中设置 typechain.target 确保生成正确版本,但类型检查需要手动触发:# 单独跑类型检查npx hardhat compile && npx tsc --noEmit# 或在测试时加上 --typechecknpx hardhat test --typecheck在 CI 流水线里加一个 tsc --noEmit 步骤,能确保每次提交都不会引入类型错误。5. Hardhat Runtime Environment 的类型扩展如果你装了第三方插件,hre 上可能缺少类型声明。可以通过模块扩展补上:// hardhat.config.tsimport "hardhat/types/runtime";declare module "hardhat/types/runtime" { interface HardhatRuntimeEnvironment { myCustomPlugin: { doSomething: () => Promise<void>; }; }}但要注意,随意扩展类型容易和其他插件冲突,只在确实需要时才这么做。TypeScript vs JavaScript:值不值得切?简单对比一下在 Hardhat 项目中的实际体感:| 方面 | JavaScript | TypeScript ||------|-----------|------------|| 合约调用 | 返回 any,参数类型靠记忆 | 自动补全 + 类型检查 || 配置错误 | 运行时才报错 | 编译时直接标红 || 重构合约 | 全局搜索替换,容易遗漏 | 改一处,引用处全部报错 || 团队协作 | 看注释或源码才知道参数含义 | 类型和注释一体化 || 学习成本 | 低 | 需要理解类型系统,但 Hardhat 模板已配好 |对于新项目,没有理由不用 TypeScript——Hardhat 的模板已经帮你把基础设施搭好了,额外成本几乎为零。老项目迁移需要一点工作量,主要是加 import 和类型声明,但迁移完成后维护体验明显提升。总结一句话:Hardhat 的 TypeScript 支持不是锦上添花,是标配。从项目初始化到合约交互、测试、部署,全链路都有类型保护。唯一需要留意的是 ethers v5/v6 的 API 差异,以及显式导入 vs 全局变量的区别——搞清楚这两点,剩下的跟着模板走就行。
服务端阅读 05月27日 20:04

Gin 框架中的并发处理和 goroutine 管理是什么?

Gin 框架中如何处理并发请求?Gin 基于 Go 标准库 net/http 构建,每一个 HTTP 请求都会由 Go 的 HTTP Server 自动分配一个独立的 goroutine 来处理。这意味着 Gin 本身就是并发安全的——不同请求之间不会互相阻塞。但当你需要在请求处理过程中自己启动额外的 goroutine 时,就需要格外小心了。核心问题在于:Gin 使用 sync.Pool 复用 gin.Context 对象。当 handler 函数返回后,Context 会被回收到池中,可能被下一个请求复用。如果你在子 goroutine 中直接引用原始 Context,就会出现数据竞争甚至 panic。在 handler 中启动 goroutine 时为什么必须用 c.Copy()?先看一段有问题的代码:func handler(c *gin.Context) { go func() { // 危险!handler 返回后 c 可能已被回收复用 log.Println(c.Request.URL.Path) }() c.JSON(200, gin.H{"status": "ok"})}这段代码在并发量大时几乎必出问题。正确做法是调用 c.Copy() 创建一个只读副本:func handler(c *gin.Context) { cCopy := c.Copy() go func() { // 安全:使用副本,不受原始 Context 回收影响 log.Println(cCopy.Request.URL.Path) }() c.JSON(200, gin.H{"status": "ok"})}c.Copy() 会复制 Request、Keys 等字段,保证子 goroutine 读取的数据不会因为请求结束而被篡改。这是 Gin 官方文档明确要求的做法。如何控制 goroutine 的并发数量?如果不加限制地在每个请求中启动 goroutine,高并发时 goroutine 数量会暴涨,导致内存飙升、调度延迟增大。控制并发数的常用方式有两种。用带缓冲 channel 实现信号量func MaxAllowed(n int) gin.HandlerFunc { sem := make(chan struct{}, n) return func(c *gin.Context) { sem <- struct{}{} defer func() { <-sem }() c.Next() }}// 注册为中间件,限制同时处理的请求不超过 100r := gin.Default()r.Use(MaxAllowed(100))channel 的缓冲大小就是最大并发数。请求进来时写入 channel(缓冲满则阻塞),处理完毕后读出释放位置。用 golang.org/x/time/rate 做令牌桶限流信号量控制的是并发数,而令牌桶控制的是每秒请求数(QPS):import "golang.org/x/time/rate"var limiter = rate.NewLimiter(100, 10) // 每秒 100 个请求,突发上限 10func rateLimitMiddleware() gin.HandlerFunc { return func(c *gin.Context) { if !limiter.Allow() { c.JSON(429, gin.H{"error": "too many requests"}) c.Abort() return } c.Next() }}两者经常搭配使用:信号量限制同时在跑的 goroutine 数量,令牌桶限制请求进入的速率。Worker Pool 模式怎么用?当需要处理大量同类任务(比如批量发邮件、批量调用第三方接口)时,逐个启动 goroutine 既不可控也不高效。Worker Pool 预先创建固定数量的 worker goroutine,通过 channel 分发任务:type Job struct { ID int Payload interface{}}type Result struct { JobID int Output interface{} Err error}func newWorkerPool(numWorkers int, jobCh <-chan Job, resultCh chan<- Result) { for i := 0; i < numWorkers; i++ { go func(workerID int) { for job := range jobCh { out, err := processJob(job) resultCh <- Result{JobID: job.ID, Output: out, Err: err} } }(i) }}在 Gin 中的典型用法:将 Worker Pool 作为应用级单例初始化,handler 只负责往 jobCh 投递任务:var ( jobCh = make(chan Job, 1000) resultCh = make(chan Result, 1000))func init() { newWorkerPool(20, jobCh, resultCh)}func handleJob(c *gin.Context) { job := Job{ID: 1, Payload: c.Query("data")} jobCh <- job select { case res := <-resultCh: c.JSON(200, gin.H{"result": res.Output}) case <-time.After(5 * time.Second): c.JSON(504, gin.H{"error": "timeout"}) }}Worker Pool 的好处:goroutine 数量固定可控,任务通过 channel 排队,不会因为瞬时流量激增而崩溃。生产环境也可以考虑使用成熟的协程池库如 ants,它支持动态扩缩容和任务超时。多个 goroutine 之间如何安全地共享数据?Go 的哲学是"不要通过共享内存来通信,而要通过通信来共享内存"——优先用 channel。但有些场景确实需要共享状态,这时需要加锁或使用并发安全的容器。sync.Map:适合读多写少var cache sync.Mapfunc handleCache(c *gin.Context) { key := c.Query("key") if val, ok := cache.Load(key); ok { c.JSON(200, gin.H{"value": val}) return } val := computeValue(key) cache.Store(key, val) c.JSON(200, gin.H{"value": val})}sync.Map 对读多写少的场景做了优化,不需要额外加锁。但如果是写操作频繁的场景,它的性能反而不如 map + Mutex。sync.Mutex:适合写操作频繁type SafeCounter struct { mu sync.Mutex m map[string]int}func (sc *SafeCounter) Incr(key string) { sc.mu.Lock() sc.m[key]++ sc.mu.Unlock()}func (sc *SafeCounter) Get(key string) int { sc.mu.Lock() defer sc.mu.Unlock() return sc.m[key]}面试中常考的点:Mutex 和 RWMutex 的区别。如果读远多于写,用 sync.RWMutex 可以让多个读操作并行,提升吞吐。如何等待多个 goroutine 完成并收集结果?sync.WaitGroup 是标准库提供的同步原语,用来等待一组 goroutine 全部结束:func handleConcurrentTasks(c *gin.Context) { var wg sync.WaitGroup results := make([]string, 0, 3) mu := sync.Mutex{} tasks := []string{"task1", "task2", "task3"} for _, task := range tasks { wg.Add(1) go func(t string) { defer wg.Done() res := processTask(t) mu.Lock() results = append(results, res) mu.Unlock() }(task) } wg.Wait() c.JSON(200, gin.H{"results": results})}注意这里对 results 切片的追加操作加了锁,因为多个 goroutine 并发 append 会导致数据竞争。另一个常见做法是用 channel 收集结果,避免加锁。如何用 context 取消正在执行的 goroutine?生产环境中,客户端可能随时断开连接,或者请求有超时要求。这时需要通过 context.Context 通知子 goroutine 提前退出:func handleCancellableTask(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) defer cancel() resultCh := make(chan string, 1) go func() { resultCh <- longRunningTask(ctx) }() select { case res := <-resultCh: c.JSON(200, gin.H{"result": res}) case <-ctx.Done(): c.JSON(408, gin.H{"error": "request timeout"}) }}func longRunningTask(ctx context.Context) string { for i := 0; i < 10; i++ { select { case <-ctx.Done(): return "cancelled" default: time.Sleep(500 * time.Millisecond) } } return "completed"}关键点:在循环或耗时步骤中检查 ctx.Done(),一旦 context 被取消就能及时退出,避免 goroutine 泄漏。如何检测和防止 goroutine 泄漏?goroutine 泄漏是 Go 服务中最隐蔽的问题之一——goroutine 不会自动报错,只会默默占用内存,直到 OOM。常见泄漏场景channel 阻塞:往无缓冲 channel 写入,但没有接收方;或从 channel 读取,但没有发送方缺少退出机制:goroutine 内部是死循环,没有监听退出信号context 未传递:启动 goroutine 时没有传入 context,无法通知其退出检测手段import _ "net/http/pprof"// 在 main 中启动 pprof HTTP 服务go func() { http.ListenAndServe(":6060", nil)}()然后访问 http://localhost:6060/debug/pprof/goroutine?debug=1 可以看到当前所有 goroutine 的堆栈。对比两次请求的 goroutine 数量,如果持续增长就说明存在泄漏。Gin 项目也可以用 github.com/gin-contrib/pprof 直接集成。防泄漏原则启动 goroutine 时就想好它什么时候结束所有 goroutine 都应该监听 context 的取消信号用 defer 确保资源释放和 channel 关闭上线前用 go test -race 检测数据竞争面试中常被追问的几个问题Gin 为什么用 sync.Pool 管理 Context? 高并发下每个请求都 new 一个 Context 会给 GC 带来巨大压力。sync.Pool 让 Context 对象在请求结束后被复用,减少内存分配次数。这也是为什么在 goroutine 中不能直接用原始 Context——它随时会被回收。goroutine 和线程的区别? goroutine 是用户态的轻量级协程,初始栈只有 2KB(可动态扩容),创建和切换成本远低于操作系统线程。一个 Go 进程可以轻松跑几十万个 goroutine,而线程通常受限于系统资源只能开几千个。GMP 调度模型和 Gin 并发有什么关系? Gin 的每个请求对应一个 G(goroutine),由 Go runtime 的 GMP 模型调度到 M(系统线程)上执行。P(逻辑处理器)的数量默认等于 CPU 核心数,决定了真正的并行度。理解 GMP 有助于排查调度延迟和 CPU 利用率问题。
服务端阅读 05月27日 20:01

Hardhat Network 的特点和优势是什么?

Hardhat Network 是 Hardhat 框架内置的本地以太坊开发网络,专为智能合约开发、测试和调试而设计。它让开发者无需部署到真实链上即可完成全流程开发验证,是以太坊开发工具链中的核心组件。核心特性1. 即时挖矿(Automining)Hardhat Network 默认启用自动挖矿模式——每笔交易提交后立即被打包进下一个区块,无需等待出块时间:// hardhat.config.jsmodule.exports = { networks: { hardhat: { mining: { auto: true, // 默认开启,交易即时确认 interval: 5000 // 也可设为定时出块(毫秒) } } }};关闭自动挖矿后,交易会进入内存池(mempool),行为与 Geth 客户端一致,适合测试交易排序和 MEV 场景。2. 预置测试账户启动时自动生成 20 个测试账户,每个账户预分配 10000 ETH:const [deployer, user1, user2] = await hre.ethers.getSigners();console.log("Deployer address:", deployer.address);console.log("Balance:", hre.ethers.formatEther(await hre.ethers.provider.getBalance(deployer.address)));// 输出: Balance: 10000.0还可自定义账户配置:networks: { hardhat: { accounts: { count: 5, // 只生成 5 个账户 accountsBalance: "100000000000000000000000" // 每个账户 100000 ETH } }}3. 状态快照与回滚evm_snapshot 和 evm_revert 允许在测试中保存和恢复网络状态,避免每次测试都重新部署:describe("Token 测试", function () { let snapshotId; before(async function () { // 部署合约 this.token = await Token.deploy(); // 保存初始状态快照 snapshotId = await hre.network.provider.send("evm_snapshot"); }); afterEach(async function () { // 每个测试用例后恢复快照 await hre.network.provider.send("evm_revert", [snapshotId]); snapshotId = await hre.network.provider.send("evm_snapshot"); }); it("转账测试", async function () { await this.token.transfer(user1.address, 100); // 测试结束后状态自动回滚 });});4. Solidity 调试工具console.log 调试在合约中直接输出调试信息,无需触发交易:// SPDX-License-Identifier: MITpragma solidity ^0.8.20;import "hardhat/console.sol";contract DebugExample { function calculate(uint256 a, uint256 b) public pure returns (uint256) { console.log("Input a:", a); console.log("Input b:", b); uint256 result = a * b + a; console.log("Result:", result); return result; }}堆栈追踪交易失败时,Hardhat Network 提供组合 JavaScript 和 Solidity 的完整调用栈:Error: VM Exception while processing transaction: reverted with reason string 'Insufficient balance' at Token.transfer (contracts/Token.sol:45) at process._tickCallback (internal/process/next_tick.js:68:7)5. 主网分叉(Mainnet Forking)基于真实链上状态创建本地分叉,可直接与已部署的 DeFi 协议交互:networks: { hardhat: { forking: { url: "https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY", enabled: true, // 可选:锁定到特定区块,确保测试可复现 blockNumber: 18500000 } }}分叉环境下的典型用法——与 Uniswap 交互测试:it("在分叉网络上交换代币", async function () { // 模拟持有 USDC 的鲸鱼账户 await hre.network.provider.request({ method: "hardhat_impersonateAccount", params: ["0x55fe002..."], // USDC 鲸鱼地址 }); const whale = await hre.ethers.getSigner("0x55fe002..."); // 用鲸鱼账户执行交易 const usdc = await hre.ethers.getContractAt("IERC20", USDC_ADDRESS, whale); await usdc.transfer(user1.address, hre.ethers.parseUnits("1000", 6));});6. 时间操控与挖矿控制在测试中灵活操控区块时间和出块:// 前进指定时间await hre.network.provider.send("evm_increaseTime", [3600]); // 快进 1 小时await hre.network.provider.send("evm_mine"); // 挖一个新区块// 设置到具体时间戳await hre.network.provider.send("evm_setNextBlockTimestamp", [1700000000]);// 重置网络状态await hre.network.provider.send("hardhat_reset");7. Gas 报告结合 hardhat-gas-reporter 插件,量化合约函数的 Gas 消耗:// 安装:npm install hardhat-gas-reporterrequire("hardhat-gas-reporter");module.exports = { gasReporter: { enabled: true, currency: "USD", gasPrice: 20 // Gwei }};输出示例:·--------------------------------|---------------------------|-------------|-----------------------------|| Solc version: 0.8.20 · Gas price: 20 gwei · USD/ETH: 3800 ||--------------------------------|---------------------------|-------------|-----------------------------|| Method · Min · Max · Avg ||································|···························|·············|·····························|| transfer · 51654 · 61654 · 53987 || approve · 46263 · 46263 · 46263 ||--------------------------------|---------------------------|-------------|-----------------------------|与其他本地网络的对比| 特性 | Hardhat Network | Ganache | Anvil (Foundry) ||------|----------------|---------|-----------------|| 即时挖矿 | 默认开启 | 默认开启 | 默认开启 || Solidity 堆栈追踪 | 完整支持 | 不支持 | 支持 || console.log | 原生支持 | 不支持 | 支持 || 主网分叉 | 原生内置 | 需配置 | 原生内置 || 快照/回滚 | 支持 | 支持 | 支持 || 账户模拟 | hardhat_impersonateAccount | 不直接支持 | vm.prank || TypeScript 集成 | 深度集成 | 弱 | 弱 || 运行性能 | 中等(EDR 加速) | 中等 | 极快(Rust 原生) || 插件生态 | 最丰富 | 有限 | 增长中 |典型使用场景单元测试与集成测试describe("MyContract", function () { it("应正确执行质押", async function () { const [owner, staker] = await hre.ethers.getSigners(); const token = await Token.deploy(); const staking = await Staking.deploy(token.target); await token.connect(staker).approve(staking.target, hre.ethers.parseEther("100")); await staking.connect(staker).stake(hre.ethers.parseEther("100")); expect(await staking.stakedBalance(staker.address)).to.equal(hre.ethers.parseEther("100")); });});DeFi 协议集成测试通过主网分叉测试与 Aave、Uniswap 等协议的交互逻辑,无需在测试网排队或消耗真实 Gas。时间依赖逻辑验证利用 evm_increaseTime 和 evm_mine 测试锁仓期、投票截止时间等时间相关功能。Gas 优化迭代通过 Gas 报告对比不同实现的 Gas 消耗,持续优化合约效率。常见配置参考// hardhat.config.js — 完整开发配置require("@nomicfoundation/hardhat-toolbox");require("hardhat-gas-reporter");module.exports = { solidity: "0.8.24", networks: { hardhat: { chainId: 31337, mining: { auto: true }, forking: process.env.FORK_URL ? { url: process.env.FORK_URL, blockNumber: 18500000 } : undefined, accounts: { count: 20, accountsBalance: "10000000000000000000000" // 10000 ETH }, allowBlocksWithSameTimestamp: true, throwOnTransactionFailures: true, throwOnCallFailures: true } }, gasReporter: { enabled: process.env.REPORT_GAS === "true" }};总结Hardhat Network 的核心优势在于将开发调试效率最大化:即时确认交易、Solidity 级堆栈追踪、主网状态分叉、灵活的时间与账户操控,这些能力让开发者能在本地完成绝大部分验证工作,显著减少对测试网的依赖。虽然 Foundry/Anvil 在执行速度上有优势,但 Hardhat Network 凭借 TypeScript 深度集成和丰富的插件生态,仍是复杂 DApp 开发场景下的首选本地网络。
服务端阅读 05月27日 19:59

如何在 Hardhat 中编写智能合约测试?

在以太坊智能合约开发中,测试是保障合约安全性和功能正确性的关键环节。Hardhat 内置了基于 Mocha 和 Chai 的测试框架,配合 ethers.js 提供了强大的合约交互能力。本文将从基础到进阶,系统讲解如何在 Hardhat 中编写高质量的智能合约测试。一、测试环境搭建确保项目已安装 Hardhat 及相关依赖:npm install --save-dev hardhat @nomicfoundation/hardhat-toolboxhardhat-toolbox 集成了 ethers.js、chai、hardhat-network-helpers 等常用测试工具,推荐直接使用。测试文件统一放在 test/ 目录下,以 .js 或 .ts 为后缀。二、测试文件基本结构一个典型的 Hardhat 测试文件结构如下:const { expect } = require("chai");const { ethers } = require("hardhat");describe("TokenContract", function () { let TokenContract; let token; let owner; let addr1; let addr2; // 每个测试用例执行前部署全新合约 beforeEach(async function () { [owner, addr1, addr2] = await ethers.getSigners(); TokenContract = await ethers.getContractFactory("TokenContract"); token = await TokenContract.deploy(); await token.waitForDeployment(); }); it("部署后应正确设置 owner", async function () { expect(await token.owner()).to.equal(owner.address); }); it("应能正确转账", async function () { await token.transfer(addr1.address, 100); expect(await token.balanceOf(addr1.address)).to.equal(100); });});关键要点:describe 对测试用例分组,it 定义单个测试beforeEach 确保每个测试在干净状态运行getSigners() 获取测试账户,owner 是默认的第一个账户Hardhat 2.x 使用 deployed(),3.x/最新版推荐 waitForDeployment()三、核心测试技巧1. 合约部署与初始化describe("部署", function () { it("部署时传入初始参数", async function () { const Token = await ethers.getContractFactory("MyToken"); const token = await Token.deploy("MyToken", "MTK", 1000000); await token.waitForDeployment(); expect(await token.name()).to.equal("MyToken"); expect(await token.symbol()).to.equal("MTK"); expect(await token.totalSupply()).to.equal(1000000); });});2. 状态读取与断言it("应正确记录余额", async function () { // 写入操作 const tx = await token.connect(addr1).mint(500); await tx.wait(); // 等待交易确认 // 读取状态并断言 expect(await token.balanceOf(addr1.address)).to.equal(500);});使用 .connect(signer) 切换调用者身份,模拟不同用户操作。3. 事件测试验证合约是否正确触发事件及其参数:it("转账时应触发 Transfer 事件", async function () { await expect(token.transfer(addr1.address, 100)) .to.emit(token, "Transfer") .withArgs(owner.address, addr1.address, 100);});4. 异常与回滚测试测试 revert 是安全审计的重点。常见的三种断言方式:// 匹配特定错误消息await expect(token.connect(addr1).mint(100)) .to.be.revertedWith("Only owner can mint");// 匹配自定义错误(Solidity 0.8.16+)await expect(token.connect(addr1).mint(100)) .to.be.revertedWithCustomError(token, "Unauthorized");// 仅验证是否 revert(不关心具体消息)await expect(token.transfer(addr1.address, 0)) .to.be.reverted;5. 使用快照重置状态当测试用例需要共享状态但又要在某些场景重置时,使用快照:describe("快照测试", function () { let snapshotId; beforeEach(async function () { snapshotId = await ethers.provider.send("evm_snapshot", []); }); afterEach(async function () { await ethers.provider.send("evm_revert", [snapshotId]); }); it("状态修改后可回滚", async function () { await token.transfer(addr1.address, 100); expect(await token.balanceOf(addr1.address)).to.equal(100); // afterEach 会自动回滚状态 });});6. 时间操作测试锁仓、投票等时间相关逻辑:const { time } = require("@nomicfoundation/hardhat-network-helpers");it("锁仓到期后应能提取", async function () { const lock = await Lock.deploy(oneYearInSec, { value: ethers.parseEther("1") }); // 快进时间 await time.increase(oneYearInSec); // 验证到期后可提取 await expect(lock.withdraw()).not.to.be.reverted;});推荐使用 hardhat-network-helpers 提供的 time.increase()、time.increaseTo()、helpers.mine() 等方法,比直接发送 evm_increaseTime 更直观。四、进阶测试场景Gas 消耗测试监控关键函数的 Gas 消耗,防止异常飙升:it("转账 Gas 消耗应在合理范围", async function () { const tx = await token.transfer(addr1.address, 100); const receipt = await tx.wait(); console.log(`Gas used: ${receipt.gasUsed.toString()}`); expect(receipt.gasUsed).to.be.below(80000); // 设定合理上限});权限与访问控制测试describe("访问控制", function () { it("非 admin 不能调用受限函数", async function () { await expect(token.connect(addr1).setPause(true)) .to.be.revertedWithCustomError(token, "AccessControlUnauthorizedAccount"); }); it("admin 可授权后调用", async function () { await token.grantRole(ADMIN_ROLE, addr1.address); await expect(token.connect(addr1).setPause(true)).not.to.be.reverted; });});可升级合约测试const { upgrades } = require("@openzeppelin/hardhat-upgrades");it("应能升级合约并保留状态", async function () { const V1 = await ethers.getContractFactory("TokenV1"); const proxy = await upgrades.deployProxy(V1, ["MTK"]); await proxy.waitForDeployment(); // 写入一些状态 await proxy.mint(addr1.address, 100); // 升级到 V2 const V2 = await ethers.getContractFactory("TokenV2"); const upgraded = await upgrades.upgradeProxy(await proxy.getAddress(), V2); // 验证状态保留 expect(await upgraded.balanceOf(addr1.address)).to.equal(100); // 验证新功能可用 expect(await upgraded.version()).to.equal(2);});五、常见陷阱与排查| 陷阱 | 说明 | 解决方式 ||------|------|----------|| 忘记 await tx.wait() | 只发送交易未等待确认就断言 | 写操作后必须 await tx.wait() || 使用 this 在箭头函数中 | Mocha 箭头函数中 this 不指向 Mocha 上下文 | 使用 describe(function() {}) 而非箭头函数 || Gas 估算失败 | 交易本身会 revert 导致无法估算 Gas | 直接用 expect(...).to.be.reverted 捕获 || BigInt 比较 | ethers v6 返回 BigInt | 使用 expect(value).to.equal(n) 而非 === || 测试间状态污染 | beforeEach 未正确重置 | 确保每个测试前重新部署或使用快照 |六、测试最佳实践测试覆盖要全面 — 每个公开函数至少一个正向用例和一个异常用例边界值测试 — 测试 0、最大值、溢出边界等极端情况保持测试独立性 — 不依赖其他测试的执行顺序使用有意义的描述 — it("应在余额不足时 revert") 而非 it("test1")模拟真实场景 — 不要只测理想路径,考虑前端调用方式、多签名流程等集成 CI — 在 GitHub Actions 中自动运行测试,配合 hardhat coverage 输出覆盖率报告使用 hardhat-gas-reporter — 持续监控 Gas 变化趋势,及时发现性能退化运行全部测试:npx hardhat test运行特定文件并输出 Gas 报告:REPORT_GAS=true npx hardhat test test/Token.test.js查看测试覆盖率:npx hardhat coverage
服务端阅读 05月27日 19:59

Hardhat 主网分叉功能如何使用?

Hardhat 主网分叉(Mainnet Forking)允许开发者在本地复刻以太坊主网或测试网的当前状态,在无需花费真实 Gas 的前提下,与链上已部署的真实合约进行交互。这对测试 DeFi 协议集成、调试复杂交易路径、验证合约升级影响等场景至关重要。工作原理主网分叉的本质是:Hardhat 节点通过 RPC 节点读取主网的归档数据,在本地模拟出与主网一致的状态。你的本地交易不会影响真实链上数据,但可以读取主网上合约的存储值、余额等状态。核心依赖:一个支持归档数据的 RPC 节点(如 Alchemy、Infura、QuickNode)足够的本地内存(分叉会占用较多 RAM)基础配置在 hardhat.config.js(或 .ts)中添加分叉配置:module.exports = { solidity: "0.8.20", networks: { hardhat: { forking: { url: process.env.MAINNET_RPC_URL, // 如 https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY } } }};使用 .env 文件管理 RPC URL:MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY指定分叉区块号固定区块号可以确保每次测试的环境一致,提高测试的可重复性:networks: { hardhat: { forking: { url: process.env.MAINNET_RPC_URL, blockNumber: 19000000 // 锁定在此区块的主网状态 } }}为什么要固定区块号?如果不指定,每次启动都会分叉最新区块,合约状态(如代币价格、流动性池余额)可能发生变化,导致测试间歇性失败。模拟账户与代币水龙头分叉环境中,你可以直接扮演主网上持有大量代币的账户,这是主网分叉最强大的能力之一:const { ethers } = require("hardhat");async function main() { // 模拟 Whales 账户 const whaleAddress = "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503"; // Binance 热钱包 await network.provider.request({ method: "hardhat_impersonateAccount", params: [whaleAddress], }); const whale = await ethers.getSigner(whaleAddress); // 查询该账户的 ETH 余额 const ethBalance = await ethers.provider.getBalance(whaleAddress); console.log("ETH Balance:", ethers.formatEther(ethBalance)); // 查询该账户的 USDT 余额 const usdt = await ethers.getContractAt("IERC20", "0xdAC17F958D2ee523a2206206994597C13D831ec7"); const usdtBalance = await usdt.balanceOf(whaleAddress); console.log("USDT Balance:", ethers.formatUnits(usdtBalance, 6));}main();给任意地址注入 ETH(无需真实挖矿):// 给指定地址转入 100 ETHawait network.provider.send("hardhat_setBalance", [ "0xYourAddress", "0x56BC75E2D63100000", // 100 ETH 的十六进制(单位 wei)]);实战案例:在分叉环境测试 Uniswap 交易以下是一个完整的测试流程,在分叉环境中用模拟账户执行真实的 Uniswap V2 交易:const { ethers } = require("hardhat");describe("Uniswap V2 Swap on Forked Mainnet", function () { const WHALE = "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503"; const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const ROUTER = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"; let whale, usdt, router; before(async function () { // 模拟鲸鱼账户 await network.provider.request({ method: "hardhat_impersonateAccount", params: [WHALE], }); whale = await ethers.getSigner(WHALE); usdt = await ethers.getContractAt("IERC20", USDT); router = await ethers.getContractAt( [ "function swapExactTokensForTokens(uint,uint,address[],address,uint) external returns (uint[])", ], ROUTER ); }); it("should swap USDT for WETH", async function () { // 授权 Router 使用 USDT const swapAmount = ethers.parseUnits("1000", 6); // 1000 USDT await usdt.connect(whale).approve(ROUTER, swapAmount); // 执行兑换 const tx = await router.connect(whale).swapExactTokensForTokens( swapAmount, 0, // 最少输出(生产环境应设置滑点保护) [USDT, "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"], // USDT -> WETH whale.address, Math.floor(Date.now() / 1000) + 60 * 10 // 10 分钟 deadline ); const receipt = await tx.wait(); console.log("Swap succeeded, gas used:", receipt.gasUsed.toString()); });});测试 Aave 协议集成const { ethers } = require("hardhat");describe("Aave V3 Integration Test", function () { const WHALE = "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503"; const AAVE_POOL = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; it("should deposit USDC into Aave V3", async function () { await network.provider.request({ method: "hardhat_impersonateAccount", params: [WHALE], }); const whale = await ethers.getSigner(WHALE); const usdc = await ethers.getContractAt("IERC20", USDC); const pool = await ethers.getContractAt( [ "function deposit(address,uint256,address,uint16) external returns (bool)", ], AAVE_POOL ); const amount = ethers.parseUnits("10000", 6); // 10,000 USDC await usdc.connect(whale).approve(AAVE_POOL, amount); await pool.connect(whale).deposit(USDC, amount, whale.address, 0); console.log("Deposit succeeded"); });});动态启用/禁用分叉在测试中按需切换分叉,避免所有测试都承受分叉的性能开销:describe("With forking enabled", function () { before(async function () { await network.provider.request({ method: "hardhat_reset", params: [ { forking: { jsonRpcUrl: process.env.MAINNET_RPC_URL, blockNumber: 19000000, }, }, ], }); }); after(async function () { // 禁用分叉,恢复普通 Hardhat 网络 await network.provider.request({ method: "hardhat_reset", params: [], }); }); it("interacts with mainnet contract", async function () { // 你的分叉测试逻辑 });});RPC 节点选择| 服务商 | 免费额度 | 归档数据支持 | 推荐场景 ||--------|---------|------------|---------|| Alchemy | 300M CU/月 | 支持 | 开发调试首选 || Infura | 100K 请求/天 | 支持 | 轻量测试 || QuickNode | 免费套餐 | 支持 | 高性能需求 || Chainstack | 3M 请求/月 | 支持 | 多链支持 |注意:必须选择支持归档数据的套餐,否则无法指定历史区块号进行分叉。常见问题Q: 分叉启动后报错 "Missing trie node"?A: 你的 RPC 节点不支持指定区块号的归档数据,请切换到支持归档的套餐或去掉 blockNumber 使用最新区块。Q: 分叉环境交易回执显示 gas 极高?A: 这是正常现象,分叉环境中的 gas 计算可能与主网不一致,不影响功能测试。Q: impersonateAccount 后交易失败?A: 部分合约有合约白名单或代理限制。确保模拟的账户确实有权限调用目标合约,同时检查该账户在分叉区块号时是否持有足够的 ETH 支付 gas。Q: 如何在 CI/CD 中使用分叉?A: 使用固定的 blockNumber 并配合缓存(如缓存 RPC 响应),避免每次 CI 都大量请求 RPC 节点。Alchemy 的归档节点配合 blockNumber 是最稳定的 CI 方案。最佳实践使用环境变量存储 RPC URL,绝不将 API Key 硬编码提交到代码仓库固定 blockNumber 确保测试可重复,团队协作时所有人使用相同区块号只在需要主网状态的测试中启用分叉,普通单元测试使用本地 Hardhat 网络即可模拟高价值账户(Whales)获取测试代币,而非自己部署模拟 ERC20在 CI 中配合缓存机制减少 RPC 请求量,避免超出免费额度定期更新分叉区块号以测试与最新链上状态的兼容性
服务端阅读 05月27日 19:58

Jest 代码覆盖率怎么配置?四个指标分别是什么意思?

Jest 内置了代码覆盖率收集功能,基于 Istanbul(Babel provider)或 V8 引擎实现。运行 jest --coverage 即可生成报告,四种核心指标:语句覆盖率(Statements)衡量代码语句执行比例,分支覆盖率(Branches)衡量 if/switch 等分支走过了多少,函数覆盖率(Functions)统计函数调用比例,行覆盖率(Lines)统计代码行执行比例。四个指标中分支覆盖率通常最低,也最值得重点关注——因为未覆盖的分支意味着逻辑路径没被测到。配置方面,collectCoverageFrom 控制统计范围,coverageThreshold 设置门槛,coverageReporters 选择输出格式(text 控制台、lcov 给 CI、html 可视化浏览)。阈值支持全局和按文件/目录设置,还能用负数表示"最多允许 N 个未覆盖项"。追问Statements 和 Lines 有什么区别?不都是行吗?不是。一行代码可以包含多条语句,比如 let a = 1, b = 2; 是一条行但两条语句。反过来,一条 if 判断如果跨行书写,行覆盖率可能覆盖了但分支没覆盖。实际项目中这两个数字通常很接近,差异大说明代码风格比较紧凑。覆盖率到了 100% 就说明测试充分吗?不是。覆盖率只衡量"有没有被执行过",不衡量"有没有被正确验证"。比如一个函数返回值你从没断言,但函数被调用了,语句覆盖率照样算通过。另外边界值、异常路径、并发场景这些覆盖率工具本身很难捕捉。80% 是常见基线,核心模块可以要求更高。babel provider 和 v8 provider 怎么选?Babel provider 是默认选项,通过代码插桩(instrumentation)收集覆盖率,支持 /* istanbul ignore next */ 跳过指定行。V8 provider 利用 V8 引擎原生覆盖率 API,速度更快但不支持 Istanbul 忽略注释(改用 /* c8 ignore next */)。大型项目如果 Babel provider 跑覆盖率太慢,可以试 coverageProvider: "v8",但注意 V8 provider 是实验性功能,输出精度在某些边界场景有差异。CI 里覆盖率检查不通过怎么排查?先看 HTML 报告里标红的文件,重点看分支覆盖——很多是 else 分支或三元表达式的某一端没走到。常见原因:错误处理路径没测、环境判断(if (process.env.NODE_ENV === "production"))在测试环境走不到、死代码没排除。用 collectCoverageFrom 排除配置文件和类型定义,用负数阈值给特定模块放宽限制,比如 { "./src/legacy/**/*.js": { statements: -20 } } 允许老代码最多 20 个语句未覆盖。写段代码// jest.config.jsmodule.exports = { collectCoverage: true, coverageProvider: "v8", // 或 "babel" collectCoverageFrom: [ "src/**/*.{js,ts}", "!src/**/*.d.ts", "!src/index.ts", ], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 }, "./src/core/**/*.ts": { branches: 90 }, // 核心模块更严格 }, coverageReporters: ["text-summary", "lcov", "html"],};
服务端阅读 05月27日 19:58

如何在 Jest 中进行参数化测试?如何使用 test.each 和 describe.each?

为什么需要参数化测试写测试的时候,经常会遇到同一套逻辑需要用不同数据反复验证的情况。比如一个加法函数,你要测正数、负数、零、边界值,如果每组数据都单独写一个 test,代码会变得冗长且难以维护。参数化测试就是为了解决这个问题——把数据和断言逻辑分离,用一份测试代码覆盖多组输入。Jest 提供了 test.each 和 describe.each 两个 API 来实现参数化测试。前者对单条测试用例做参数化,后者对整组测试做参数化,两者搭配可以显著减少重复代码。test.each 的基本用法test.each 接收一个数组,数组中的每个元素代表一组测试数据,Jest 会为每组数据生成一条独立的测试用例。用二维数组传入参数,这是最直接的写法:test.each([ [1, 1, 2], [1, 2, 3], [2, 1, 3],])('adds %i + %i = %i', (a, b, expected) => { expect(add(a, b)).toBe(expected);});注意测试名称中的 %i 是占位符,Jest 会按顺序用数组元素替换它们。常用的占位符有:%s(字符串)、%i(整数)、%d(数字)、%p(pretty-format)、%#(测试索引)。用对象数组提高可读性二维数组的参数顺序容易搞混,特别是参数多的时候。用对象数组可以让每组数据的含义一目了然:test.each([ { a: 1, b: 1, expected: 2 }, { a: 1, b: 2, expected: 3 }, { a: 2, b: 1, expected: 3 },])('$a + $b = $expected', ({ a, b, expected }) => { expect(add(a, b)).toBe(expected);});对象数组的测试名称用 $key 的语法引用对象属性,比位置占位符更清晰。如果某个属性值是对象或数组,用 $key 也能自动展开显示。表格语法的写法Jest 还支持用模板字符串写表格式的参数化数据,可读性更好,特别适合数据量较多的场景:test.each` a | b | expected ${1} | ${1} | ${2} ${1} | ${2} | ${3} ${2} | ${1} | ${3}`('returns $expected when $a is added to $b', ({ a, b, expected }) => { expect(add(a, b)).toBe(expected);});表格语法有几个要点:表头行定义变量名,用 | 分隔;数据行中 JavaScript 表达式必须用 ${} 包裹;字符串值可以不用 ${},直接写即可。这种方式在测试报告里看起来像一张表格,维护和审查都很方便。describe.each 分组参数化当你需要针对不同环境或配置运行一整套测试时,describe.each 就派上用场了。它为每组数据生成一个 describe 块,里面可以包含多条测试:describe.each([ ['node', 'node'], ['jsdom', 'browser'],])('test environment: %s', (env, type) => { test(`runs in ${type} environment`, () => { expect(process.env.NODE_ENV).toBeDefined(); }); test('has correct global scope', () => { if (env === 'jsdom') { expect(window).toBeDefined(); } else { expect(global).toBeDefined(); } });});这个例子中,两组环境配置各自生成一个 describe 块,每个块里有两条测试。describe.each 同样支持对象数组和表格语法,用法和 test.each 一致。参数化测试边界情况和错误处理参数化测试不只是测正常路径,更实用的场景是批量覆盖边界值和异常输入:test.each([ [0, 0, 0], [Number.MAX_SAFE_INTEGER, 1, Number.MAX_SAFE_INTEGER + 1], [Number.MIN_SAFE_INTEGER, -1, Number.MIN_SAFE_INTEGER - 1],])('handles edge cases: %i + %i = %i', (a, b, expected) => { expect(add(a, b)).toBe(expected);});test.each([ [undefined, 'input is required'], [null, 'input is required'], ['', 'input cannot be empty'],])('throws error for invalid input: %p', (input, expectedError) => { expect(() => validate(input)).toThrow(expectedError);});把正常值、边界值、异常值分不同的 test.each 组织,测试报告里失败用例一目了然,比把所有数据塞进一个 each 更容易定位问题。常见踩坑点占位符和参数数量不匹配。测试名称里的 %s、%i 等占位符数量必须和数组元素个数一致,多一个少一个都会报错。如果嫌数占位符麻烦,推荐用对象数组加 $key 的方式。异步测试忘记返回 Promise。参数化测试中的回调函数如果是异步的,和普通测试一样需要返回 Promise 或使用 async/await,这个容易遗漏:test.each([ [1, 2], [3, 4],])('async test for %i and %i', async (a, b) => { const result = await asyncAdd(a, b); expect(result).toBe(a + b);});表格语法中的类型陷阱。表格语法里不加 ${} 的值会被当作字符串处理,所以数字、布尔值、对象必须用 ${} 包裹,否则拿到的是字符串类型的值,断言结果可能不符合预期。实战建议在实际项目中,参数化测试用得好可以大幅提升测试覆盖率和可维护性,但也要注意分寸。一组测试数据建议控制在 10 条以内,超过这个数量就要考虑是否该拆分场景。数据太多时测试报告可读性会下降,调试也不方便。选择哪种语法形式可以按场景来:两三个简单参数用二维数组就够了;参数多或者含义不明显时用对象数组;数据量大、需要表格化展示时用模板字符串语法。test.each 和 describe.each 也可以嵌套使用,外层用 describe.each 按环境或配置分组,内层用 test.each 跑具体数据,这样测试结构既清晰又紧凑。
服务端阅读 05月27日 19:55

Jest 断言方法有哪些?expect 和匹配器怎么用?

Jest 断言就一个套路:expect(实际值).匹配器(期望值)。匹配器决定怎么比,面试常考的分这几类:相等性:toBe 用 ===,只适合基本类型;toEqual 递归比较对象和数组每个属性,比对象首选它。两个高频坑:expect({a:1}).toBe({a:1}) 永远失败(引用不同);toEqual 会忽略 undefined 属性,需要严格比较用 toStrictEqual。toMatchObject 只匹配属性子集,适合只关心部分字段。真假值:toBeNull/toBeUndefined/toBeDefined 各自只匹配一个值;toBeTruthy/toBeFalsy 按 JS 强制布尔转换——0、""、null、undefined、NaN 是 falsy,其余 truthy。别混用:toBeFalsy 比 toBeUndefined 宽泛得多。数字:toBeGreaterThan/toBeLessThan 及 OrEqual 变体。浮点数必须 toBeCloseTo——0.1 + 0.2 !== 0.3 是 JS 经典问题,用 toBe 比浮点数会翻车。字符串与容器:toMatch 匹配正则或子串;toContain 检查数组含元素或字符串含子串;toHaveLength 检查长度;toHaveProperty 检查对象属性。异常:toThrow 断言函数抛错,可匹配错误消息(字符串或正则)。必须传函数引用 expect(fn).toThrow(),传调用结果 expect(fn()).toThrow() 会在 expect 执行前就崩了。异步:resolves/rejects 断言 Promise 结果,必须 await——忘了 await 是新手最常犯的错,断言还没完成测试就静默通过了。否定修饰:任何匹配器前加 .not 取反。但别滥用:expect(x).not.toBeUndefined() 不如直接 expect(x).toBeDefined()。Mock:toHaveBeenCalledWith 检查调用参数;toHaveBeenCalledTimes 检查调用次数;toMatchSnapshot 做 UI 渲染快照回归。追问toBe 和 toEqual 有什么区别?什么时候用哪个?toBe 是引用相等(===),基本类型值相同就过,对象必须同一引用才过。toEqual 递归比较每个属性,结构相同就过。一句话:基本类型用 toBe,对象数组用 toEqual。面试里 90% 的坑就是拿 toBe 比对象然后一脸懵。Jest 异步测试怎么写?三种方式:回调用 done 参数,Promise 用 resolves/rejects,async/await 同样配 resolves/rejects。最大坑是忘 await——expect(promise).resolves.toBe(x) 不加 await,断言没跑完测试就 passed 了。正确写法:await expect(fetchData()).resolves.toEqual(data)。toThrow 有什么注意点?两个坑:一、必须传函数引用不是调用结果,前面说了;二、只捕获同步错误,异步错误得用 rejects.toThrow()。还有个细节:toThrow 匹配的是 error message 不是 error 类型,要精确匹配传字符串或正则。.not 能和所有匹配器组合吗?语法上可以,但语义上别乱用。expect(x).not.toBeUndefined() 和 expect(x).toBeDefined() 结果一样,后者更清晰。.not 用在"不应该发生"的场景:函数不应抛错、返回不应为 null、mock 不应被调用。项目里哪些匹配器用得最多?toEqual 和 toBe 占七成以上——几乎所有测试都在比较值;toHaveBeenCalledWith 和 toThrow 是第二梯队——验证 mock 和错误分支;toMatchSnapshot 在组件测试中大量使用。掌握这几个就能覆盖日常 80% 的断言场景。写段代码// toBe vs toEqualexpect(1 + 1).toBe(2);expect({ name: 'a' }).not.toBe({ name: 'a' }); // 引用不同,失败expect({ name: 'a' }).toEqual({ name: 'a' }); // 深度相等,通过// 异步断言必须 awaitawait expect(api.getUser(1)).resolves.toEqual({ id: 1 });// toThrow 传函数引用,匹配错误消息expect(() => JSON.parse('invalid')).toThrow();expect(() => risky()).toThrow(/permission denied/);// Mock 验证expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');expect(mockFn).toHaveBeenCalledTimes(2);
服务端阅读 05月27日 19:54

如何在 Jest 中测试 React Hooks?renderHook 和 act 怎么用?

测试 React Hooks 的核心工具是 renderHook 和 act。React 18 之后,renderHook 已从废弃的 @testing-library/react-hooks 迁移到 @testing-library/react,用法也有变化。核心思路renderHook:在测试环境中渲染 Hook,返回 result(当前返回值)、rerender(重新渲染)、unmount(卸载)act:包裹所有会导致状态更新的操作,确保 React 完成渲染后再执行断言waitFor:处理异步状态更新,替代旧版的 waitForNextUpdate安装依赖npm install --save-dev jest @testing-library/react @testing-library/jest-dom 注意:@testing-library/react-hooks 已废弃,React 18+ 请统一使用 @testing-library/react。测试 useStateimport { renderHook, act } from '@testing-library/react';function useCounter(initialValue = 0) { const [count, setCount] = useState(initialValue); const increment = () => setCount(c => c + 1); const decrement = () => setCount(c => c - 1); return { count, increment, decrement };}test('useCounter 初始值和更新', () => { const { result } = renderHook(() => useCounter(0)); // 验证初始状态 expect(result.current.count).toBe(0); // 用 act 包裹状态更新 act(() => { result.current.increment(); }); expect(result.current.count).toBe(1);});关键点:任何触发 setState 的调用都必须包裹在 act() 中,否则 React 会发出警告,断言也可能基于未更新的状态。测试 useEffectimport { renderHook, act } from '@testing-library/react';function useDocumentTitle(title) { useEffect(() => { document.title = title; return () => { document.title = 'default'; }; }, [title]);}test('useEffect 设置和清理', () => { const { result, unmount, rerender } = renderHook( ({ title }) => useDocumentTitle(title), { initialProps: { title: 'Hello' } } ); expect(document.title).toBe('Hello'); // 依赖变化时 effect 重新执行 rerender({ title: 'World' }); expect(document.title).toBe('World'); // 卸载时执行清理函数 unmount(); expect(document.title).toBe('default');});关键点:用 rerender 测试依赖变化,用 unmount 测试清理逻辑。测试 useContextimport { renderHook } from '@testing-library/react';const ThemeContext = createContext('light');function useTheme() { return useContext(ThemeContext);}test('useContext 读取 Provider 值', () => { const wrapper = ({ children }) => ( <ThemeContext.Provider value="dark"> {children} </ThemeContext.Provider> ); const { result } = renderHook(() => useTheme(), { wrapper }); expect(result.current).toBe('dark');});关键点:Hook 依赖 Context 时,通过 wrapper 选项注入 Provider,renderHook 会自动用 wrapper 包裹组件树。测试异步 Hookimport { renderHook, waitFor, act } from '@testing-library/react';function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; fetch(url) .then(res => res.json()) .then(json => { if (!cancelled) { setData(json); setLoading(false); } }) .catch(err => { if (!cancelled) { setError(err); setLoading(false); } }); return () => { cancelled = true; }; }, [url]); return { data, loading, error };}test('useFetch 异步请求', async () => { // 用 jest.fn mock fetch global.fetch = jest.fn(() => Promise.resolve({ json: () => Promise.resolve({ name: 'test' }) }) ); const { result } = renderHook(() => useFetch('/api/data')); // 初始状态 expect(result.current.loading).toBe(true); // 等待异步完成 await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.data).toEqual({ name: 'test' }); expect(result.current.error).toBeNull();});关键点:用 waitFor 等待异步更新,不要在 act 里 await waitFor(那是反模式)异步 Hook 需要处理竞态:组件卸载后不应再 setState,用 cancelled 标志位或 AbortController测试自定义 Hookfunction useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue;}test('useDebounce 防抖', () => { jest.useFakeTimers(); const { result, rerender } = renderHook( ({ value }) => useDebounce(value, 500), { initialProps: { value: 'hello' } } ); expect(result.current).toBe('hello'); // 快速更新值,防抖未到期 rerender({ value: 'world' }); expect(result.current).toBe('hello'); // 还是旧值 // 快进 500ms act(() => { jest.advanceTimersByTime(500); }); expect(result.current).toBe('world'); jest.useRealTimers();});关键点:涉及定时器的 Hook,用 jest.useFakeTimers() + act(() => jest.advanceTimersByTime(ms)) 精确控制时间。常见报错排查"not wrapped in act()" 警告原因:状态更新发生在 act() 之外(如异步回调、定时器未用 fake timers)。解决:异步操作用 waitFor 或 await act(async () => ...)定时器用 jest.useFakeTimers() 并在 act 中推进时间确保所有 setState 调用都在 act 内"Can't perform a React state update on an unmounted component"原因:异步操作完成后组件已卸载,仍然调用了 setState。解决:在 useEffect 清理函数中取消异步操作(cancelled 标志位 / AbortController)。最佳实践用 @testing-library/react 的 renderHook,不要再用废弃的 @testing-library/react-hooks所有状态更新包裹 act,同步用 act(fn),异步用 await act(async fn) 或 waitFor测试行为不测实现:关注 Hook 的输入输出,不关注内部状态变量名测试边界:初始值、空值、错误状态、并发场景用 rerender 测试依赖变化,用 unmount 测试清理逻辑Mock 外部依赖(API、定时器、DOM API),不 Mock React 内置 Hook
服务端阅读 05月27日 19:52

Jest 怎么测试 setTimeout 和 setInterval?fake timers 怎么用?

Jest 用 jest.useFakeTimers() 把 setTimeout、setInterval 替换成模拟实现,然后通过 jest.runAllTimers()、jest.advanceTimersByTime() 等方法手动推进时间,不用真等。核心流程就三步:开启假定时器 → 写业务代码 → 手动推进时间并断言。jest.useFakeTimers();const callback = jest.fn();setTimeout(callback, 1000);jest.advanceTimersByTime(1000);expect(callback).toHaveBeenCalledTimes(1);runAllTimers 会一口气跑完所有待执行的定时器,包括嵌套的。如果你的代码里定时器会不断递归注册自己(比如轮询),用 runAllTimers 会死循环——这种情况用 runOnlyPendingTimers 只跑当前这轮。advanceTimersByTime(ms) 更精确,只推进指定毫秒数,适合测"3 秒后应该执行了 3 次"这类场景:const cb = jest.fn();setInterval(cb, 1000);jest.advanceTimersByTime(3000);expect(cb).toHaveBeenCalledTimes(3);每个测试用例结束记得恢复真实定时器:jest.useRealTimers(),不然会影响后续测试。推荐放 afterEach 里统一清理。追问useFakeTimers 和手动 mock setTimeout 有什么区别?useFakeTimers 是 Jest 内置的,会替换全局的 setTimeout/setInterval/clearTimeout/clearInterval/setImmediate 等,提供 runAllTimers、advanceTimersByTime 等控制 API。手动 mock 只替换你 spyOn 的那一个函数,控制力更弱,需要自己模拟时间推进。fake timers 和 Promise 混用时有什么坑?这是最常见的坑:jest.useFakeTimers() 默认也会 fake 掉 process.nextTick 和微任务队列,导致 Promise.resolve().then(...) 里的回调不执行。Jest 27+ 可以用 jest.useFakeTimers({ doNotFake: ['nextTick'] }) 排除 nextTick,或者手动 await new Promise(process.nextTick) 让微任务跑完再推进时间。jest.advanceTimersByTime 和 jest.runTimersToTime 有什么区别?runTimersToTime 是旧 API(Jest 22 及之前),行为和 advanceTimersByTime 基本一致但语义模糊。Jest 23+ 推荐用 advanceTimersByTime,旧 API 仅为向后兼容保留。实际项目里测定时器最容易犯什么错?忘记在 beforeEach 里开启 fake timers,导致前一个测试的真实定时器泄漏到下一个测试;或者用 runAllTimers 跑有递归定时器的代码导致栈溢出。另一个常见问题是 afterEach 里只调了 useRealTimers 但没调 clearAllTimers,残留的定时器可能干扰后续用例。
服务端阅读 05月27日 19:51

Jest 中有哪些测试匹配器(Matchers)?如何使用自定义匹配器?

为什么匹配器是 Jest 测试的核心写测试本质上就是做断言——拿实际结果和期望结果比对。匹配器(Matchers)就是 Jest 提供的断言语言,决定了你能用多自然、多精确的方式表达"我期望这段代码的行为是什么"。如果你只会 toBe 和 toEqual,很多场景要么写不出断言,要么写得很别扭。掌握完整的匹配器体系,加上自定义匹配器的能力,才能写出既清晰又健壮的测试。相等性匹配器:判断值是否如你所料最基础也是用得最多的一组:toBe(value) — 严格相等,即 ===。适合原始类型(number、string、boolean)和 null/undefined 的比较。注意:对象比较的是引用,不是内容。expect(1 + 1).toBe(2);expect(null).toBe(null);toEqual(value) — 深度递归比较。对象和数组逐字段比对,是测试复杂数据结构的首选。expect({ name: 'Jest', version: 29 }).toEqual({ name: 'Jest', version: 29 });// 通过:内容一致即可,不要求同一引用toStrictEqual(value) — 比 toEqual 更严格:undefined 属性、稀疏数组空位、Date 实例等都会纳入比较。当你需要确保数据结构完全一致、没有多余属性时使用。expect({ a: undefined, b: 1 }).not.toStrictEqual({ b: 1 });// toEqual 会认为两者相同,toStrictEqual 不会toMatchObject(object) — 部分匹配,只检查给定的属性是否存在且值相等,忽略对象中的其他属性。适合只关心几个关键字段的场景。const user = { id: 1, name: 'Alice', email: 'alice@test.com', role: 'admin' };expect(user).toMatchObject({ name: 'Alice', role: 'admin' });// 只验证这两个字段,其余忽略真值匹配器:处理 null、undefined 和真假值JavaScript 的真假值规则经常让人踩坑,Jest 专门提供了一组匹配器:| 匹配器 | 通过条件 | 典型用途 ||--------|---------|---------|| toBeNull() | 仅 null | 区分 null 和 undefined || toBeUndefined() | 仅 undefined | 检测未赋值变量 || toBeDefined() | 非 undefined | 确认变量已定义 || toBeTruthy() | 真值(!!value === true) | 检查非空字符串、非零数字等 || toBeFalsy() | 假值(0、''、null、undefined、false) | 检查空值或无数据状态 |// 常见场景:函数返回 null 表示未找到expect(findUser(-1)).toBeNull();// 常见场景:检查可选配置项是否存在expect(config.timeout).toBeDefined();// 常见场景:检查有内容(非空字符串、非零数字)expect(response.body).toBeTruthy();一个常见的坑:toBeTruthy() 对 0 和空字符串返回 false。如果你确实需要区分 0 和 undefined,别用 toBeTruthy,用 toBeDefined。数字匹配器:比较大小和精度toBeGreaterThan(n) / toBeGreaterThanOrEqual(n) — 大于 / 大于等于toBeLessThan(n) / toBeLessThanOrEqual(n) — 小于 / 小于等于toBeCloseTo(n, precision) — 浮点数近似比较,避免精度问题expect(0.1 + 0.2).not.toBe(0.3); // JavaScript 浮点精度问题expect(0.1 + 0.2).toBeCloseTo(0.3, 5); // 正确做法:指定精度比较toBeCloseTo 是处理浮点运算的必备匹配器,第二个参数是小数点后的精度位数,默认是 2。如果测试中涉及金额计算或科学计算,务必用它替代 toBe。字符串匹配器toMatch(regexp | string) — 匹配正则或包含子串toContain(item) — 包含子字符串expect('Hello, Jest!').toMatch(/jest/i);expect('error: file not found').toContain('error');toMatch 支持正则,比 toContain 更灵活。需要模式匹配时用 toMatch,只需判断是否包含子串时用 toContain。数组匹配器toContain(item) — 数组中是否包含某元素(用 === 比较)toContainEqual(item) — 数组中是否包含深度相等的元素toHaveLength(n) — 数组/字符串长度const users = [{ id: 1 }, { id: 2 }];expect(users).toContainEqual({ id: 1 }); // 深度比较,通过expect(users).not.toContain({ id: 1 }); // 引用比较,不通过expect(users).toHaveLength(2);toContain 对对象用的是引用比较,如果数组里存的是对象字面量,一定要用 toContainEqual,否则断言会失败。对象匹配器toHaveProperty(keyPath, value?) — 检查对象是否有指定属性路径,可选检查值toMatchObject(object) — 部分匹配(上文已介绍)const config = { db: { host: 'localhost', port: 5432 } };expect(config).toHaveProperty('db.port', 5432); // 支持点号路径expect(config).toHaveProperty(['db', 'host']); // 也支持数组路径toHaveProperty 的 keyPath 参数支持点号分隔的字符串或字符串数组,可以深层数据校验。函数匹配器:验证函数调用行为这组匹配器配合 jest.fn() 或 jest.spyOn() 使用,是 Mock 测试的核心工具:toHaveBeenCalled() — 函数被调用过toHaveBeenCalledWith(...args) — 用特定参数调用过toHaveBeenCalledTimes(n) — 调用了 n 次toHaveLastReturnedWith(value) — 最后一次返回值toHaveNthReturnedWith(n, value) — 第 n 次返回值toHaveReturned() — 成功返回过(没抛错)toHaveReturnedWith(value) — 返回过指定值const onClick = jest.fn();button.click();button.click();expect(onClick).toHaveBeenCalledTimes(2);expect(onClick).toHaveBeenCalledWith(); // 无参数调用// 带参数的场景const save = jest.fn();save({ name: 'Alice' });expect(save).toHaveBeenCalledWith({ name: 'Alice' });一个实用技巧:toHaveBeenCalledWith 只检查某一次调用是否匹配,不要求所有调用都匹配。如果需要验证所有调用的参数序列,可以用 expect(fn.mock.calls).toEqual([[arg1], [arg2]])。异常匹配器:测试错误抛出toThrow(error?) — 函数抛出错误,可匹配错误消息或类型toThrowErrorMatchingSnapshot() — 错误消息快照function divide(a, b) { if (b === 0) throw new Error('Division by zero'); return a / b;}expect(() => divide(1, 0)).toThrow('Division by zero');expect(() => divide(1, 0)).toThrow(/zero/);expect(() => divide(1, 0)).toThrow(Error);关键点:toThrow 的参数必须是包裹在函数中的(expect(() => fn()) 而不是 expect(fn())),否则错误会在 expect 执行前直接抛出,测试框架捕获不到。否定匹配器:用 .not 取反所有匹配器都可以通过 .not 前缀取反:expect(value).not.toBe(42);expect(array).not.toContain('deprecated');expect(fn).not.toHaveBeenCalled();.not 链式调用让断言的语义更自然。当 not 加上语义明确的匹配器仍不够用时,就是自定义匹配器登场的时候了。快照匹配器:捕获和比对输出toMatchSnapshot(propertyMatchers?, hint?) — 与存储的快照比对toThrowErrorMatchingSnapshot() — 异常消息快照expect(component.render()).toMatchSnapshot();// 首次运行会生成快照文件,后续运行自动比对// 输出变化时测试失败,需用 --updateSnapshot 更新快照适合测试稳定的序列化输出(如组件渲染结果、配置对象)。不适合频繁变化的数据,否则快照文件会不断需要更新,失去测试价值。异步匹配器:处理 Promiseresolves — 期望 Promise 成功 resolverejects — 期望 Promise 被 reject// 测试异步函数成功返回await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'Alice' });// 测试异步函数抛错await expect(fetchUser(-1)).rejects.toThrow('User not found');使用 resolves / rejects 时必须加 await,否则 Jest 无法正确捕获异步结果,测试会提前结束并始终通过。自定义匹配器:让断言更贴合业务语义当内置匹配器无法精确表达你的断言意图时,expect.extend() 允许你创建自己的匹配器。基本结构自定义匹配器接收 received(expect() 传入的值)和自定义参数,返回一个包含 pass 和 message 的对象:expect.extend({ toBeWithinRange(received, floor, ceiling) { const pass = received >= floor && received <= ceiling; return { pass, message: () => pass ? `Expected ${received} NOT to be within range ${floor}–${ceiling}` : `Expected ${received} to be within range ${floor}–${ceiling}`, }; },});test('score is within passing range', () => { expect(85).toBeWithinRange(60, 100); expect(30).not.toBeWithinRange(60, 100);});message 函数要同时处理通过和不通过两种场景。pass 为 true 时,message 描述的是 .not 取反后的预期(因为 .not 让通过的变成失败),反之亦然。在 TypeScript 项目中使用自定义匹配器需要扩展 jest.Matchers 接口,否则 TypeScript 会报类型错误:// 在 jest.d.ts 或 global.d.ts 中声明declare global { namespace jest { interface Matchers<R> { toBeWithinRange(floor: number, ceiling: number): R; } }}实际案例:验证日期范围expect.extend({ toBeDateAfter(received, baseline) { const pass = received instanceof Date && baseline instanceof Date && received > baseline; return { pass, message: () => pass ? `Expected ${received.toISOString()} NOT to be after ${baseline.toISOString()}` : `Expected ${received.toISOString()} to be after ${baseline.toISOString()}`, }; },});test('expiry date is after creation date', () => { const created = new Date('2025-01-01'); const expires = new Date('2026-01-01'); expect(expires).toBeDateAfter(created);});自定义匹配器的最佳实践命名要语义化:toBeValidEmail 比 toMatchEmailRegex 更易读,测试代码读起来像自然语言。输入校验不能省:对 received 做类型检查,遇到非法输入抛出有意义的错误,而不是返回莫名其妙的 pass: false。配合 setupFilesAfterEnv 全局注册:把 expect.extend() 放在独立的 setup 文件中,在 Jest 配置的 setupFilesAfterEnv 里引入,避免每个测试文件重复注册。优先组合内置匹配器:如果只是 expect(a).toBeGreaterThan(x) 和 expect(a).toBeLessThan(y) 的组合,直接用 .and 或写两行断言就够了,不必自定义。自定义匹配器的价值在于表达内置匹配器无法简洁描述的业务规则。选择匹配器的思路遇到断言需求时,按这个顺序选择:值比较 — toBe / toEqual / toStrictEqual类型或存在性 — toBeDefined / toBeNull / toBeTruthy大小或范围 — toBeGreaterThan / toBeCloseTo包含关系 — toContain / toContainEqual / toMatchObject函数行为 — toHaveBeenCalledWith / toThrow异步结果 — resolves / rejects内置都不合适 — expect.extend() 自定义匹配器选对了,测试的可读性和维护性都会上一个台阶。不必死记硬背所有匹配器,理解每个类别的适用场景,需要时查阅即可。自定义匹配器则是把反复出现的断言模式封装成可复用工具,在项目规模变大时尤其值得投入。
服务端阅读 05月27日 19:51

什么是 Jest 快照测试?如何使用快照测试来验证组件输出?

Jest 快照测试(Snapshot Testing)是前端测试中一种高效的质量保障手段,它通过"拍照对比"的方式确保组件输出和数据结构不会发生意外变化。本文将从原理、用法、进阶技巧到常见踩坑,全面讲解快照测试的实践方法。快照测试的工作原理快照测试的核心思路是"第一次运行时记录预期输出,后续运行时与预期比对":首次运行:Jest 将组件的渲染输出序列化为字符串,保存到 __snapshots__/ 目录下的 .snap 文件中后续运行:重新渲染组件,将输出与已保存的快照进行逐行对比差异处理:如果输出与快照不一致,测试失败并在终端展示 diff 信息;开发者确认变更合理后,可更新快照与传统的断言式测试相比,快照测试无需手写每个期望值,尤其适合 UI 组件这种结构复杂的输出对象。基本用法:React 组件快照使用 react-test-renderer 创建组件的渲染树,再调用 toMatchSnapshot() 生成快照:import renderer from 'react-test-renderer';import UserProfile from './UserProfile';test('UserProfile renders correctly', () => { // 创建组件的渲染树 const tree = renderer .create(<UserProfile name="Alice" role="admin" />) .toJSON(); // 首次运行:生成快照文件;后续运行:与快照比对 expect(tree).toMatchSnapshot();});首次运行后,Jest 会在 __snapshots__/UserProfile.test.js.snap 中生成类似以下的快照:exports[`UserProfile renders correctly 1`] = `<div className="user-profile"> <h2> Alice </h2> <span className="role" > admin </span></div>`;如果后续修改了组件结构,快照测试会立即捕获变化并报告差异。使用 React Testing Library 进行快照在现代 React 项目中,更推荐使用 @testing-library/react 结合快照测试:import { render } from '@testing-library/react';import NavMenu from './NavMenu';test('NavMenu snapshot', () => { const { container } = render(<NavMenu items={['Home', 'About', 'Contact']} />); expect(container.firstChild).toMatchSnapshot();});这种方式更贴近用户的真实交互方式,渲染结果也更接近浏览器中的实际 DOM。内联快照:toMatchInlineSnapshottoMatchInlineSnapshot() 将快照内容直接写在测试文件中,而不是单独的 .snap 文件,适合输出较短的场景:test('formatUserInfo returns correct structure', () => { const result = formatUserInfo({ name: 'Bob', age: 28 }); expect(result).toMatchInlineSnapshot(` { "age": 28, "displayName": "Bob", "isActive": true } `);});内联快照的优势在于:快照与测试代码在同一文件中,code review 时更直观;不会产生额外的快照文件。但输出较长时不建议使用,会让测试文件变得臃肿。属性匹配器:处理动态数据当快照中包含动态生成的值(时间戳、UUID、随机数)时,每次运行快照都会不同,导致测试误报。使用属性匹配器可以优雅地解决这个问题:test('user creation response matches expected structure', () => { const response = createUser({ name: 'Charlie', email: 'charlie@example.com' }); expect(response).toMatchSnapshot({ id: expect.any(String), // id 是动态生成的,只验证类型 createdAt: expect.any(Date), // 时间戳也是动态的 token: expect.any(String), // JWT token 每次不同 }); // 其余字段会进行精确匹配});快照文件中对应字段会记录为 Any<String>、Any<Date>,后续运行只校验类型而不校验具体值。自定义序列化器当组件中包含无法直接序列化的对象(如 CSS-in-JS 的样式对象、Moment.js 日期对象)时,可以编写自定义序列化器:// customSerializer.jsconst styleSerializer = { // 判断是否需要自定义序列化 test: (val) => val && val.$$typeof === Symbol.for('react.element'), // 自定义序列化逻辑 print: (val, serialize) => { // 移除动态生成的 className,避免快照频繁变化 const props = { ...val.props }; delete props.className; return serialize({ ...val, props }); },};// 在 jest.config.js 中配置module.exports = { snapshotSerializers: ['./customSerializer.js'],};快照更新的正确姿势当有意修改组件导致快照测试失败时,需要更新快照:# 交互式更新(推荐):逐个确认是否更新jest --updateSnapshot# 简写jest -u# 只更新匹配特定测试名的快照jest -u --testNamePattern="UserProfile"# CI 环境中禁止意外更新jest --ci重要提醒:在 CI/CD 流水线中务必使用 --ci 标志,防止快照被意外更新而掩盖真正的 bug。Vue 组件的快照测试Vue 项目中使用 @vue/test-utils 进行快照测试:import { mount } from '@vue/test-utils';import TodoItem from './TodoItem.vue';test('TodoItem snapshot', () => { const wrapper = mount(TodoItem, { props: { title: 'Learn Jest', completed: false } }); expect(wrapper.html()).toMatchSnapshot();});Vue 的快照通常基于渲染后的 HTML 字符串,比 React 的虚拟 DOM 树更加可读。常见踩坑与解决方案1. 快照文件体积膨胀大组件的快照可能长达数百行,diff 审查成本高。解决方案:将大组件拆分为子组件分别测试;使用 toMatchSnapshot({ mode: 'deep' }) 控制序列化深度。2. 快照测试频繁误报包含动态数据的组件每次渲染输出不同,快照测试反复失败。解决方案:使用属性匹配器(Property Matchers)忽略动态字段;使用自定义序列化器过滤不稳定属性。3. 快照更新沦为"无脑确认"开发者遇到快照失败时不审查 diff,直接 jest -u 更新,导致快照测试失去意义。解决方案:在 CI 中强制使用 --ci 标志;团队 code review 时要求检查快照变更;定期清理过时快照(jest --listTests 配合 --findRelatedTests)。4. 快照测试运行缓慢组件依赖过多,渲染链路长导致快照测试耗时。解决方案:使用 shallow 渲染(浅渲染)代替 mount(全渲染),只渲染当前组件而不渲染子组件。快照测试的适用场景与局限| 适用场景 | 不适用场景 ||---------|-----------|| UI 组件结构回归测试 | 需要验证交互行为(点击、输入) || API 响应数据结构验证 | 需要验证计算逻辑正确性 || 配置文件结构检查 | 频繁变化的动态内容 || 序列化/格式化函数输出验证 | 需要精确数值断言的场景 |快照测试是回归测试的好帮手,但不能替代行为测试和单元测试。推荐将快照测试与 fireEvent、waitFor 等交互测试结合使用,形成完整的测试覆盖。总结快照测试通过"首次记录、后续比对"的方式高效检测 UI 和数据结构的意外变化使用 toMatchSnapshot() 生成外部快照,toMatchInlineSnapshot() 生成内联快照属性匹配器解决动态数据问题,自定义序列化器处理特殊对象CI 中务必使用 --ci 标志,团队 review 流程中必须审查快照变更快照测试适合结构回归,不适合验证交互行为和计算逻辑