
Bitwarden Server 集成测试实战ApplicationFactory 模式、内存 SQLite 与意图方法设计【免费下载链接】serverBitwarden infrastructure/backend (API, database, Docker, etc).项目地址: https://gitcode.com/GitHub_Trending/ser/server本文基于 Bitwarden server 仓库的官方文档test/INTEGRATION_TEST.md整理系统讲解如何用 ASP.NET Core 的WebApplicationFactoryTProgram为这个多主机Api、Admin、Identity、Billing 等架构的 .NET 后端编写集成测试如何为每个被测项目封装一个FooApplicationFactory、如何用内存 SQLite 替换真实数据库、如何设计“意图方法intent methods”让测试代码可读且可平滑迁移到真实 Bitwarden 实例以及必须规避的八类反模式。核心原则一张图看懂仓库的集成测试约定test/INTEGRATION_TEST.md在 TL;DR 部分给出了五条硬性约定它们构成了整个test/目录下集成测试的代码组织方式尽量通过HttpClient驱动测试。纯 HTTP 的测试套件未来有一天可以直接跑在真实的 Bitwarden 实例上做更深层的端到端验证。只有当某个操作无法用 HTTP 表达时——例如种子数据 API 根本不暴露、需要强制主机读回某种外部状态、需要直接调用某个命令——才允许使用伸进 DI 容器内部的“意图方法”。这类方法应当被标注出来标记为未来真实实例变体需要跳过的缺口。每个被测项目用一个FooApplicationFactory类封装。该类私有地持有WebApplicationFactoryFoo.Program对外主要暴露HttpClient访问器加上意图方法RegisterUserAsync、LoginAsync、ConfirmRegistrationAsync等。测试应当调用这些意图方法而不是直接触碰Services意图方法内部可以使用 DI但没有 HTTP 等价形式时除外。集成测试不需要独立项目。它们可以放在对应的单元测试项目里src/Api的测试放在test/Api.Test/也可以放在专门的*.IntegrationTest项目里。默认测试数据库是应用工厂持有的内存 SQLite 连接DataSource:memory:。使用IClassFixtureFooApplicationFactory多主机场景用IClassFixtureTFixture保证状态隔离每个测试类一个实例。文档给出的统一原则是FooApplicationFactory是对一个主机的、小而自包含的抽象。同样的形状未来应当能对真实 Bitwarden 实例实现一遍——进程内变体住在当前仓库真实实例变体只做同样的事情但走 HTTP。不共享基类不共享数据库抽象。注意范围边界数据库集成测试仓库层、跨数据库提供方不在该文档范围内那部分见 test/Infrastructure.IntegrationTest 项目。何时写集成测试文档明确给出了“伸手就写集成测试”的三种情形行为依赖真实数据库状态或关系型不变量而单元测试层无法表达级联删除、约束、仓库层 SQL流程跨越多个控制器、中间件或认证管线例如/connect/token→ 资源端点这条链流程跨越多个主机例如 Admin 发出一个链接Api 侧后续消费它。其余情况——纯逻辑、验证器、单方法行为——都属于带 mock 依赖的单元测试。集成测试更慢、噪音更大只保留给“接缝seams”处。测试放在哪里项目归属与共享基础设施集成测试不必独立成项目——“按项目把集成测试和单元测试分开”是选择不是要求。可以放在对应的*.Test项目src/Api对应test/Api.Test/、src/Admin对应test/Admin.Test/等也可以放在专门的*.IntegrationTest项目里选择与当前工作面及既有代码一致的那个。当集成测试与单元测试共享同一个项目时用Integration/子目录把它们在视觉上分开是个好办法。单元测试项目可能需要补上它原本没有的包引用Microsoft.AspNetCore.Mvc.Testing、Microsoft.AspNetCore.TestHost、Microsoft.Data.Sqlite、Microsoft.EntityFrameworkCore.Sqlite在引入第一个集成测试时把它们加到 csproj 里即可。共享基础设施IntegrationTestCommon是遗留代码文档特别指明test/IntegrationTestCommonWebApplicationFactoryBase、ITestDatabase、SqliteTestDatabase等属于遗留基础设施。新测试应当按后文 编写应用工厂 一节自己内联搭建进程内主机而不是依赖它已经在使用它的存量测试可以继续用。从源码看遗留基类 WebApplicationFactoryBase 的形态确实更“重”它通过构造函数属性暴露TestDatabase和ManagesDatabase开关提供SubstituteServiceTService、UpdateConfiguration、GetDatabaseContext()、GetServiceTService()这类通用访问器——恰恰是反模式一节所批评的“把 DI 原语暴露给测试”的形状。它注入的假配置也能印证文档所说“主机按globalSettings:databaseProvider分支注册 EF”这一机制var config new Dictionarystring, string? { // 手动插入一个 EF provider让 ConfigureServices 添加 EF 仓库 // 但随后覆盖 DbContextOptions 使用内存数据库 { globalSettings:databaseProvider, postgres }, { globalSettings:postgreSql:connectionString, Hostlocalhost;Usernametest;Passwordtest;Databasetest }, // 清空 redis 连接串强制分布式缓存走内存实现 { globalSettings:redis:connectionString, }, // 清空各类存储连接串attachment/events/send/notifications/storage // 令 IdentityServer 使用临时密钥 { globalSettings:developmentDirectory, null }, // 邮箱验证、新设备验证、Web Push 等全局开关 { globalSettings:enableEmailVerification, true }, ... };新写法则把这些配置直接写进自己工厂的WithWebHostBuilder回调里不再依赖公共基类。编写应用工厂Authoring an Application Factory每个被测项目配一个FooApplicationFactory类按所封装的系统命名AdminApplicationFactory、ApiApplicationFactory等。它持有经WithWebHostBuilder配置过的WebApplicationFactoryFoo.Program——无需子类化——并只向测试代码暴露两样东西HttpClient访问器意图揭示方法RegisterUserAsync、LoginAsync、AssertOrganizationExistsAsync等描述“测试想做什么”。避免把Services、Server或其他 DI 原语直接暴露给测试。意图方法内部可以使用 DI——那是 DI 该待的位置。测试代码应调用意图方法而不是自己去够主机的 DI。数据库替换遵循微软“定制 WebApplicationFactory”的标准做法移除主机的IDbContextOptionsConfigurationTContext注册再用AddDbContextTContext配一个内存 SQLite 连接。文档给出的完整示例public sealed class FooApplicationFactory : IAsyncDisposable { private readonly SqliteConnection _connection; private readonly WebApplicationFactoryFoo.Program _factory; public FooApplicationFactory() { _connection new SqliteConnection(DataSource:memory:); _connection.Open(); _factory new WebApplicationFactoryFoo.Program().WithWebHostBuilder(builder { builder.ConfigureAppConfiguration((_, config) { // 假的 SQLite 值——provider 必须设置这样主机才会装配 // EF Core但连接串本身永远不会被使用因为我们下面在 // ConfigureServices 中替换了 DbContext 注册。 config.AddInMemoryCollection(new Dictionarystring, string? { [globalSettings:databaseProvider] sqlite, [globalSettings:sqlite:connectionString] Data Sourceignored.db, [globalSettings:redis:connectionString] , }); }); builder.ConfigureServices(services { // 用内存 SQLite 替换 EF 装配 services.RemoveAllIDbContextOptionsConfigurationDatabaseContext(); services.AddDbContextDatabaseContext(options options.UseSqlite(_connection)); // 在这里替换服务但仅限于“跑真实实现会让测试无法进行”的情况。 // 例如 mock IMailService让测试能从调用参数里捕获验证令牌。 // services.AddSingleton(Substitute.ForIMailService()); }); }); // 触碰 Services 会构建主机此后 schema 在整个 _connection 生命周期内存在。 using var scope _factory.Services.CreateScope(); scope.ServiceProvider.GetRequiredServiceDatabaseContext().Database.EnsureCreated(); } public HttpClient CreateClient() _factory.CreateClient(); // --- 意图方法 ---------------------------------------------------------- public async Task RegisterUserAsync(string email, string password) { // … 对 /accounts/register/send-verification-email 和 // /accounts/register/finish 发 HTTP 请求走真实客户端相同的流程 … } public async Taskstring LoginAsync(string email, string password) { // … 对 /connect/token 发 HTTP 请求返回访问令牌 … } public async ValueTask DisposeAsync() { await _factory.DisposeAsync(); _connection.Dispose(); } }仓库中真实主机的入口正符合这个形状例如 src/Api/Program.cs 定义了Bit.Api.Program静态入口CreateHostBuilder(args).Build().Run()WebApplicationFactoryFoo.Program正是以这类Program类为宿主锚点重建整套 DI 与中间件管线的。五个关键点应用工厂就是测试 API。测试代码调用factory.RegisterUserAsync(...)而不是factory.Services.GetRequiredService...()。如果你发现自己想要一个通用的“给我 DbContext”的方法那是坏味道——给它起一个意图名RegisterUserAsync、MarkUserAsConfirmedAsync在可行时用 HTTP 驱动。当操作无法用 HTTP 表达时意图方法内部可以使用 DI——只是把抽象边界保持在意图方法这一层而不是 DI 原语。WithWebHostBuilder返回的就是配置好的工厂。存进字段即可为测试去子类化WebApplicationFactoryT几乎从不需要。SqliteConnection就是数据库。只要它保持打开schema 和数据就持久存在关闭它数据库立刻消失——这正是IClassFixtureFooApplicationFactory里隔离机制的来源。假 SQLite 配置键是必需的。主机的ConfigureServices管线按globalSettings:databaseProvider分支来决定注册哪套 EF 装配。选sqlite能让 EF 落地然后RemoveAll主机的IDbContextOptionsConfigurationDatabaseContext并用内存连接重新注册。字符串值本身永远不会被使用。这一点在遗留基类 WebApplicationFactoryBase 中有同样的实现证据它先注入globalSettings:databaseProvider的假值随后在ConfigureTestServices中移除DbContextOptionsDatabaseContext描述符再挂上TestDatabase。mock 保持最小。集成测试的意义在于让真实代码穿过真实 DI。只有在真实实现会让测试无法进行时才替换服务——它会给真实收件人发信、扣真实卡、推真实通知或需要测试环境没有的凭据。不要因为它“外部”就条件反射式地 mock。意图方法与通往真实实例模式的路应用工厂的形状特意设计成第二个实现可以拿同一组意图方法去打真实的 Bitwarden 集群走 HTTP。到那一天内部已经用HttpClient的方法免费移植伸进进程内 DI 的方法需要一个 HTTP 等价形式——或者如果该操作对真实实例根本无法表达真实实例的实现抛出 skip 异常让测试报告“跳过”而不是“失败”xUnit v3 用Assert.Skip(...)xUnit v2 xunit.skippablefact用throw new SkipException(...)。写新意图方法时要问自己这个操作能否对真实实例表达能就在进程内变体里也优先用 HTTP 形状——现在不花钱以后省工。不能——而且确实有真实原因API 不允许你设置的状态、主机要读回的第三方状态、用户界面触达不了的命令——就在意图方法内伸进 DI并留一条简短注释让缺口在构建真实实例变体时可见。基于 DI 的意图方法好过一个泄漏出去的Services访问器。仓库里现存的最典型“意图方法”集合见遗留形态的 ApiApplicationFactoryLoginWithNewAccount注册新账号并返回令牌对、LoginAsync、LoginWithClientSecretAsyncSecrets Manager 服务账号、LoginWithOrganizationApiKeyAsync公共 API 组织密钥登录。多应用场景下它还会组合出 Identity 工厂并把 Identity 的Server.CreateHandler()注入 JwtBearer 的BackchannelHttpHandler——这正是后文多应用组合的一个实例。编写测试按被验证的场景分组而不是按被调用的控制器或类分组。测试类描述一种行为RetrievingFooTests、UserRegistrationFlowTests、BusinessUnitConversionTests其方法是该场景的各种变体正常路径、边缘情况、失败模式。镜像源码文件结构FooControllerTests会把与行为无关的生产类耦合固化进测试模糊掉真正被验证的东西。仓库中的 BusinessUnitConversionTests 就是这一约定的落样public class BusinessUnitConversionTests(StripeTestsFixture fixture) : IClassFixtureStripeTestsFixture { [BillingFact] public async Task ConvertingOrganizationToBusinessUnit_ProviderWarnings_Succeed() { var (client, providerId) await fixture.PrepareProviderAdminAsync(providerexample.com); var warningsResponse await client.GetAsync($/providers/{providerId}/billing/vnext/warnings); await Assert.SuccessResponseAsync(warningsResponse); } }用IClassFixtureFooApplicationFactory让应用工厂及其内存数据库每类构建一次、类结束后销毁。测试通过CreateClient()和意图方法交互——永远不通过Services或任何主机原语public class RetrievingFooTests(FooApplicationFactory factory) : IClassFixtureFooApplicationFactory { [Fact] public async Task AuthenticatedUser_GetsFoo_ReturnsOk() { await factory.RegisterUserAsync(testexample.com, password); var token await factory.LoginAsync(testexample.com, password); var client factory.CreateClient(); client.DefaultRequestHeaders.Authorization new AuthenticationHeaderValue(Bearer, token); var response await client.GetAsync(/foo); await Assert.SuccessResponseAsync(response); var body await response.Content.ReadFromJsonAsyncJsonObject(); Assert.NotNull(body); } }测试体读起来就是意图本身——“注册一个用户、登录、取 foo、断言”。把它指向真实的 Bitwarden 实例时每一行仍然讲得通。这就是标准。请求与响应模型的选择文档示例对请求体用匿名对象、对响应用JsonObject替代方案是直接上生产 DTOOrganizationCreateRequest等二者的取舍生产 DTO类型化访问、IDE 重命名、更不啰嗦——对深层响应尤其如此。代价是一次伴随[JsonPropertyName]换名的线上形状变更依然能编译并通过把旧形状定死的旧客户端会被静默打碎。匿名对象 JsonObject线上形状漂移会直接弄断测试把“旧客户端会坏”这件事提前暴露出来。代价是啰嗦读深层响应时尤其明显。断言 HTTP 响应Assert.SuccessResponseAsyncHttpResponseMessage.EnsureSuccessStatusCode()失败时只抛状态码——没有响应体不知道为什么挂。应当用Assert.SuccessResponseAsync(response)。它是一个 C# 14 的Assert扩展嵌进 xUnit 既有词汇Assert.Equal、Assert.NotNull、Assert.SuccessResponseAsync并在失败信息里呈现响应体——是 JSON 时还会格式化var response await client.GetAsync(/foo); await Assert.SuccessResponseAsync(response);若单元测试项目还没有引用 test/Common/Common.csprojBit.Test.Common加一个ProjectReference即可。其源码实现很短见 AssertExtensions.cs成功直接返回失败时读取响应体尝试JsonDocument.Parse后按WriteIndented重新序列化拼进Assert.Fail的消息public static async Task SuccessResponseAsync(HttpResponseMessage response) { if (response.IsSuccessStatusCode) { return; } var body await response.Content.ReadAsStringAsync(); var formatted TryFormatJson(body) ?? body; Assert.Fail( $Expected success, got {(int)response.StatusCode} {response.ReasonPhrase}.\n\n $Response body:\n{formatted}); }多应用 FixtureMulti-app Fixtures当一个主机产生的副作用由另一个主机消费时——Admin 发出一个链接Api 侧稍后校验或 Admin 签发的转换令牌由 Api 兑换——就把多个应用工厂组合进一个 fixture。fixture 拥有任何共享的进程内状态例如共享 SQLite 连接并把各工厂暴露给测试public sealed class FooBarFixture : IAsyncDisposable { private readonly SqliteConnection _connection; public FooApplicationFactory Foo { get; } public BarApplicationFactory Bar { get; } public FooBarFixture() { _connection new SqliteConnection(DataSource:memory:); _connection.Open(); // 内部构造函数让 fixture 注入共享连接测试代码够不到它们。 Foo new FooApplicationFactory(_connection, owns: true); Bar new BarApplicationFactory(_connection, owns: false); } public async ValueTask DisposeAsync() { await Foo.DisposeAsync(); await Bar.DisposeAsync(); _connection.Dispose(); } }四条规则fixture 拥有共享状态。共享的SqliteConnection住在 fixture 上而不是任何应用工厂上。每个工厂通过internal构造函数或方法接收它使测试代码无法触及。只有主工厂执行EnsureCreated示例中由owns: true标志控制。次工厂对同一个连接实例注册UseSqlite(sharedConnection)并跳过 schema 创建。副作用捕获例如从 mock 的IMailService发送中提取令牌放在应用工厂内做意图方法factory.NextSentLinkTokenAsync()之类。不要在 fixture 上暴露ConcurrentDictionary...让测试去戳。IClassFixtureTFixture把组合绑定到测试类。xUnit 为类构建一次 fixture两个工厂被该类所有测试共享。一个可运行的多应用组合实例遗留形态——作为接线参考有用不作为抽象参考见 StripeTestsFixture 和 AdminApplicationFactory。从源码看该 fixture 的构造正是文档规则的现实投影Api CreateApi()持有数据库Admin new AdminApplicationFactory(Api.TestDatabase)复用同一TestDatabase它还提供PrepareOrganizationOwnerAsync注册登录 → 建组织 → 刷新令牌拿新 claim、PrepareProviderAdminAsync走 Admin 的商务单元转换再回 Api 兑换这类跨主机意图方法测试类 BusinessUnitConversionTests 以IClassFixtureStripeTestsFixture绑定它——Admin 发出邀请令牌、Api 侧兑换并建立 provider 的跨主机链路完全在 HTTP 意图方法里表达。反模式清单文档最后列出八条必须规避的反模式每条都对应一个真实会发生的故障形态在应用工厂上暴露Services、Server或DatabaseContext。绕过抽象把测试绑死在进程内主机上。应加一个命名意图方法表达“测试想做什么”——哪怕它内部用了 DI。测试触碰factory.Services或自建WebApplicationFactoryT。测试于是依赖主机的形状而非应用工厂的 API。改走工厂上的意图方法——把 DI 调用包进一个命名方法没问题从测试里直接调 DI 不行。继承WebApplicationFactoryBase或使用ITestDatabase/SqliteTestDatabase。两者都属于遗留共享基础设施。直接用WebApplicationFactoryTProgramWithWebHostBuilderSQLite 内联配置。主机构建之后再修改应用工厂状态。WithWebHostBuilder在主机创建时第一次访问Services只被消费一次。事后修改会静默变成 no-op故障表现是“我的 override 没生效”。所有配置在构造函数或 fixture 里设好。让一个应用工厂跨多个会改状态的测试共享却没有IClassFixture边界。状态会以不可预测的顺序在测试间渗漏。要么每类用IClassFixtureFooApplicationFactory要么暴露一个 reset 意图方法并从IAsyncLifetime.InitializeAsync调用它。在应用工厂销毁前关闭 SQLite 连接。关闭连接会立刻丢弃内存数据库任何在途请求都会失败。保持连接在整个工厂生命周期内打开让DisposeAsync来关。用控制器名给测试类命名FooControllerTests。应按被测场景命名RetrievingFooTests、UserRegistrationFlowTests。镜像生产类会掩盖该类实际验证的是什么行为。调用EnsureSuccessStatusCode()。错误消息里只有状态码——没有体、没有诊断。改用Assert.SuccessResponseAsync(response)它把格式化后的 JSON响应体带进失败消息测试就能告诉你到底哪里坏了。参考路径规范本体test/INTEGRATION_TEST.md响应断言扩展test/Common/Helpers/AssertExtensions.cs、test/Common/Common.csproj遗留共享基础设施test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs遗留多应用实例test/Billing.IntegrationTest/StripeTestsFixture.cs、test/Billing.IntegrationTest/AdminApplicationFactory.cs、test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs场景式测试样例test/Billing.IntegrationTest/BusinessUnitConversionTests.cs被测主机入口src/Api/Program.cs【免费下载链接】serverBitwarden infrastructure/backend (API, database, Docker, etc).项目地址: https://gitcode.com/GitHub_Trending/ser/server创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考