大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
测试策略:xUnit 与集成测试(对照 JUnit/Mockito)
这一课你将能做到
- 用 xUnit 写出参数化的单元测试,覆盖判题服务的各分支(AC/WA/TLE/RE)
- 用
WebApplicationFactory起真实宿主做集成测试,DI 覆盖为内存数据库 - 对照 JUnit 5/Mockito/@SpringBootTest 建立完整的测试金字塔与命令行流程
为什么要学
测试是「敢不敢重构」的唯一底气。Java 侧的组合 JUnit 5 + Mockito + @SpringBootTest(+ Testcontainers),.NET 侧对应 xUnit + NSubstitute + WebApplicationFactory,连「单元测业务、集成测管线」的分层策略都相同。
人话:单元测试把类拎出来验逻辑(依赖换假货),集成测试把应用真起来发 HTTP 请求验装配。判题服务规则多、边界多(超时 1ms 之差翻转 AC/TLE),最值得堆测试。
先建立图像
集成测试(少) WebApplicationFactory ≙ @SpringBootTest + Testcontainers
──────────── 真实路由/绑定/DI,数据库用替身
单元测试(多) xUnit + NSubstitute ≙ JUnit 5 + Mockito
跟着做
步骤 1:建测试项目
dotnet new xunit -o Oj.Api.Tests
cd Oj.Api.Tests
dotnet add reference ../Oj.Api/Oj.Api.csproj
dotnet add package NSubstitute
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet test
步骤 2:单元测试——判题规则(xUnit 参数化)
被测代码:根据用例结果与资源占用判定状态:
public class VerdictEvaluator
{
public static string Evaluate(bool outputMatched, int timeMs, int limitMs, int exitCode) =>
(outputMatched, timeMs, limitMs, exitCode) switch
{
(_, _, _, != 0) => "RE",
(false, _, _, 0) => "WA",
(true, > 0, _, 0) when timeMs > limitMs => "TLE",
(true, _, _, 0) => "AC",
_ => "SE",
};
}
测试类([Theory] + [InlineData] = JUnit 的 @ParameterizedTest + @CsvSource):
public class VerdictEvaluatorTests
{
[Theory]
[InlineData(true, 100, 1000, 0, "AC")] // 输出对、没超时
[InlineData(true, 2000, 1000, 0, "TLE")] // 超时
[InlineData(false, 50, 1000, 0, "WA")] // 答案错
[InlineData(true, 0, 1000, 1, "RE")] // 非零退出码优先判 RE
public void Evaluate_Should_Classify(bool matched, int time, int limit, int code, string expected)
{
var actual = VerdictEvaluator.Evaluate(matched, time, limit, code);
Assert.Equal(expected, actual);
}
}
断言对照:Assert.Equal(a, b) ≙ assertEquals;Assert.ThrowsAsync<T> ≙ assertThrows。
步骤 3:Mock 依赖(NSubstitute,Mockito 平替)
public interface ISandboxRunner { Task<RunOutcome> RunAsync(string code, string lang, CancellationToken ct); }
public class JudgeService(ISandboxRunner runner, IVerdictStore store)
{
public async Task<string> JudgeAsync(string code, string lang, CancellationToken ct)
{
var outcome = await runner.RunAsync(code, lang, ct);
var verdict = VerdictEvaluator.Evaluate(outcome.Matched, outcome.TimeMs, 1000, outcome.ExitCode);
await store.SaveAsync(code, verdict, ct);
return verdict;
}
}
[Fact]
public async Task JudgeAsync_Should_Persist_Verdict()
{
var runner = Substitute.For<ISandboxRunner>(); // 创建假实现
runner.RunAsync("int main(){}", "cpp", Arg.Any<CancellationToken>())
.Returns(new RunOutcome(false, 120, 0)); // 打桩 ≙ when(...).thenReturn(...)
var store = Substitute.For<IVerdictStore>();
var verdict = await new JudgeService(runner, store).JudgeAsync("int main(){}", "cpp", default);
Assert.Equal("WA", verdict);
await store.Received(1).SaveAsync("int main(){}", "WA", Arg.Any<CancellationToken>()); // ≙ verify
}
语法对照:Substitute.For<T>() ≙ mock(T.class);.Returns(...) ≙ when(...).thenReturn(...);Received(1) ≙ verify(mock, times(1))。
步骤 4:集成测试——WebApplicationFactory 起真应用
public class OjApiFactory : WebApplicationFactory<Program>
{
private readonly SqliteConnection _conn = new("DataSource=:memory:");
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
_conn.Open(); // 连接开着内存库才活着
builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<OjDbContext>>(); // 移除真库,换共享内存 SQLite
services.AddDbContext<OjDbContext>(o => o.UseSqlite(_conn));
using var scope = services.BuildServiceProvider().CreateScope();
scope.ServiceProvider.GetRequiredService<OjDbContext>().Database.EnsureCreated();
});
}
}
注意:顶级语句项目的 Program 对测试可见,需在 Program.cs 末尾加 public partial class Program { }。
步骤 5:发真实 HTTP 请求验证 API 契约
public class SubmissionsApiTests(OjApiFactory factory) : IClassFixture<OjApiFactory> // ≙ @SpringBootTest(webEnvironment)
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task Submit_Should_Return_201_With_Location()
{
var resp = await _client.PostAsJsonAsync("/api/submissions",
new { problemId = 1, language = "cpp", code = "int main(){}" });
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
Assert.NotNull(resp.Headers.Location);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("PENDING", body.GetProperty("status").GetString());
}
[Fact]
public async Task Submit_With_Empty_Code_Should_Return_400()
{
var resp = await _client.PostAsJsonAsync("/api/submissions",
new { problemId = 1, language = "cpp", code = "" });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); // 校验管线真实生效
}
}
IClassFixture 让工厂跨测试类复用(宿主只启动一次),等价 @SpringBootTest 单上下文复用。
步骤 6:外部依赖真实化(Testcontainers)
SQLite 与真实数据库的方言/约束有偏差,关键集成测试上真容器:
dotnet add package Testcontainers.PostgreSql
var pg = new PostgreSqlBuilder().Build();
await pg.StartAsync();
services.AddDbContext<OjDbContext>(o => o.UseNpgsql(pg.GetConnectionString()));
对应 Java 的 Testcontainers 同名生态。
讲清楚要点
- 金字塔策略两边完全一致:大多数是纯单元(毫秒级、无框架),少量集成验路由/绑定/校验/DI 装配,更少端到端。判题平台的分配:VerdictEvaluator/分页/DTO 全单元;提交 API、鉴权、SignalR 握手做集成。
- xUnit 测试类每次测试都重新实例化(保证测试间无共享状态):构造函数 =
@BeforeEach,Dispose=@AfterEach,异步初始化用IAsyncLifetime。 dotnet test一条命令出结果:CI 里加--collect:"XPlat Code Coverage"出覆盖率,--filter按名过滤,等价mvn test -Dtest=...。- 集成测试里覆盖 DI 是关键技巧:
ConfigureServices里RemoveAll<T>后加测试替身——等价@MockBean,把判题沙箱替换成假实现即可全链路跑通。
你可能会懵的地方
-
[Fact] 和 [Theory] 怎么选? 无参用
[Fact];同逻辑多数据集用[Theory]+[InlineData]。看到重复结构的测试方法就合并成 Theory。 -
单元测试要不要 mock DbContext? 不要——mock 掉 EF 后测的是 mock 自己。单元测只测纯逻辑(如 VerdictEvaluator),数据相关挪到集成测试用 SQLite/容器验,与 Java 社区「不 mock Repository」的共识一致。
-
测试顺序、测试依赖怎么控制? xUnit 默认类间并行、类内串行且无顺序保证——这是特性不是缺陷。需要顺序说明存在共享状态,优先重构测试;确需串行用
[Collection]分组。 -
怎么测 SignalR 推送? 用 SignalR 客户端库连到 TestServer 的
/hubs/judge,订阅事件后触发业务,断言收到消息——握手与序列化都被真实验证。 -
私密配置(连接串)在测试里怎么办? 测试项目的环境变量注入,CI 用 secrets,绝不让生产连接串进仓库。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 集成测试直接连开发库 | 污染数据、跑两次结果不一致 | 每测试类独立 SQLite 内存库/容器 |
| mock 了被测对象自己 | 测试没有意义 | 只 mock 边界依赖(沙箱、网关),测纯逻辑 |
| 断言只有「不抛异常」 | 什么都没验证 | 断言返回值/状态码/副作用调用 |
| 测试里 sleep 等异步完成 | 又慢又不稳定 | 显式 await,SignalR 用事件等待 |
| 一个测试方法验证十件事 | 首个失败掩盖后续 | 一个行为一个测试,Theory 拆数据集 |
卡点急救
- 集成测试报「Program 类不可访问」:在
Program.cs文件末尾加public partial class Program { }。 - SQLite 内存库表不存在:连接必须保持打开,
EnsureCreated在首次使用前执行;连接关闭即库消失。 - 测试并行导致日志混乱/端口冲突:
WebApplicationFactory用内存 TestServer 不占端口;日志用ITestOutputHelper注入。
面试常问
-
单元测试和集成测试的边界怎么划分? 单元测试验证「一个类的业务规则」,依赖全替换,毫秒级;集成测试验证「多组件装配正确性」(路由→绑定→校验→DI→序列化),数据库用替身或容器。判别标准:失败修复点在一个类里是单元,跨配置/跨组件是集成。
-
.NET 侧 @MockBean 的等价物?
ConfigureWebHost里RemoveAll<T>()后AddSingleton(mock实例),替换任意已注册服务,配合Received()断言副作用。 -
如何让测试跑得又快又稳? 工厂/容器跨测试复用(IClassFixture)、显式 await 替代 sleep、无共享文件/端口的并行隔离、常用路径用内存库、方言差异场景才上 Testcontainers。
自测三问
[Fact]与[Theory]的区别?- 为什么不建议 mock
DbSet? WebApplicationFactory默认监听真实端口吗?请求发到哪里?
小结一句话
xUnit + NSubstitute + WebApplicationFactory = JUnit + Mockito + @SpringBootTest 的同构映射,测试金字塔的分层策略原封不动,判题规则这种多分支模块值得最高单测密度。
延伸
- 官方文档:ASP.NET Core 集成测试
- 下一课:异步消息与事件驱动——判题队列削峰、重试与幂等,对照 Spring AMQP。