WebMock和VCR是Ruby测试中处理外部HTTP服务依赖的两个常用工具。WebMock通过在网络库层面拦截请求,让测试不再真正访问远程服务器;VCR则进一步把真实的HTTP交互录制到本地文件里,后续测试直接从文件回放,兼顾了真实性与执行速度。但要把这套工具用好,除了会写stub和use_cassette,还需要理解匹配规则、录制模式和cassette的生命周期管理。下面从WebMock的请求模拟入手,再讨论VCR的录制与回放,最后给出cassette管理中可以落地的策略。

一、WebMock的stub与请求断言
WebMock的核心价值在于把HTTP请求拦截在客户端库之外,常见的Net::HTTP、Faraday、HTTPClient、Excon等都会经过它。要使用WebMock,最简单的方式是在spec_helper中加入require 'webmock/rspec',然后调用WebMock.disable_net_connect!关闭所有真实网络连接。这样一旦测试中出现未配置stub的请求,测试会立刻失败,并给出请求方法和完整URL,帮助你快速补上对应模拟。
定义请求模拟主要依靠stub_request。它可以指定HTTP方法、URL、请求头、请求体,并通过to_return返回预设的状态码、响应头和响应体。比如某一个接口需要返回JSON,直接给出字符串即可;如果希望测试异常分支,可以用to_raise抛出一个标准错误,或者用to_timeout模拟超时。这样不需要真的去构造故障环境,就能验证客户端的重试与降级逻辑。
require 'webmock/rspec'
WebMock.disable_net_connect!(allow_localhost: true)
RSpec.describe 'GitHub API client' do
it 'fetches user profile with stub' do
stub_request(:get, "https://api.github.com/users/octocat")
.with(
headers: {
'Accept' => 'application/json',
'User-Agent' => 'my-ruby-client'
}
)
.to_return(
status: 200,
body: '{"login":"octocat","id":1}',
headers: { 'Content-Type' => 'application/json' }
)
response = Net::HTTP.get(URI('https://api.github.com/users/octocat'))
expect(response).to include('octocat')
expect(WebMock).to have_requested(:get, 'https://api.github.com/users/octocat').once
end
end
除了设置返回内容,WebMock还提供了assert_requested和RSpec匹配器have_requested,用于断言某个请求是否真的被发送,以及发送次数、携带的头部和请求体是否符合预期。这种验证方式尤其适合测试那些没有直接返回值或返回值与请求无关的场景,比如事件上报、埋点接口。通过expect(WebMock).to have_requested(:post, url).with(body: hash_including(...)),可以把关注点从外部服务的响应转移到客户端是否按约定发送数据。
需要注意,WebMock的匹配默认相当严格。如果with中指定了headers,那么请求头必须完全匹配,多个或少一个键都会导致stub找不到。如果在测试中遇到Real HTTP connections are disabled之类的错误,优先检查URL是否写完整,包括协议、端口和查询参数,再检查头信息的大小写和内容。必要时可以移除部分with条件,让匹配更宽松,或使用hash_including、正则表达式等方式进行部分匹配。
二、VCR的录制、回放与匹配模式
VCR解决的是另一个问题:手写stub虽然稳定,但响应体往往来自人工构造,容易偏离真实数据。VCR的cassette会把真实HTTP交互序列化到本地文件中,测试首次运行时发真实请求并录制,后续运行直接读取本地文件回放,完全不再访问外部服务。它依赖WebMock等适配器来拦截请求,因此通常先配置config.hook_into :webmock,再指定cassette存放目录。
录制的核心接口是VCR.use_cassette。在给定的块内部,所有HTTP请求都会被记录到对应的cassette文件。默认的record模式是:once,意思是如果cassette文件已存在,就只回放不再录制;如果文件不存在,则录制新的交互。还有:new_episodes允许在已有cassette基础上追加新请求,:all每次都重新请求并覆盖,:none只回放不允许任何真实请求。测试环境下通常使用:once,开发时为了更新数据可以临时切换:all。
require 'vcr'
require 'webmock/rspec'
VCR.configure do |config|
config.cassette_library_dir = 'spec/fixtures/vcr_cassettes'
config.hook_into :webmock
config.default_cassette_options = {
record: :once,
match_requests_on: [:method, :uri, :body],
allow_playback_repeats: true,
re_record_interval: 7 * 24 * 60 * 60
}
config.filter_sensitive_data('<AUTH_TOKEN>') do |interaction|
interaction.request.headers['Authorization'].first
end
config.filter_sensitive_data('<API_KEY>') do |interaction|
interaction.request.headers['X-Api-Key'].first
end
end
默认的请求匹配只比较HTTP方法和URI,这在大多数场景下够用。但是当同一个URL根据请求体不同返回不同响应时,需要把匹配项扩展为[:method, :uri, :body]。比如GraphQL接口通常用POST同一个地址,请求体中的query决定响应,如果不匹配body,回放时会错乱。VCR也支持匹配headers,但要注意某些头部值每次都变,比如User-Agent或X-Request-Id,最好不要加入匹配条件,否则会导致cassette无法命中。
cassette文件默认使用YAML格式存储,结构上包含HTTP交互数组,每个交互记录了请求和响应的完整内容。打开文件可以看到请求头、请求体、响应状态、响应头和响应体。这些内容便于调试,但也很容易泄露敏感数据。VCR提供filter_sensitive_data来替换录制内容中的敏感字段,常见用法是把Authorization头替换为占位符。这样cassette文件里不会出现真实token,回放时占位符对应的值也不会参与真实请求,测试仍然能通过。
使用cassette的代码示例如下:
require 'vcr'
RSpec.describe 'Weather API with VCR' do
it 'returns current temperature' do
VCR.use_cassette('weather/london/current') do
response = Faraday.get('https://api.weather.ipipp.com/v1/current?city=London')
expect(response.status).to eq(200)
body = JSON.parse(response.body)
expect(body['temperature']).to be_a(Numeric)
end
end
end
首次运行这个测试时,VCR会向api.weather.ipipp.com发起请求并保存交互,之后运行不会产生网络流量。如果把spec/fixtures/vcr_cassettes目录纳入版本控制,其他开发者或CI环境可以直接复用这些录制内容,避免因网络不稳定导致测试随机失败。不过要注意,一旦外部API的返回结构变化,旧cassette会让测试继续通过,从而掩盖真实兼容性问题。所以需要定期检查并更新cassette,或结合版本机制让过期cassette自动重新录制。
三、cassette管理:过期策略、目录组织与敏感信息处理
cassette本质上是外部API的快照,快照会过期。VCR提供re_record_interval选项,单位是秒。例如设置为7 * 24 * 60 * 60表示cassette超过7天就自动重新录制。这个机制适合数据变化不那么频繁的接口,但对于价格、库存等实时数据,宁可缩短间隔或直接使用WebMock stub。需要注意,re_record_interval只有在cassette已经存在且record模式允许重新录制时才生效,默认的:once模式不会主动重新录制,需要改成:all或使用rerun的机制。
目录组织方面,cassette文件会随着测试增长而膨胀。推荐按照业务域或接口资源划分目录,比如weather/london/current.yml、github/users/octocat.yml,并在VCR.use_cassette中使用完整相对路径。这样既能避免同名cassette冲突,也能在测试失败时快速定位是哪段交互出了问题。不要在cassette文件名中放置随机数或时间戳,否则每次录制都会产生新文件,旧文件不会被复用,还会污染版本库。
另一个常见问题是同一测试方法内多次调用VCR.use_cassette。每个块会创建一个独立的回放上下文,块结束后自动弹出。如果嵌套使用,内层cassette会优先匹配。为了避免混淆,可以把所有外部请求放在同一cassette下,或者把多个cassette的use_cassette拆成不同的测试示例,保持每个测试只依赖一个cassette。测试失败时,检查VCR日志中的cassette路径和匹配信息,通常能找到请求未被回放的原因。
敏感信息处理不能只依赖开发者自觉。除了在filter_sensitive_data中替换Authorization和API Key,还建议在CI环境中设置全局过滤,把环境变量里的token统一替换为占位符。VCR支持多次调用filter_sensitive_data,每个占位符可以对应不同的过滤逻辑。如果某些响应体里也包含敏感字段,比如用户邮箱或手机号,可以用相同方式替换响应体中的内容。过滤后的cassette依然能被WebMock正常使用,因为请求匹配发生在过滤之前的原始数据上,回放时占位符不会影响断言。
最后补充一个调试技巧:如果在测试中遇到VCR::Errors::UnhandledHTTPRequestError,说明当前cassette没有记录到某个真实请求。先确认该请求是否发生在use_cassette块内,再检查请求匹配条件是否发生变化,例如新增了查询参数或头信息。可以临时把record模式设为:new_episodes重新运行一次,让VCR把新请求追加到cassette,然后恢复为:once。手动编辑cassette文件虽然可行,但必须保持YAML结构正确,否则会导致反序列化失败,所以更推荐用重新录制的方式更新。
Ruby WebMockHTTP请求模拟VCR cassette管理修改时间:2026-09-25 08:20:46