首页
/ Playwright .NET API 测试实战:用 C 与 APIRequestContext 打通 Web API 与 E2E 测试

Playwright .NET API 测试实战:用 C 与 APIRequestContext 打通 Web API 与 E2E 测试

2026-09-06 18:43:53作者:田桥桑Industrious

Playwright for .NET 允许你不打开任何页面、不执行浏览器内 JavaScript,直接用 C# 发起 HTTP(S) 请求来测试应用的服务端 REST API。本文围绕 API 测试官方指南展开,系统讲解如何使用 APIRequestContext 编写纯 API 测试、在浏览器用例中通过 API 准备/校验服务端状态,以及在 APIRequestContextBrowserContext 之间复用登录态,并给出可在 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.RequestPage.Request 访问——注意它们返回的是同一个实例page.Request 只是 page.Context().Request 的快捷方式)。

与之相对,你还可以通过 APIRequest.NewContextAsyncAPIRequest 类,见 class-apirequest.md)创建一个独立的、隔离的 API 请求上下文:

  • BrowserContext / Page 提供的 APIRequestContext 与浏览器共享同一个 Cookie 容器(cookie jar)
    • 每次外发 API 请求会自动带上该上下文的 Cookie,无需手动读取后拼接 Cookie 头;
    • API 响应中的 Set-Cookie 会写回 BrowserContext,后续的页面导航与 API 调用都能立即生效;
    • "通过 API 登录就等同于通过浏览器登录",反之亦然。
  • 通过 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"作为贯穿全文的实战案例,测试套件的完整流程为:

  1. 运行测试前创建测试仓库;
  2. 通过 API 创建若干 Issue 并校验服务端状态;
  3. 运行测试后删除测试仓库。

先决条件: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 需要鉴权,因此先配置一个"对所有测试生效"的请求上下文:把 AuthorizationAccept 放进 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.XunitMicrosoft.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,遍历数组核对 titlebody 是否与提交一致。

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 还支持 Formapplication/x-www-form-urlencoded)与 Multipartmultipart/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 列表页,用 LocatorAssertionsExpect(...).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,因此 PageRequest(浏览器上下文自带的 API 请求上下文)同时可用——二者共享 Cookie,浏览器刚登录的会话可以直接用于 API 校验。

六、复用认证状态:storageState 在 API 上下文与浏览器上下文之间互通

Web 应用普遍采用基于 Cookie 或 Token 的认证,认证状态以 Cookie 形式存放。Playwright 提供 APIRequestContext.StorageStateAsync() 方法,可从已认证的上下文中取出存储状态,再用它创建携带该状态的新上下文。

关键点在于:存储状态在 BrowserContextAPIRequestContext 之间是可互换的。因此可以"先用 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 });

上述代码展示了两种能力:

  1. HttpCredentialsNewContextAsync 可直接携带 HTTP Basic/Digest 认证凭据(用户名 user、密码 passwd);
  2. StorageStateAsync:登录完成后把 Cookie 快照取出。C# 端返回的 storage state 是 JSON 字符串;BrowserContext.NewContextAsyncStorageState 选项接受它作为初始化凭据。

class-apirequestcontext.mdstorageState 返回结构的描述看,其快照包含 cookies 数组(每项含 namevaluedomainpathexpires(Unix 秒)、httpOnlysecuresameSite)以及 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 测试套件的推荐分层是:

  1. APIRequestContext纯 API 层测试(速度最快);
  2. 用 API 准备数据,让 UI 用例专注渲染与交互断言;
  3. 用 API 校验操作结果,弥补 UI 断言无法触及的服务端逻辑;
  4. storageState 打通 API 登录与浏览器会话,消除 UI 登录的脆弱性。

若想继续深入,可在当前仓库内查阅以下材料:

登录后查看全文
热门项目推荐
相关项目推荐