Playwright .NET API 测试实战:用 C 与 APIRequestContext 打通 Web API 与 E2E 测试
Playwright for .NET 允许你不打开任何页面、不执行浏览器内 JavaScript,直接用 C# 发起 HTTP(S) 请求来测试应用的服务端 REST API。本文围绕 API 测试官方指南展开,系统讲解如何使用 APIRequestContext 编写纯 API 测试、在浏览器用例中通过 API 准备/校验服务端状态,以及在 APIRequestContext 与 BrowserContext 之间复用登录态,并给出可在 MSTest、NUnit、xUnit、xUnit v3 四种框架下直接运行的完整示例。读完本文,你将掌握一套"API 层 + UI 层"互相配合的 .NET 全栈测试方案。
一、为什么需要在 .NET 中直接测试 API
Playwright 的浏览器能力广为人知,但它的 API 测试能力同样重要。原文档明确给出了三种典型场景,其共同点是"不想为了一个 HTTP 请求而启动页面、在页面里运行 JS":
- 测试你的服务端 API:对
/api/**之类的端点做纯接口层验证; - 在访问 Web 应用之前准备服务端状态:例如先通过 API 创建数据(仓库、用户、Issue、订单),再让浏览器用例基于这些数据执行 UI 流程;
- 在浏览器操作结束后校验服务端状态:例如在页面上提交表单后,通过 API 核对后端是否真的落库。
这三种场景都可以通过 APIRequestContext 的方法完成。APIRequestContext 是 Playwright 官方 API 文档中的一等公民(class-apirequestcontext.md,自 v1.16 引入),其定位在文档中描述为"用于 Web API 测试,可触发 API 端点、配置微服务、为 E2E 测试准备环境或服务状态"。
二、核心类型与上下文管理模型
在进入代码之前,先理清这一组 API 的类型关系,这对理解"登录态互通"机制至关重要。
APIRequestContext 的两种来源
根据 class-apirequestcontext.md,每个 Playwright BrowserContext 都关联一个 APIRequestContext,通过 BrowserContext.Request 或 Page.Request 访问——注意它们返回的是同一个实例(page.Request 只是 page.Context().Request 的快捷方式)。
与之相对,你还可以通过 APIRequest.NewContextAsync(APIRequest 类,见 class-apirequest.md)创建一个独立的、隔离的 API 请求上下文:
- 由
BrowserContext/Page提供的APIRequestContext与浏览器共享同一个 Cookie 容器(cookie jar):- 每次外发 API 请求会自动带上该上下文的 Cookie,无需手动读取后拼接
Cookie头; - API 响应中的
Set-Cookie会写回BrowserContext,后续的页面导航与 API 调用都能立即生效; - "通过 API 登录就等同于通过浏览器登录",反之亦然。
- 每次外发 API 请求会自动带上该上下文的 Cookie,无需手动读取后拼接
- 通过
APIRequest.NewContextAsync独立创建的上下文拥有自己的隔离 Cookie 存储,适合不想与浏览器共享 Cookie 的场景。
上面这条"Cookie 双向同步"设计,正是本文第六节"复用认证状态"能够成立的地基,也是在第五、六节里"API 造数 → UI 可见"之所以无需手动登录的原因。
主要方法速览
APIRequestContext 提供的请求方法(详见 class-apirequestcontext.md):
| 方法 | 对应 HTTP 方法 | 说明 |
|---|---|---|
FetchAsync |
任意(Method 参数指定) |
通用请求入口,未指定时默认 GET |
GetAsync |
GET | 可用 Params 序列化查询字符串 |
PostAsync |
POST | 可用 DataObject(JSON 序列化)、Form/Multipart 发送请求体 |
PutAsync |
PUT | 同上 |
PatchAsync |
PATCH | 同上 |
DeleteAsync |
DELETE | 同上 |
HeadAsync |
HEAD | 同上 |
StorageStateAsync |
— | 返回当前上下文的 Cookie/LocalStorage 快照 |
DisposeAsync |
— | 释放上下文资源 |
这些方法都会自动从上下文取 Cookie 填到请求、把响应的 Cookie 回写上下文,并自动跟随重定向。CreateFormData(v1.23+)可用于构造表单与 multipart 数据。
上下文级选项与默认值
通过 APIRequest.NewContextAsync 创建独立上下文时,class-apirequest.md 记录了这些关键选项及其默认值:
| 选项 | 默认值/说明 |
|---|---|
BaseURL |
无默认值;设置后,请求可传相对路径,按 URL() 构造函数规则拼接 |
Timeout |
默认 30000 ms(30 秒),传 0 表示禁用超时 |
MaxRedirects |
默认 20,传 0 表示不跟随重定向,可在单次请求中覆盖 |
FailOnStatusCode |
v1.51+,为 true 时对非 2xx/3xx 抛出异常;默认对任何状态码都返回响应对象 |
ExtraHTTPHeaders |
为所有请求附加额外请求头 |
HttpCredentials |
HTTP 认证凭据(用户名/密码) |
IgnoreHTTPSErrors |
是否忽略 HTTPS 证书错误 |
Proxy |
代理配置 |
StorageState / StorageStatePath |
用已保存的登录态初始化上下文 |
在仓库的驱动实现中,这套逻辑落在与语言无关的服务端模块 packages/playwright-core/src/server/fetch.ts,它基于 Node 的 http/https 模块实现了请求发送、Cookie 存取、重定向与 TLS 处理;.NET 客户端通过 Playwright 驱动与该实现通信。也就是说,"同一个 API 测试能力"在 JS/Python/Java/C# 各语言 SDK 背后共享同一套驱动内核。
三、动手编写 API 测试(以 GitHub API 为例)
原文档用"GitHub API 管理 Issue"作为贯穿全文的实战案例,测试套件的完整流程为:
- 运行测试前创建测试仓库;
- 通过 API 创建若干 Issue 并校验服务端状态;
- 运行测试后删除测试仓库。
先决条件:GitHub 账号与 Personal Access Token。原文档约定通过环境变量读取,测试代码本身不硬编码任何机密:
GITHUB_API_TOKEN:访问令牌(在 GitHub 的Authorization: token <TOKEN>头中使用);GITHUB_USER:GitHub 用户名。
前置说明:以下代码都继承自 Playwright 提供的测试基类(见 test-runners-csharp.md)。
PlaywrightTest基类为每个测试创建一个Playwright实例(MSTest 用[TestClass]+Microsoft.Playwright.MSTest、NUnit 用[TestFixture]、xUnit/xUnit v3 用[Fact]);若需要Page实例,则继承PageTest。Playwright 与 Browser 实例会在测试间复用以提升性能,官方推荐每个测试运行在全新的BrowserContext中以保证状态隔离。
3.1 配置共享请求上下文
GitHub API 需要鉴权,因此先配置一个"对所有测试生效"的请求上下文:把 Authorization 与 Accept 放进 ExtraHTTPHeaders,同时设置 BaseURL = "https://api.github.com",这样后续测试只需写 /repos/... 这样的相对路径。四个测试框架的配置类写法如下。
MSTest:
using Microsoft.Playwright;
using Microsoft.Playwright.MSTest;
namespace PlaywrightTests;
[TestClass]
public class TestGitHubAPI : PlaywrightTest
{
static string? API_TOKEN = Environment.GetEnvironmentVariable("GITHUB_API_TOKEN");
private IAPIRequestContext Request = null!;
[TestInitialize]
public async Task SetUpAPITesting()
{
await CreateAPIRequestContext();
}
private async Task CreateAPIRequestContext()
{
var headers = new Dictionary<string, string>();
// 按 GitHub 官方指引设置该头。
headers.Add("Accept", "application/vnd.github.v3+json");
// 为所有请求附加授权令牌(假设 token 已存在于环境变量)。
headers.Add("Authorization", "token " + API_TOKEN);
Request = await this.Playwright.APIRequest.NewContextAsync(new() {
// 后续所有请求都发往该 API 端点。
BaseURL = "https://api.github.com",
ExtraHTTPHeaders = headers,
});
}
[TestCleanup]
public async Task TearDownAPITesting()
{
await Request.DisposeAsync();
}
}
NUnit: 命名空间换为 Microsoft.Playwright.NUnit + NUnit.Framework,类上标注 [Parallelizable(ParallelScope.Self)] 与 [TestFixture],前/后置钩子换为 [SetUp] / [TearDown],其余完全相同:
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
using NUnit.Framework;
namespace PlaywrightTests;
[Parallelizable(ParallelScope.Self)]
[TestFixture]
public class TestGitHubAPI : PlaywrightTest
{
static string? API_TOKEN = Environment.GetEnvironmentVariable("GITHUB_API_TOKEN");
private IAPIRequestContext Request = null!;
[SetUp]
public async Task SetUpAPITesting()
{
await CreateAPIRequestContext();
}
private async Task CreateAPIRequestContext()
{
var headers = new Dictionary<string, string>();
headers.Add("Accept", "application/vnd.github.v3+json");
headers.Add("Authorization", "token " + API_TOKEN);
Request = await this.Playwright.APIRequest.NewContextAsync(new() {
BaseURL = "https://api.github.com",
ExtraHTTPHeaders = headers,
});
}
[TearDown]
public async Task TearDownAPITesting()
{
await Request.DisposeAsync();
}
}
xUnit / xUnit v3: 命名空间分别为 Microsoft.Playwright.Xunit 与 Microsoft.Playwright.Xunit.v3,不使用特性标记钩子,而是覆写基类的 InitializeAsync / DisposeAsync 虚方法(注意先调用 base.InitializeAsync() / base.DisposeAsync()):
using Microsoft.Playwright;
using Microsoft.Playwright.Xunit;
namespace PlaywrightTests;
public class TestGitHubAPI : PlaywrightTest
{
static string? API_TOKEN = Environment.GetEnvironmentVariable("GITHUB_API_TOKEN");
private IAPIRequestContext Request = null!;
public override async Task InitializeAsync()
{
await base.InitializeAsync();
await CreateAPIRequestContext();
}
private async Task CreateAPIRequestContext()
{
var headers = new Dictionary<string, string>();
headers.Add("Accept", "application/vnd.github.v3+json");
headers.Add("Authorization", "token " + API_TOKEN);
Request = await this.Playwright.APIRequest.NewContextAsync(new() {
BaseURL = "https://api.github.com",
ExtraHTTPHeaders = headers,
});
}
public override async Task DisposeAsync()
{
await Request.DisposeAsync();
await base.DisposeAsync();
}
}
xUnit v3 的唯一差异是把 using Microsoft.Playwright.Xunit; 换成 using Microsoft.Playwright.Xunit.v3;。
关于并行度,test-runners-csharp.md 给出了官方建议:默认按"每个类并行、类内顺序执行"的方式运行;可用 CLI 参数调整,例如 NUnit 用 dotnet test -- NUnit.NumberOfTestWorkers=5,MSTest 用 -- MSTest.Parallelize.Workers=4,xUnit/xUnit v3 用 -- xUnit.MaxParallelThreads=5。CPU 密集测试建议 worker 数为核数的一半,IO 密集测试可等于核数。
3.2 编写测试用例:创建并校验 Issue
上下文就绪后,就可以写真正的 API 测试了。以"创建 Bug 报告"和"创建功能请求"两个用例为例,流程是:PostAsync 创建 Issue → Expect(newIssue).ToBeOKAsync() 断言 HTTP 成功 → GetAsync 拉取 Issue 列表 → 用 System.Text.Json 解析 JSON,遍历数组核对 title 与 body 是否与提交一致。
MSTest 完整用例:
using System.Text.Json;
using Microsoft.Playwright;
using Microsoft.Playwright.MSTest;
namespace PlaywrightTests;
[TestClass]
public class TestGitHubAPI : PlaywrightTest
{
static string REPO = "test";
static string USER = Environment.GetEnvironmentVariable("GITHUB_USER");
static string? API_TOKEN = Environment.GetEnvironmentVariable("GITHUB_API_TOKEN");
private IAPIRequestContext Request = null!;
[TestMethod]
public async Task ShouldCreateBugReport()
{
var data = new Dictionary<string, string>
{
{ "title", "[Bug] report 1" },
{ "body", "Bug description" }
};
var newIssue = await Request.PostAsync("/repos/" + USER + "/" + REPO + "/issues", new() { DataObject = data });
await Expect(newIssue).ToBeOKAsync();
var issues = await Request.GetAsync("/repos/" + USER + "/" + REPO + "/issues");
await Expect(newIssue).ToBeOKAsync();
var issuesJsonResponse = await issues.JsonAsync();
JsonElement? issue = null;
foreach (JsonElement issueObj in issuesJsonResponse?.EnumerateArray())
{
if (issueObj.TryGetProperty("title", out var title) == true)
{
if (title.GetString() == "[Bug] report 1")
{
issue = issueObj;
}
}
}
Assert.IsNotNull(issue);
Assert.AreEqual("Bug description", issue?.GetProperty("body").GetString());
}
[TestMethod]
public async Task ShouldCreateFeatureRequests()
{
var data = new Dictionary<string, string>
{
{ "title", "[Feature] request 1" },
{ "body", "Feature description" }
};
var newIssue = await Request.PostAsync("/repos/" + USER + "/" + REPO + "/issues", new() { DataObject = data });
await Expect(newIssue).ToBeOKAsync();
var issues = await Request.GetAsync("/repos/" + USER + "/" + REPO + "/issues");
await Expect(newIssue).ToBeOKAsync();
var issuesJsonResponse = await issues.JsonAsync();
JsonElement? issue = null;
foreach (JsonElement issueObj in issuesJsonResponse?.EnumerateArray())
{
if (issueObj.TryGetProperty("title", out var title) == true)
{
if (title.GetString() == "[Feature] request 1")
{
issue = issueObj;
}
}
}
Assert.IsNotNull(issue);
Assert.AreEqual("Feature description", issue?.GetProperty("body").GetString());
}
// ...
}
代码中有几处要点值得展开:
- 请求体发送:
PostAsync的第 2 个参数new() { DataObject = data }会把Dictionary自动序列化为 JSON 请求体。除此之外,APIRequestContext还支持Form(application/x-www-form-urlencoded)与Multipart(multipart/form-data,可用于文件上传,配合FilePayload构造内存中的文件内容),以及Params(序列化为查询字符串,C# 端另有ParamsString)——完整可选项见 class-apirequestcontext.md 中各方法的选项清单。 - 响应断言:
await Expect(newIssue).ToBeOKAsync()来自 APIResponseAssertions(v1.18+),语义是断言响应状态码为 2xx/3xx 成功范围。 - 响应体读取:
JsonAsync()返回 JSON 表示(C# 端返回字符串,需要你自己用System.Text.Json解析,因此示例中用JsonElement/EnumerateArray()遍历);另有TextAsync()(原始文本)、BodyAsync()(字节)、Status/Ok(状态信息)、Headers/HeadersArray(响应头)等成员,详见 class-apiresponse.md。值得留意的是,响应的 body 会驻留内存直到上下文释放,如需提前释放可调用DisposeAsync()。
其他框架的差异只体现在特性与断言风格上,测试方法体完全一致:
- NUnit:
[Test]标注用例;断言用Assert.That(issue, Is.Not.Null)、Assert.That(issue?.GetProperty("body").GetString(), Is.EqualTo("Bug description"));类上加[Parallelizable(ParallelScope.Self)]+[TestFixture]。 - xUnit / xUnit v3:
[Fact]标注用例;断言用Assert.NotNull(issue)、Assert.Equal("Bug description", ...)。
3.3 通过 Setup/Teardown 管理仓库生命周期
上述用例默认仓库已存在。更稳妥的工程化做法是:测试前创建仓库、测试后删除仓库,保证测试可重复运行、不留垃圾数据。MSTest 利用 [TestInitialize]/[TestCleanup] 钩子:
using System.Text.Json;
using Microsoft.Playwright;
using Microsoft.Playwright.MSTest;
namespace PlaywrightTests;
[TestClass]
public class TestGitHubAPI : PlaywrightTest
{
// ...
[TestInitialize]
public async Task SetUpAPITesting()
{
await CreateAPIRequestContext();
await CreateTestRepository();
}
private async Task CreateTestRepository()
{
var resp = await Request.PostAsync("/user/repos", new()
{
DataObject = new Dictionary<string, string>()
{
["name"] = REPO,
},
});
await Expect(resp).ToBeOKAsync();
}
[TestCleanup]
public async Task TearDownAPITesting()
{
await DeleteTestRepository();
await Request.DisposeAsync();
}
private async Task DeleteTestRepository()
{
var resp = await Request.DeleteAsync("/repos/" + USER + "/" + REPO);
await Expect(resp).ToBeOKAsync();
}
}
四个框架的前/后置时机对应关系如下:
| 框架 | 前置钩子 | 后置钩子 | 说明 |
|---|---|---|---|
| MSTest | [TestInitialize] |
[TestCleanup] |
注解驱动 |
| NUnit | [SetUp] |
[TearDown] |
注解驱动,类需 [TestFixture] |
| xUnit | 覆写 InitializeAsync |
覆写 DisposeAsync |
记得调用 base.InitializeAsync() / base.DisposeAsync() |
| xUnit v3 | 同 xUnit | 同 xUnit | 命名空间为 Microsoft.Playwright.Xunit.v3 |
后置顺序也值得注意:在 xUnit 体系中建议先删仓库、再释放 Request、最后调 base.DisposeAsync(),与 MSTest/NUnit 中"先删仓库再 DisposeAsync"的顺序保持一致,确保删除请求仍使用有效的上下文。
3.4 完整的单文件示例(可直接复制运行)
把 3.1–3.3 的内容合并,就是一个可独立运行的完整 API 测试类。下面给出四个框架各自的完整版本。
MSTest:
using System.Text.Json;
using Microsoft.Playwright;
using Microsoft.Playwright.MSTest;
namespace PlaywrightTests;
[TestClass]
public class TestGitHubAPI : PlaywrightTest
{
static string REPO = "test-repo-2";
static string USER = Environment.GetEnvironmentVariable("GITHUB_USER");
static string? API_TOKEN = Environment.GetEnvironmentVariable("GITHUB_API_TOKEN");
private IAPIRequestContext Request = null!;
[TestMethod]
public async Task ShouldCreateBugReport()
{
var data = new Dictionary<string, string>
{
{ "title", "[Bug] report 1" },
{ "body", "Bug description" }
};
var newIssue = await Request.PostAsync("/repos/" + USER + "/" + REPO + "/issues", new() { DataObject = data });
await Expect(newIssue).ToBeOKAsync();
var issues = await Request.GetAsync("/repos/" + USER + "/" + REPO + "/issues");
await Expect(newIssue).ToBeOKAsync();
var issuesJsonResponse = await issues.JsonAsync();
JsonElement? issue = null;
foreach (JsonElement issueObj in issuesJsonResponse?.EnumerateArray())
{
if (issueObj.TryGetProperty("title", out var title) == true)
{
if (title.GetString() == "[Bug] report 1")
{
issue = issueObj;
}
}
}
Assert.IsNotNull(issue);
Assert.AreEqual("Bug description", issue?.GetProperty("body").GetString());
}
[TestMethod]
public async Task ShouldCreateFeatureRequests()
{
var data = new Dictionary<string, string>
{
{ "title", "[Feature] request 1" },
{ "body", "Feature description" }
};
var newIssue = await Request.PostAsync("/repos/" + USER + "/" + REPO + "/issues", new() { DataObject = data });
await Expect(newIssue).ToBeOKAsync();
var issues = await Request.GetAsync("/repos/" + USER + "/" + REPO + "/issues");
await Expect(newIssue).ToBeOKAsync();
var issuesJsonResponse = await issues.JsonAsync();
JsonElement? issue = null;
foreach (JsonElement issueObj in issuesJsonResponse?.EnumerateArray())
{
if (issueObj.TryGetProperty("title", out var title) == true)
{
if (title.GetString() == "[Feature] request 1")
{
issue = issueObj;
}
}
}
Assert.IsNotNull(issue);
Assert.AreEqual("Feature description", issue?.GetProperty("body").GetString());
}
[TestInitialize]
public async Task SetUpAPITesting()
{
await CreateAPIRequestContext();
await CreateTestRepository();
}
private async Task CreateAPIRequestContext()
{
var headers = new Dictionary<string, string>
{
{ "Accept", "application/vnd.github.v3+json" },
{ "Authorization", "token " + API_TOKEN }
};
Request = await Playwright.APIRequest.NewContextAsync(new()
{
BaseURL = "https://api.github.com",
ExtraHTTPHeaders = headers,
});
}
private async Task CreateTestRepository()
{
var resp = await Request.PostAsync("/user/repos", new()
{
DataObject = new Dictionary<string, string>()
{
["name"] = REPO,
},
});
await Expect(resp).ToBeOKAsync();
}
[TestCleanup]
public async Task TearDownAPITesting()
{
await DeleteTestRepository();
await Request.DisposeAsync();
}
private async Task DeleteTestRepository()
{
var resp = await Request.DeleteAsync("/repos/" + USER + "/" + REPO);
await Expect(resp).ToBeOKAsync();
}
}
NUnit:
using System.Text.Json;
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
using NUnit.Framework;
namespace PlaywrightTests;
[Parallelizable(ParallelScope.Self)]
[TestFixture]
public class TestGitHubAPI : PlaywrightTest
{
static string REPO = "test-repo-2";
static string USER = Environment.GetEnvironmentVariable("GITHUB_USER");
static string? API_TOKEN = Environment.GetEnvironmentVariable("GITHUB_API_TOKEN");
private IAPIRequestContext Request = null!;
[Test]
public async Task ShouldCreateBugReport()
{
var data = new Dictionary<string, string>
{
{ "title", "[Bug] report 1" },
{ "body", "Bug description" }
};
var newIssue = await Request.PostAsync("/repos/" + USER + "/" + REPO + "/issues", new() { DataObject = data });
await Expect(newIssue).ToBeOKAsync();
var issues = await Request.GetAsync("/repos/" + USER + "/" + REPO + "/issues");
await Expect(newIssue).ToBeOKAsync();
var issuesJsonResponse = await issues.JsonAsync();
JsonElement? issue = null;
foreach (JsonElement issueObj in issuesJsonResponse?.EnumerateArray())
{
if (issueObj.TryGetProperty("title", out var title) == true)
{
if (title.GetString() == "[Bug] report 1")
{
issue = issueObj;
}
}
}
Assert.That(issue, Is.Not.Null);
Assert.That(issue?.GetProperty("body").GetString(), Is.EqualTo("Bug description"));
}
[Test]
public async Task ShouldCreateFeatureRequests()
{
var data = new Dictionary<string, string>
{
{ "title", "[Feature] request 1" },
{ "body", "Feature description" }
};
var newIssue = await Request.PostAsync("/repos/" + USER + "/" + REPO + "/issues", new() { DataObject = data });
await Expect(newIssue).ToBeOKAsync();
var issues = await Request.GetAsync("/repos/" + USER + "/" + REPO + "/issues");
await Expect(newIssue).ToBeOKAsync();
var issuesJsonResponse = await issues.JsonAsync();
JsonElement? issue = null;
foreach (JsonElement issueObj in issuesJsonResponse?.EnumerateArray())
{
if (issueObj.TryGetProperty("title", out var title) == true)
{
if (title.GetString() == "[Feature] request 1")
{
issue = issueObj;
}
}
}
Assert.That(issue, Is.Not.Null);
Assert.That(issue?.GetProperty("body").GetString(), Is.EqualTo("Feature description"));
}
[SetUp]
public async Task SetUpAPITesting()
{
await CreateAPIRequestContext();
await CreateTestRepository();
}
private async Task CreateAPIRequestContext()
{
var headers = new Dictionary<string, string>
{
{ "Accept", "application/vnd.github.v3+json" },
{ "Authorization", "token " + API_TOKEN }
};
Request = await Playwright.APIRequest.NewContextAsync(new()
{
BaseURL = "https://api.github.com",
ExtraHTTPHeaders = headers,
});
}
private async Task CreateTestRepository()
{
var resp = await Request.PostAsync("/user/repos", new()
{
DataObject = new Dictionary<string, string>()
{
["name"] = REPO,
},
});
await Expect(resp).ToBeOKAsync();
}
[TearDown]
public async Task TearDownAPITesting()
{
await DeleteTestRepository();
await Request.DisposeAsync();
}
private async Task DeleteTestRepository()
{
var resp = await Request.DeleteAsync("/repos/" + USER + "/" + REPO);
await Expect(resp).ToBeOKAsync();
}
}
xUnit:
using System.Text.Json;
using Microsoft.Playwright;
using Microsoft.Playwright.Xunit;
namespace PlaywrightTests;
public class TestGitHubAPI : PlaywrightTest
{
static string REPO = "test-repo-2";
static string USER = Environment.GetEnvironmentVariable("GITHUB_USER");
static string? API_TOKEN = Environment.GetEnvironmentVariable("GITHUB_API_TOKEN");
private IAPIRequestContext Request = null!;
[Fact]
public async Task ShouldCreateBugReport()
{
var data = new Dictionary<string, string>
{
{ "title", "[Bug] report 1" },
{ "body", "Bug description" }
};
var newIssue = await Request.PostAsync("/repos/" + USER + "/" + REPO + "/issues", new() { DataObject = data });
await Expect(newIssue).ToBeOKAsync();
var issues = await Request.GetAsync("/repos/" + USER + "/" + REPO + "/issues");
await Expect(newIssue).ToBeOKAsync();
var issuesJsonResponse = await issues.JsonAsync();
JsonElement? issue = null;
foreach (JsonElement issueObj in issuesJsonResponse?.EnumerateArray())
{
if (issueObj.TryGetProperty("title", out var title) == true)
{
if (title.GetString() == "[Bug] report 1")
{
issue = issueObj;
}
}
}
Assert.NotNull(issue);
Assert.Equal("Bug description", issue?.GetProperty("body").GetString());
}
[Fact]
public async Task ShouldCreateFeatureRequests()
{
var data = new Dictionary<string, string>
{
{ "title", "[Feature] request 1" },
{ "body", "Feature description" }
};
var newIssue = await Request.PostAsync("/repos/" + USER + "/" + REPO + "/issues", new() { DataObject = data });
await Expect(newIssue).ToBeOKAsync();
var issues = await Request.GetAsync("/repos/" + USER + "/" + REPO + "/issues");
await Expect(newIssue).ToBeOKAsync();
var issuesJsonResponse = await issues.JsonAsync();
JsonElement? issue = null;
foreach (JsonElement issueObj in issuesJsonResponse?.EnumerateArray())
{
if (issueObj.TryGetProperty("title", out var title) == true)
{
if (title.GetString() == "[Feature] request 1")
{
issue = issueObj;
}
}
}
Assert.NotNull(issue);
Assert.Equal("Feature description", issue?.GetProperty("body").GetString());
}
public override async Task InitializeAsync()
{
await base.InitializeAsync();
await CreateAPIRequestContext();
await CreateTestRepository();
}
private async Task CreateAPIRequestContext()
{
var headers = new Dictionary<string, string>
{
{ "Accept", "application/vnd.github.v3+json" },
{ "Authorization", "token " + API_TOKEN }
};
Request = await Playwright.APIRequest.NewContextAsync(new()
{
BaseURL = "https://api.github.com",
ExtraHTTPHeaders = headers,
});
}
private async Task CreateTestRepository()
{
var resp = await Request.PostAsync("/user/repos", new()
{
DataObject = new Dictionary<string, string>()
{
["name"] = REPO,
},
});
await Expect(resp).ToBeOKAsync();
}
public override async Task DisposeAsync()
{
await DeleteTestRepository();
await Request.DisposeAsync();
await base.DisposeAsync();
}
private async Task DeleteTestRepository()
{
var resp = await Request.DeleteAsync("/repos/" + USER + "/" + REPO);
await Expect(resp).ToBeOKAsync();
}
}
xUnit v3: 与 xUnit 版本逐字相同,仅把 using Microsoft.Playwright.Xunit; 换成 using Microsoft.Playwright.Xunit.v3;。
四、通过 API 调用准备服务端状态,再用浏览器校验
这是"API 造数 + UI 验证"的经典组合。下面的用例先用 PostAsync 创建一个 Issue(准备前置数据),随后用浏览器导航到该仓库的 Issues 列表页,用 LocatorAssertions(Expect(...).ToHaveTextAsync)断言新建的 Issue 出现在列表顶部。
要点:本类继承 PageTest 而不是 PlaywrightTest——正如原文档注释所指出的,"继承 PlaywrightTest 只给你 Playwright 实例;要获得 Page,要么手动启动 browser/context/page,要么直接继承会帮你启动一切的 PageTest"。
MSTest:
[TestClass]
public class TestGitHubAPI : PageTest
{
[TestMethod]
public async Task LastCreatedIssueShouldBeFirstInTheList()
{
var data = new Dictionary<string, string>
{
{ "title", "[Feature] request 1" },
{ "body", "Feature description" }
};
var newIssue = await Request.PostAsync("/repos/" + USER + "/" + REPO + "/issues", new() { DataObject = data });
await Expect(newIssue).ToBeOKAsync();
await Page.GotoAsync("https://github.com/" + USER + "/" + REPO + "/issues");
var firstIssue = Page.Locator("a[data-hovercard-type='issue']").First;
await Expect(firstIssue).ToHaveTextAsync("[Feature] request 1");
}
}
NUnit: 类标注 [Parallelizable(ParallelScope.Self)]、[TestFixture],用例用 [Test]:
[Parallelizable(ParallelScope.Self)]
[TestFixture]
public class TestGitHubAPI : PageTest
{
[Test]
public async Task LastCreatedIssueShouldBeFirstInTheList()
{
var data = new Dictionary<string, string>
{
{ "title", "[Feature] request 1" },
{ "body", "Feature description" }
};
var newIssue = await Request.PostAsync("/repos/" + USER + "/" + REPO + "/issues", new() { DataObject = data });
await Expect(newIssue).ToBeOKAsync();
await Page.GotoAsync("https://github.com/" + USER + "/" + REPO + "/issues");
var firstIssue = Page.Locator("a[data-hovercard-type='issue']").First;
await Expect(firstIssue).ToHaveTextAsync("[Feature] request 1");
}
}
xUnit / xUnit v3: 类直接继承 PageTest(无需额外特性),用例标注 [Fact],方法体与上相同(v3 仅换命名空间):
public class TestGitHubAPI : PageTest
{
[Fact]
public async Task LastCreatedIssueShouldBeFirstInTheList()
{
var data = new Dictionary<string, string>
{
{ "title", "[Feature] request 1" },
{ "body", "Feature description" }
};
var newIssue = await Request.PostAsync("/repos/" + USER + "/" + REPO + "/issues", new() { DataObject = data });
await Expect(newIssue).ToBeOKAsync();
await Page.GotoAsync("https://github.com/" + USER + "/" + REPO + "/issues");
var firstIssue = Page.Locator("a[data-hovercard-type='issue']").First;
await Expect(firstIssue).ToHaveTextAsync("[Feature] request 1");
}
}
这种"先把服务端状态准备好,再测 UI 渲染"的方式,让 UI 测试只关心自己的职责(渲染与交互),而不再需要冗长的 UI 造数步骤,显著提升测试速度与稳定性。
五、浏览器操作之后,用 API 校验服务端状态
反向组合同样常见:先在浏览器里完成真实用户操作(UI 流程),结束后通过 API 核对服务端结果。下面的用例在浏览器中依次点击 New Issue、填写 [aria-label='Title'] 与 [aria-label='Comment body']、提交表单,从最终 URL 中取出新 Issue 的 ID,再用 Request.GetAsync 请求该 Issue 页面并断言内容包含标题。
MSTest:
// 若要在用例中使用 Page,务必继承 PageTest。
[TestClass]
public class GitHubTests : PageTest
{
[TestMethod]
public async Task LastCreatedIssueShouldBeOnTheServer()
{
await Page.GotoAsync("https://github.com/" + USER + "/" + REPO + "/issues");
await Page.Locator("text=New Issue").ClickAsync();
await Page.Locator("[aria-label='Title']").FillAsync("Bug report 1");
await Page.Locator("[aria-label='Comment body']").FillAsync("Bug description");
await Page.Locator("text=Submit new issue").ClickAsync();
var issueId = Page.Url.Substring(Page.Url.LastIndexOf('/'));
var newIssue = await Request.GetAsync("https://github.com/" + USER + "/" + REPO + "/issues/" + issueId);
await Expect(newIssue).ToBeOKAsync();
StringAssert.Contains(await newIssue.TextAsync(), "Bug report 1");
}
}
NUnit: 类加 [Parallelizable(ParallelScope.Self)] + [TestFixture],用例用 [Test],断言为 Assert.That(await newIssue.TextAsync(), Does.Contain("Bug report 1"));xUnit / xUnit v3 用例用 [Fact],断言为 Assert.Contains("Bug report 1", await newIssue.TextAsync())(v3 仅命名空间不同)。浏览器交互部分四个框架完全一致:
await Page.GotoAsync("https://github.com/" + USER + "/" + REPO + "/issues");
await Page.Locator("text=New Issue").ClickAsync();
await Page.Locator("[aria-label='Title']").FillAsync("Bug report 1");
await Page.Locator("[aria-label='Comment body']").FillAsync("Bug description");
await Page.Locator("text=Submit new issue").ClickAsync();
var issueId = Page.Url.Substring(Page.Url.LastIndexOf('/'));
var newIssue = await Request.GetAsync("https://github.com/" + USER + "/" + REPO + "/issues/" + issueId);
await Expect(newIssue).ToBeOKAsync();
注意这里继承了 PageTest,因此 Page 与 Request(浏览器上下文自带的 API 请求上下文)同时可用——二者共享 Cookie,浏览器刚登录的会话可以直接用于 API 校验。
六、复用认证状态:storageState 在 API 上下文与浏览器上下文之间互通
Web 应用普遍采用基于 Cookie 或 Token 的认证,认证状态以 Cookie 形式存放。Playwright 提供 APIRequestContext.StorageStateAsync() 方法,可从已认证的上下文中取出存储状态,再用它创建携带该状态的新上下文。
关键点在于:存储状态在 BrowserContext 与 APIRequestContext 之间是可互换的。因此可以"先用 API 调用登录(比如提交登录表单拿到 Set-Cookie),再以该状态创建已登录的浏览器上下文",从而彻底绕开 UI 登录步骤。原文档给出的代码从"已认证的 APIRequestContext"取状态,并据此创建新的 BrowserContext:
var requestContext = await Playwright.APIRequest.NewContextAsync(new()
{
HttpCredentials = new()
{
Username = "user",
Password = "passwd"
},
});
await requestContext.GetAsync("https://api.example.com/login");
// 把存储状态保存到变量中。
var state = await requestContext.StorageStateAsync();
// 用保存的存储状态创建一个新上下文。
var context = await Browser.NewContextAsync(new() { StorageState = state });
上述代码展示了两种能力:
HttpCredentials:NewContextAsync可直接携带 HTTP Basic/Digest 认证凭据(用户名user、密码passwd);StorageStateAsync:登录完成后把 Cookie 快照取出。C# 端返回的 storage state 是 JSON 字符串;BrowserContext.NewContextAsync的StorageState选项接受它作为初始化凭据。
从 class-apirequestcontext.md 对 storageState 返回结构的描述看,其快照包含 cookies 数组(每项含 name、value、domain、path、expires(Unix 秒)、httpOnly、secure、sameSite)以及 origins(含 origin 与其 localStorage)。也就是说,"登录态"不仅包含 Cookie,还包含按源隔离的 LocalStorage 数据,足以让新上下文表现为一个已登录会话。若已把状态保存为文件,也可以用 StorageStatePath 选项直接以文件路径初始化上下文(v1.18+,C#/Java 端点),参见 class-apirequest.md。
把这一机制与前文"Cookie 容器互通"结合,可以得到完整的登录策略矩阵:
| 需求 | 推荐做法 |
|---|---|
| 纯 API 测试,无浏览器 | APIRequest.NewContextAsync 独立上下文 |
| API 准备数据 + 同一用例中浏览器可见 | 用浏览器上下文的 Context.Request/Page.Request(共享 Cookie) |
| 用 API 登录、浏览器免登录 | API 上下文 StorageStateAsync() → Browser.NewContextAsync(StorageState=...) |
| 全局只登录一次、跨用例复用 | 把 storage state 持久化为文件,在各用例的 SetUp 中读取注入 |
七、结语与进一步阅读
通过 APIRequestContext,Playwright for .NET 把"服务端 API 测试"与"浏览器 E2E 测试"统一进了同一套测试框架与断言体系:你可以像测试 UI 一样对响应断言 ToBeOK,也可以让 API 与浏览器通过共享 Cookie 互相"看见"对方的状态。综合来看,一个现代 .NET 测试套件的推荐分层是:
- 用
APIRequestContext做纯 API 层测试(速度最快); - 用 API 准备数据,让 UI 用例专注渲染与交互断言;
- 用 API 校验操作结果,弥补 UI 断言无法触及的服务端逻辑;
- 用 storageState 打通 API 登录与浏览器会话,消除 UI 登录的脆弱性。
若想继续深入,可在当前仓库内查阅以下材料:
- API 测试完整指南(本文依据):docs/src/api-testing-csharp.md
APIRequestContext全量方法、选项与 Cookie 互通语义:class-apirequestcontext.md- 创建独立上下文的可选项(超时、重定向、failOnStatusCode、认证等):class-apirequest.md
- 响应对象能力(JSON/文本/字节、头信息、资源释放):class-apiresponse.md
- 响应断言
ToBeOK:class-apiresponseassertions.md - .NET 测试框架基类与并行策略:test-runners-csharp.md
- .NET 安装与入门:intro-csharp.md
- 语言无关的请求服务端实现(可了解底层如何发请求、管理 Cookie):packages/playwright-core/src/server/fetch.ts
- 仓库内 JS 版的同主题可运行示例(GitHub API 建仓/建 Issue/删仓全流程):examples/github-api
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0625
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00