简介:这款用C#编写的接口测试工具采用WinForm桌面界面,面向需要联调Web接口的开发者及学习C#网络编程的入门者,主要解决日常验证中请求构造繁琐、返回结果不直观的问题。它支持获取、提交、更新、删除四类常见请求,可填写地址与JSON请求体,设置内容类型、授权等请求头,并展示状态码、响应头和响应内容,便于核对接口行为。压缩包共41个文件,包含13个C#源码、6个配置文件、3个可执行程序、2个工程文件、2个解决方案文件及资源缓存文件,整体约62KB,目录紧凑,代码注释清晰,便于按模块阅读和二次修改。已有1449人学习,既可用于接口联调、回归测试和参数排查,也可作为WinForm界面布局、请求发送、响应解析与配置保存等实现思路的学习参考,对初学者理解不同请求方法的差异也有帮助。
1. 内网联调卡在外部工具上,我把接口测试工具收进了 200 行 C# 里
上周去某公司做联调,开发机在内网,装不了额外的接口测试工具,浏览器里手敲 URL 又只能发 GET,PUT 和 DELETE 根本没法验证。当时就想,要是有个 C# WinForms 写的小窗口,能填 URL、选 POST/GET/PUT/DELETE、贴 JSON、点一下看原文响应,这事十分钟就完了。后来我干脆自己攒了一个,单文件 exe,双击就跑,不依赖运行时之外的任何东西。它解决的不是「专业接口平台能做的事」,而是「人在客户现场、只有一台受限机器、只想验一个 URL 通不通」这种场景。适合做后端联调、现场实施、以及要给非开发同事发个能点的按钮的人。
2. WinForms 请求面板怎么搭:控件选型与三种布局取舍
这个工具的界面部分占了差不多一半工作量。很多人觉得 WinForms 拖几个控件就完了,真到用的时候才发现:URL 长了输入框不跟着长、响应内容一多滚动条跑到外层、窗口一拉大所有控件原地不动。这些都不是玄学,是布局容器没选对。
2.1 HttpWebRequest、WebClient、HttpClient 三套 API 的取舍
先定请求层。.NET 里能发 HTTP 的有三代 API,选错了后面全是补丁。
HttpWebRequest是最老的一代,GetResponse()同步阻塞,取消要靠Abort(),异步模型是BeginGetResponse/EndGetResponse的 APM 模式,写出来一堆回调。它的好处是控制粒度最细,能直接摸到ServicePoint、连接复用策略。但在这个工具的场景里,我们要的是「点按钮→等结果→显示」,同步阻塞会直接把 UI 线程钉死,只能自己开线程再回主线程更新控件,代码量翻倍。
WebClient是上面那层的语法糖,DownloadString一行搞定,但它几乎不可配置:改超时得反射 hack 内部HttpWebRequest,改请求头得继承重写GetWebRequest,加取消基本没戏。写个一次性脚本可以,做工具不行。
HttpClient是现在的默认答案,SendAsync天然异步,CancellationToken直接支持取消,请求头、Content-Type、超时都能在请求对象上逐次设置。它唯一被说烂的坑是「不要每次 new」,因为每个实例持有自己的连接池,频繁 new 会导致 TIME_WAIT 堆积,端口耗尽。解决办法很简单:整个进程一个static readonly实例。
三代 API 的对比放在一张表里更直观:
| 维度 | HttpWebRequest | WebClient | HttpClient |
|---|---|---|---|
| 异步模型 | APM 回调 | 同步为主 | Task 原生 |
| 取消支持 | Abort 粗暴中断 | 无 | CancellationToken |
| 单次请求头定制 | 支持 | 需继承重写 | 请求对象上直接加 |
| 连接复用 | 靠 ServicePoint | 内部封装 | 实例级连接池 |
| 推荐用途 | 老项目兼容 | 一次性脚本 | 工具类首选 |
这个工具的目标框架如果是 .NET Framework 4.6.2 以上,HttpClient完全可用;如果要兼容更老的 XP 时代机器,那就只能退回HttpWebRequest,但那种场景现在已经很少了。
2.2 控件清单:五个必须有的,三个容易漏的
一个能用的请求面板,最小控件集是这样的:
| 控件 | 类型 | 作用 | 关键属性 |
|---|---|---|---|
| 方法选择 | ComboBox | GET/POST/PUT/DELETE | DropDownStyle=DropDownList |
| URL 输入 | TextBox | 目标地址 | Anchor=Left|Right|Top |
| 请求头表 | DataGridView | 键值对编辑 | AllowUserToAddRows=true |
| 请求体 | TextBox | JSON / 表单原文 | Multiline=true, ScrollBars=Vertical |
| 发送按钮 | Button | 触发请求 | Enabled 随请求状态切换 |
| 响应区 | TextBox | 原始响应 | ReadOnly=true, Multiline=true |
| 状态条 | StatusStrip | 状态码/耗时/大小 | 三项 ToolStripStatusLabel |
容易漏的三个:一是取消按钮,长轮询接口不取消就只能杀进程;二是响应区设成ReadOnly,否则用户误触改内容,复制出去的对不上;三是状态条里的耗时,接口慢到底慢在服务端还是慢在本地网络,这个数字是唯一的判断依据。
ComboBox一定要设DropDownStyle = DropDownList,不然用户能手输一个get带空格的方法名,new HttpMethod("get ")会在发请求时抛异常,报错信息还很难懂。
2.3 纯代码建界面:让窗体初始化能被 diff
我一般不用设计器拖。.Designer.cs是自动生成的,两个人同时改一个窗体,合并冲突里全是坐标数字,根本没法 review。改成在构造函数里手写布局,代码进了 Git 之后每一行改动都有意义。
// MainForm.Layout.cs —— 布局与控件构造集中在一个文件,方便 diff private void BuildLayout() { SuspendLayout(); MinimumSize = new Size(760, 520); Text = "接口请求工具"; Font = new Font("Microsoft YaHei UI", 9F); // 顶部一行:方法下拉 + URL 输入 + 发送按钮,用 TableLayoutPanel 按比例分 var top = new TableLayoutPanel { Dock = DockStyle.Top, Height = 34, ColumnCount = 3, Padding = new Padding(6, 4, 6, 4) }; top.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 92)); // 方法列固定宽 top.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); // URL 列吃满剩余 top.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 84)); // 按钮列固定宽 _cboMethod.DropDownStyle = ComboBoxStyle.DropDownList; _cboMethod.Items.AddRange(new object[] { "GET", "POST", "PUT", "DELETE" }); _cboMethod.SelectedIndex = 0; _cboMethod.Dock = DockStyle.Fill; _txtUrl.Dock = DockStyle.Fill; _txtUrl.Text = "https://example.internal/api/user/1"; _btnSend.Text = "发送"; _btnSend.Dock = DockStyle.Fill; _btnSend.Click += OnSendClick; top.Controls.Add(_cboMethod, 0, 0); top.Controls.Add(_txtUrl, 1, 0); top.Controls.Add(_btnSend, 2, 0); // 主体:上下分割,上半是「请求头 + 请求体」,下半是响应 var split = new SplitContainer { Dock = DockStyle.Fill, Orientation = Orientation.Horizontal, SplitterDistance = 220 }; split.Panel1.Controls.Add(BuildRequestArea()); split.Panel2.Controls.Add(BuildResponseArea()); Controls.Add(split); Controls.Add(top); Controls.Add(BuildStatusBar()); ResumeLayout(); }这段代码有三处值得说。ColumnStyles里方法列和按钮列用Absolute,URL 列用Percent(100),效果是窗口拉宽时只有 URL 输入框变长,其余两个不动——这是最符合直觉的行为,反过来会让按钮被拉成一条横幅。SplitContainer的SplitterDistance设 220 是经验值,正好能放四行请求头加三行 JSON,再小就得频繁拖动分割条。SuspendLayout / ResumeLayout包住整个构造过程,控件多的时候能避免逐次重排带来的闪烁。
还有一步别忘:Anchor和Dock在同一层容器里只能二选一,Dock优先。有人给一个已经Dock=Fill的控件再设Anchor,然后困惑为什么没效果,这不是 bug,是属性互斥。
3. 四种请求的统一封装:HttpClient 单例与 HttpRequestMessage 复用边界
界面搭好之后,真正决定这个工具好不好用的是请求层。GET 和 DELETE 通常不带 Body,POST 和 PUT 必须带,但这不是绝对的——有些内部接口 DELETE 也要求传一个 JSON 说明删除原因。所以封装的目标不是「按方法分四个函数」,而是「一个函数,方法名只是个参数」。
3.1 单例 HttpClient 与三个必须调的开关
创建客户端这段代码很短,但三个开关不调,后面会挨个翻车。
// Http.cs —— 进程内唯一客户端,所有请求共用连接池 private static readonly HttpClient Http = CreateClient(); private static HttpClient CreateClient() { var handler = new HttpClientHandler { // 关键开关一:关掉自动跟随重定向,否则 302 会把 POST 悄悄降级成 GET AllowAutoRedirect = false, UseCookies = true, // 关键开关二:服务端开了 gzip 但客户端不解压,响应就是一串乱码二进制 AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }; #if NETFRAMEWORK // 关键开关三:.NET Framework 默认只协商 TLS 1.0,内网新站点直接握手失败 ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; #endif var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) // 全局兜底超时 }; // 关掉 100-continue,某些内网网关会在这多等 1 秒才回 Continue client.DefaultRequestHeaders.ExpectContinue = false; return client; }AllowAutoRedirect = false是最容易被忽略的一个。默认值是true,服务端返回 302 时客户端会自动再发一次请求,而按 RFC 规范,重定向后的请求方法可能被改成 GET,Body 一并丢掉。表现出来就是「我明明发的 POST,服务端日志里是 GET,参数全空」。做测试工具时你要看到的就是原始响应,不能让它替你做决定。
Timeout设在HttpClient上是全局的,作用于整个请求生命周期,包括读响应体。如果接口是流式返回、响应体很大,30 秒可能不够,这时候要用CancellationTokenSource做细粒度控制,而不是把全局超时调到 10 分钟。
3.2 一个 SendAsync 打通四种方法
主流程的关键是把「方法、URL、请求头、Body、Content-Type」这五个输入项统一处理。
// 返回值带上耗时和响应对象,界面层只负责渲染 private async Task<RequestResult> SendAsync( string method, string url, IReadOnlyList<KeyValuePair<string, string>> headers, string body, string contentType, CancellationToken ct) { var sw = Stopwatch.StartNew(); var req = new HttpRequestMessage(new HttpMethod(method.ToUpperInvariant()), url); // GET 原则上不带 Body;但部分内部接口 DELETE 也要求传 JSON,所以只排除 GET if (!string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(body)) { req.Content = new StringContent(body, Encoding.UTF8, contentType); } foreach (var h in headers) { if (string.IsNullOrWhiteSpace(h.Key)) continue; // 先尝试加到请求头;Content-Type、Content-Length 这类属于内容头,得加到 Content 上 if (!req.Headers.TryAddWithoutValidation(h.Key, h.Value)) { req.Content?.Headers.TryAddWithoutValidation(h.Key, h.Value); } // 如果此时 Content 还是 null,说明用户给 GET 加了内容头,直接丢弃即可 } using (var resp = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct)) { // 按响应头声明的字符集解码,没声明就按 UTF-8 兜底 var charset = resp.Content.Headers.ContentType?.CharSet; var encoding = string.IsNullOrEmpty(charset) ? Encoding.UTF8 : Encoding.GetEncoding(charset.Trim('"')); var bytes = await resp.Content.ReadAsByteArrayAsync(); sw.Stop(); return new RequestResult { StatusCode = (int)resp.StatusCode, ReasonPhrase = resp.ReasonPhrase, RawHeaders = resp.Headers.ToString() + resp.Content.Headers, Body = encoding.GetString(bytes), ElapsedMs = sw.ElapsedMilliseconds, SizeBytes = bytes.Length }; } }几个参数解释一下。HttpCompletionOption.ResponseHeadersRead表示响应头一到就返回,不等整个 Body 下载完,配合后面的ReadAsByteArrayAsync才能准确量出「服务端处理耗时」和「传输耗时」的分界。TryAddWithoutValidation而不是Add,是因为Add会校验头部格式,用户填个带中文的自定义头就直接抛异常,而测试工具恰恰需要能发出非标准请求。
charset.Trim('"')这个处理不是多余的。有些服务端返回Content-Type: application/json; charset="utf-8",带引号,Encoding.GetEncoding收到引号会抛ArgumentException,报错信息是「编码名称无效」,完全看不出问题在哪。
3.3 UI 线程、取消与按钮状态
请求方法返回Task,调用方必须await,而且要包在try/catch里。
// 界面层调用示例 private async void OnSendClick(object sender, EventArgs e) { if (!Uri.TryCreate(_txtUrl.Text.Trim(), UriKind.Absolute, out var uri)) { MessageBox.Show("URL 格式不合法,需要带 http:// 或 https://"); return; } _btnSend.Enabled = false; _btnCancel.Enabled = true; _lblStatus.Text = "请求中…"; _cts = new CancellationTokenSource(); try { var result = await SendAsync( _cboMethod.SelectedItem.ToString(), uri.ToString(), ReadHeadersFromGrid(), _txtBody.Text, _cboContentType.SelectedItem.ToString(), _cts.Token); _txtResp.Text = result.Body; _lblStatus.Text = $"{result.StatusCode} {result.ReasonPhrase} · {result.ElapsedMs} ms · {result.SizeBytes} B"; } catch (TaskCanceledException) { _lblStatus.Text = "已取消"; } catch (HttpRequestException ex) { _txtResp.Text = "请求失败:" + ex.Message + Environment.NewLine + "内部异常:" + ex.InnerException?.Message; } finally { _btnSend.Enabled = true; _btnCancel.Enabled = false; _cts?.Dispose(); _cts = null; } }async void在这里是必须的,因为它是事件处理器;但除了事件处理器以外,任何地方都不要写async void,异常会直接打到未捕获异常处理器上,进程崩掉且没有堆栈。
catch (HttpRequestException)里把InnerException.Message也打出来,是血泪经验。外层的Message往往是「发送请求时出错」这种无信息量的句子,真正的原因(比如「远程名称无法解析」「基础连接已关闭」)藏在内层。少了这一行,排查时间凭空多半小时。
4. URL 与 Body 的组装:Query 转义、三种 Content-Type 与编码分叉
请求发不出去,十次里有六次是 URL 或 Body 拼错了,而不是网络问题。这一层值得单独拿出来做。
4.1 Query 拼接:EscapeDataString 和 UrlEncode 不是一回事
界面上给一个「Query 参数」表格,两列键值,发请求前拼到 URL 上。
// 把键值对拼成 query string,已存在 ? 时用 & 续接 public static string BuildUrl(string baseUrl, IEnumerable<KeyValuePair<string, string>> query) { var list = query?.Where(kv => !string.IsNullOrWhiteSpace(kv.Key)).ToList(); if (list == null || list.Count == 0) return baseUrl; var sb = new StringBuilder(baseUrl); sb.Append(baseUrl.Contains('?') ? '&' : '?'); for (int i = 0; i < list.Count; i++) { if (i > 0) sb.Append('&'); // 键和值都要转义;值允许为 null,转成空串 sb.Append(Uri.EscapeDataString(list[i].Key)); sb.Append('='); sb.Append(Uri.EscapeDataString(list[i].Value ?? string.Empty)); } return sb.ToString(); }必须用Uri.EscapeDataString而不是HttpUtility.UrlEncode,区别在空格:前者编成%20,后者编成+。两者在 query string 里都合法,但一旦这个值本身要作为另一个 URL 的参数(回调地址、跳转链接),+会被下游当成字面加号,参数就串了。
还有一个反复出现的坑:URL 里已经有?但用户又手动在末尾加了&,拼出来变成?a=1&&b=2。上面的写法靠Contains('?')判断,能挡住大部分情况,但挡不住用户输入?后什么都不填。所以在发送前加一道清洗:把连续的&&和结尾的?/&去掉。
4.2 三种 Content-Type 的构造差异
Body 类型下拉给三个选项,对应的构造方式完全不同:
| Content-Type | 典型场景 | 构造方式 | 易错点 |
|---|---|---|---|
| application/json | 前后端 REST 接口 | 原文 + UTF-8 编码 | 手写 JSON 少逗号/多逗号 |
| application/x-www-form-urlencoded | 老式表单接口 | 键值对按 query 规则拼接 | 忘记转义中文和& |
| multipart/form-data | 文件上传 | MultipartFormDataContent | 手拼 boundary |
前两种可以走同一个StringContent分支,第三种必须单独处理:
// 表单类型:把「键=值」按行解析后拼成 a=1&b=2 private static string BuildFormBody(string rawText) { var pairs = new List<KeyValuePair<string, string>>(); foreach (var line in rawText.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) { var idx = line.IndexOf('='); if (idx <= 0) continue; // 没有等号或键为空,跳过 pairs.Add(new KeyValuePair<string, string>( line.Substring(0, idx).Trim(), line.Substring(idx + 1).Trim())); } return BuildUrl(string.Empty, pairs).TrimStart('?'); } // 文件上传:交给框架管 boundary,别自己拼 private static MultipartFormDataContent BuildMultipart(string filePath, string fieldName) { var form = new MultipartFormDataContent(); var stream = File.OpenRead(filePath); var file = new StreamContent(stream); file.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); // 第三个参数是 filename,服务端靠它识别文件名,漏了会变成 "blob" form.Add(file, fieldName, Path.GetFileName(filePath)); return form; }BuildFormBody里复用了BuildUrl,把 baseUrl 传空串,然后TrimStart('?')去掉开头那个问号。这种复用看起来有点绕,但能保证转义规则只有一份,不会出现 query 用一种转义、body 用另一种的割裂。
MultipartFormDataContent的 boundary 是自动生成的,格式形如--8d4f...。有人为了「可控」自己拼字符串,结果 boundary 和内容里某段字节撞了,服务端解析直接错位。这个后悔药很难吃,交给框架就行。
4.3 中文编码:UTF-8 是默认,GBK 是历史债
new StringContent(body, Encoding.UTF8, contentType)这一行同时做了两件事:按 UTF-8 编码字节,并把charset=utf-8写进 Content-Type。如果对接的是一个十几年前的老系统,它可能只认 GBK。
判断方法很直接:请求发出去了,服务端返回「参数解析失败」或者存进库的是问号,先看服务端的编码配置。如果是 .NET Framework 写的服务端且 web.config 里写了globalization requestEncoding="gb2312",那客户端就得跟着用 GBK。
但这里有个坑:.NET Core / .NET 5+ 默认不带 GBK 编码提供程序,Encoding.GetEncoding("gbk")会抛NotSupportedException。
// .NET Core 及以上需要显式注册代码页编码提供程序 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); // 注册之后才能拿到 GBK / GB2312 var gbk = Encoding.GetEncoding("gb2312"); var content = new StringContent(body, gbk); content.Headers.ContentType = new MediaTypeHeaderValue(contentType) { CharSet = "gb2312" };注意StringContent的构造函数只接受 encoding 和 mediaType 字符串,它会自带一个charset=。如果用的是自定义编码,最好按上面这样构造完之后再覆写ContentType,把CharSet明确写死,避免服务端按默认 UTF-8 解析。
5. 排查清单:五类请求翻车现场与定位方法
工具做完了,真正花时间的是用它的时候遇到的各种「明明应该通」。下面五条是我自己踩过、也被别人问过最多的。
5.1 界面假死与线程相关的两个坑
现象一:点了发送,窗口整个白掉,拖不动,任务管理器显示「未响应」。
原因基本是把同步请求写在了按钮事件里。Http.GetAsync(...).Result或.Wait()在 UI 线程上调用时,会同步阻塞等待任务完成,而这个任务的后续部分又需要回到 UI 线程(因为捕获了SynchronizationContext),两边互等,直接死锁。这是最经典的.Result死锁。
解决:事件处理器写成async void,里面用await,不要用.Result/.Wait()/.GetAwaiter().GetResult()。如果真的必须在同步方法里调异步,用Task.Run(() => ...).GetAwaiter().GetResult()把上下文切走,但这只是权宜之计。
现象二:第一次点正常,第二次点报InvalidOperationException: 无法访问已关闭的流。
原因多半是复用了HttpRequestMessage。这个对象实现了IDisposable,SendAsync完成后它内部的 Content 流已经被消费关闭,再发一次就炸。HttpRequestMessage是一次性的,每次请求都要 new 一个;可以复用的是HttpClient,不是它。
解决:把new HttpRequestMessage(...)放在方法内部,别提到字段上。同理,StringContent也是一次性的。
5.2 响应乱码、请求头丢失与重定向
现象三:响应区显示一堆锟斤拷或者问号菱形。
原因有两层。第一层是没按响应头里的 charset 解码,直接Encoding.UTF8.GetString(bytes),而服务端实际返回 GBK。第二层更隐蔽:HttpClient在AutomaticDecompression未开启时收到 gzip 响应,拿到的是压缩后的二进制,任何编码解出来都是乱码。
解决:先看响应头里有没有Content-Encoding: gzip,有就是解压问题,开AutomaticDecompression;没有就看Content-Type里的charset,按它解码。工具里最好加一个「按 GBK 重新解码」的按钮,原始字节留着,二次解码不用重发请求。
现象四:明明在请求头表格里填了Content-Type: application/json,服务端还是收到表单格式。
原因在TryAddWithoutValidation的返回值。Content-Type属于内容头(content header),不属于请求头(request header),req.Headers.TryAddWithoutValidation会返回false,然后代码进了req.Content?.Headers分支——但如果这时Content是null(比如用户选了 GET),这次添加就被静默丢弃了。
解决:不要依赖用户填Content-Type,用界面上的下拉框生成它,通过StringContent的构造参数传进去。用户填在表格里的同名头,在发送前过滤掉,避免两处冲突。
现象五:POST 请求发出去,服务端日志显示收到的是 GET,Body 为空。
原因就是第 3.1 节说的自动重定向。服务端返回 301/302,HttpClient自动跟随,按规范把非 GET/HEAD 的方法降级成 GET,Body 一并丢弃。整个过程客户端无感知,看起来就像「POST 变成了 GET」。
解决:AllowAutoRedirect = false,把 3xx 响应原样显示出来,Location头也显示出来,由使用者决定要不要手动跟一次。做测试工具时,替使用者做决定永远不如把真相摆出来。
5.3 超时与连接复用的边界
补充一条:请求偶发卡满 30 秒后超时,重试又好了。
这不是代码问题,多半是服务端连接池里有一条已经失效的长连接,客户端复用了它,发出去才发现对端已经关了。TCP 层面要等到重传超时才会报错。生产环境可以调ServicePointManager.MaxServicePointIdleTime缩短空闲连接存活时间,工具场景更简单——超时就把HttpClient的Timeout当成兜底,让用户点重试。
6. 从单发到批量:响应落表与一层轻断言
单个 URL 调试顺了,很快就会遇到下一个需求:手上有一百个接口要回归,一个个点不现实。这个工具的批量模式我是这么做的——界面上加一个「批量」页签,接受 CSV 或纯文本,一行一个请求,格式是方法,URL,期望状态码,Body,跳过空行和#开头的注释行。
// 批量执行:顺序发,别并发,很多内网服务的限流阈值是个位数 private async Task RunBatchAsync(string filePath, CancellationToken ct) { var lines = File.ReadAllLines(filePath) .Select(l => l.Trim()) .Where(l => l.Length > 0 && !l.StartsWith("#")); _gridResult.Rows.Clear(); foreach (var line in lines) { ct.ThrowIfCancellationRequested(); var parts = line.Split(',', 4); // 只切前三个逗号,Body 里含逗号不受影响 if (parts.Length < 3) continue; var idx = _gridResult.Rows.Add(); try { var r = await SendAsync(parts[0], parts[1], null, parts.Length > 3 ? parts[3] : null, "application/json", ct); int expect = int.Parse(parts[2]); var row = _gridResult.Rows[idx]; row.Cells["colMethod"].Value = parts[0]; row.Cells["colUrl"].Value = parts[1]; row.Cells["colCode"].Value = r.StatusCode; row.Cells["colPass"].Value = r.StatusCode == expect ? "PASS" : "FAIL"; row.Cells["colMs"].Value = r.ElapsedMs; } catch (Exception ex) { // 单条失败不能中断整批,记录原因继续 _gridResult.Rows[idx].Cells["colPass"].Value = "ERROR"; _gridResult.Rows[idx].Cells["colUrl"].Value = ex.Message; } } }Split(',', 4)这个 4 是重点。如果写成Split(','),Body 里 JSON 的逗号会被当成字段分隔符,{"a":1,"b":2}直接被切成两半。限定切分次数为 4,前三段是方法、URL、期望码,剩下的全归 Body。
批量表格有四列结果值得留着:状态码、PASS/FAIL、耗时、错误原因。耗时那一列平时没人看,一旦某天整体从 80ms 涨到 3000ms,它就是唯一能拿出来的对比数据。
断言只做一层:状态码匹配就算 PASS,不做 JSON 字段级断言。理由很实在——字段级断言需要一套路径表达式语法,做着做着就变成重写一个半吊子的测试框架,收益远小于成本。真要做字段校验,我更倾向于让接口自己返回一个"ok": true,用 Body 里Contains("\"ok\":true")判断,够用且零学习成本。
一个具体技巧:批量跑之前先把HttpClient.Timeout单独调小到 5 秒。批量场景里有 80% 的失败是「某个接口挂了导致整批卡住」,默认 30 秒超时乘以 100 条,最坏情况要等 50 分钟才跑完。超时缩短到 5 秒,同样的量 8 分钟内出结果,挂掉的那几个单独重跑就行。
这个习惯是被坑出来的。早些年我做过一次全量回归,脚本用的是默认超时,跑到第 17 条时某个服务端假死,界面上进度条一动不动,我以为是工具本身卡住了,等了快十分钟才反应过来是超时没设。从那以后我每次碰批量请求,都强制先把单条超时压到能接受的最小值,再开始跑,宁可多几轮重试,也不要一整批被一个接口拖死。
工具本身没什么高深的东西,难的是把「重定向」「编码」「一次性对象」「UI 线程」这几个边界都处理干净。资源包里的工程可以直接编译运行,先拿自己的服务试一条 GET 打通,再按上面的顺序逐个开开关,比从零写一遍省事得多。希望帮到你。
本文还有配套的精品资源,点击获取