《C# 测试入门:xUnit 与 dotnet test》
这篇写给刚开始写 C#、想一步到位把测试习惯建立起来的读者;如果你会 JUnit,本文处处留了对照,上手成本大约一个下午。xUnit 在 .NET 圈的地位相当于 JUnit 之于 Java:官方模板、社区默认、教程首选。
版本口径先立起来:.NET 10 是当前 LTS(2025-11-11 GA,支持到 2028-11),.NET 9 是上一代 STS(2024-11-12 GA,支持到 2026-11-10),本文代码两版都能跑,测试相关 API 没有差异。xUnit 这边有个分水岭要说透:v2 线停在 2.9.3(2025-01-08 发布,末版),官方重心转向 v3——v3 稳定版 3.0.0 已于 2025-07-13 GA,写作时点已演进到 4.0.x 一线。存量教程和大量项目还在 v2.9.x,它的 Fact/Theory 断言模型与 v3 一致;我的建议:维护旧项目留在 v2.9.3,新项目直接上 v3。本文示例两个版本通用,差异处会标出来。
边界先说死:不比较 NUnit/MSTest(都是成熟选择,xUnit 只是我顺手的那一个),不讲性能基准,不展开单元测试理论;集成测试只给 WebApplicationFactory 一个能跑的骨架。还有一类东西我明确不测:getter/setter、生成器产出的代码、纯转调的胶水层——断言它们等于把实现抄一遍,改动时全红,收益为零,测试预算要花在业务规则和边界上。
起步:一个能跑的测试工程
dotnet new sln -n Demo
dotnet new classlib -o Src
dotnet new xunit -o Tests
dotnet sln add Src Tests
dotnet add Tests reference Src
dotnet test
预期输出的关键几行(版本号随 SDK 变,”通过”不变):
已通过! 失败:0,通过:1,跳过:0,总计:1
持续时间: ... - Tests.dll (net9.0)
第一次搭测试工程就五步:sln 与两个项目建好、引用连上、删掉模板文件、给 Src 写第一个真实类、Theory 参数表加到三组以上。跑到第三步还觉得”测试好麻烦”的,多半是第五步没做——参数化把”多测几组”的成本压到一行,写测试才有正反馈。另外模板给的是 v2 还是 v3 引用,随 SDK 版本而异,打开 csproj 确认一眼,别猜。模板自带的 UnitTest1.cs 删掉,换一个真实的被测对象:
namespace Src;
public static class PriceCalculator
{
public static decimal Round(decimal raw) =>
Math.Round(raw, 2, MidpointRounding.ToEven);
}
Fact 与 Theory:两类测试
using Src;
using Xunit;
public class PriceCalculatorTests
{
[Fact]
public void RoundsToTwoDecimals()
{
var result = PriceCalculator.Round(19.987m);
Assert.Equal(19.99m, result);
}
[Theory]
[InlineData(19.987, 19.99)]
[InlineData(0.001, 0.00)]
[InlineData(-2.345, -2.34)]
public void RoundsManyCases(decimal raw, decimal expected)
{
Assert.Equal(expected, PriceCalculator.Round(raw));
}
}
[Fact] 是单条用例,[Theory] 是”同一套断言、多组数据”,数据用 [InlineData] 内联。命名我用”方法名即用例描述”:RoundsHalfToEven_UpToTwoDecimals 这种,失败报告读名字就知道挂了哪个场景;Test 开头加编号的老风格别学了。编译期常量放不下的复杂对象用 [MemberData],指向静态属性:
public static IEnumerable<object[]> BoundaryCases =>
new List<object[]>
{
new object[] { 0m, 0m },
new object[] { decimal.MaxValue, decimal.MaxValue },
};
[Theory]
[MemberData(nameof(BoundaryCases))]
public void HandlesBoundaries(decimal raw, decimal expected) =>
Assert.Equal(expected, PriceCalculator.Round(raw));
跑法就是 dotnet test,过滤用 dotnet test --filter "FullyQualifiedName~PriceCalculator"。一个 decimal 相关的提醒:浮点断言用 Assert.Equal(expected, actual, precision) 带精度重载,别拿 == 去赌 double 的表示误差——这是我从 JUnit 的 assertEquals(delta) 迁移过来补的第一课。断言家族还有 Contains、StartsWith、Matches 这类字符串断言,用到再查;记住 Equal、NotNull、ThrowsAsync 三个高频的就能开工。第一个有价值的测试也别挑复杂的:挑一个纯函数(输入输出明确、没有依赖)当练手,PriceCalculator 就是典型——等”改完就跑测试”的手感建立了,再去碰需要替身的依赖。
生命周期:xUnit 的”每测一例”比想象中更彻底
xUnit 没有 [SetUp]/[TearDown] 这类特性。每个测试方法都会新建一个测试类实例:准备工作写在构造函数里,清理实现 IDisposable,对象生命周期就是测试生命周期:
public class OrderServiceTests : IDisposable
{
private readonly OrderService _service;
public OrderServiceTests() // 相当于 @BeforeEach
{
_service = new OrderService(new FakeRepo());
}
public void Dispose() // 相当于 @AfterEach
{ /* 释放 */ }
[Fact]
public void CreatesOrder() { /* ... */ }
[Fact]
public void RejectsNegativeQty() { /* ... */ }
}
从 JUnit 迁过来的读者,这张对照表帮你省掉来回查文档的时间:
[Fact]约等于@Test;[Theory]+[InlineData]约等于@ParameterizedTest+@CsvSource;- 构造函数与
IDisposable约等于@BeforeEach/@AfterEach(连”每测一例”的默认都一样); IClassFixture<T>约等于一次性的@BeforeAll资源,跨类共享用 CollectionFixture;Assert.ThrowsAsync<T>约等于assertThrows的异步版;dotnet test约等于mvn test,过滤语法换成--filter;- 关键差异:xUnit 没有 BeforeAll/AfterAll 注解,类级资源一律走夹具接口。
默认模型下,不同测试类之间是并行的,类内串行——所以夹具里别放可变共享状态,否则你会收获随机红的测试集。我自己的规矩:夹具只装只读资源(数据库容器连接、临时目录路径),可变状态每个测试自建。字段不可能在测试间串状态,这是构造函数模型白送的好处。贵的资源用 IClassFixture 挂上,夹具类自己管自己的初始化与释放(实现 IDisposable),xUnit 保证每个测试类只做一次:
public class DbBackedTests : IClassFixture<TempDbFixture>
{
private readonly TempDbFixture _fixture;
public DbBackedTests(TempDbFixture fixture) => _fixture = fixture;
/* 类内多个用例共享 fixture 里昂贵的初始化 */
}
分层原则:贵的进夹具,便宜的进构造函数,测试速度与隔离性都要。分错了方向的信号是夹具越攒越大——那是把”方便”当成了”设计”。
用例组织:目录、命名与并行
测试工程的组织我守三条。目录镜像:Tests 工程里的命名空间跟着 Src 走,找用例靠肌肉记忆。命名一致:被测类名加 Tests 结尾,方法名描述场景——团队风格统一比选哪种风格重要。并行有意识:类间默认并行,意味着日志文件、固定端口这类全局资源要么每类独享,要么显式收进非并行集合。第一次被随机红背刺之前把这三条立好,比事后排查便宜得多。写用例时还有个自检口诀:一个测试方法只该有准备(Arrange)、动作(Act)、断言(Assert)三段,超过三段说明在测多个东西,拆开。
异步与异常测试
C# 的 async/await 在测试里是原生体验:测试方法直接标 async Task,runner 会等它完成。永远别用 .Result 或 .Wait()——同步阻塞异步调用是死锁和假超时的经典来源:
[Fact]
public async Task LoadsOrderAsync()
{
var order = await _service.GetAsync("sku-1");
Assert.NotNull(order);
}
[Fact]
public async Task ThrowsWhenNotFound()
{
await Assert.ThrowsAsync<KeyNotFoundException>(
() => _service.GetAsync("missing"));
}
异常断言的同步版是 Assert.Throws<T>,都返回异常实例供进一步断言。C# 异常体系的组织思路与 Java 很像,我在《C# 语法要点》里做过两语言的对照,”断言类型加关键信息”的纪律两边通用。
WebApplicationFactory:给 ASP.NET Core 服务做集成测试
用内存宿主把整个应用跑起来、走真实 HTTP 管道的测试,靠 WebApplicationFactory<T>(包 Microsoft.AspNetCore.Mvc.Testing)。给一个短链服务类项目(就是《用 ASP.NET Core 做一个短链服务》那套)补一条冒烟用例:
public class ShortenerApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ShortenerApiTests(WebApplicationFactory<Program> factory)
=> _client = factory.CreateClient();
[Fact]
public async Task CreateLinkReturnsOk()
{
var resp = await _client.PostAsJsonAsync("/links",
new { url = "https://example.com" });
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
}
}
两个装配细节:顶层语句的项目要在 Program 类上补 public partial class Program { } 让工厂能引用到入口;需要替换真实依赖(比如把存储换成内存版)时,继承工厂重写 ConfigureWebHost:
public class CustomFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.RemoveAll<ILinkStore>();
services.AddSingleton<ILinkStore, InMemoryLinkStore>();
});
}
}
把数据访问换成内存实现,测试就不依赖外部数据库,CI 起得快、结果稳。这个模式跑的是完整中间件管道,路由、模型绑定、异常处理全都真刀真枪,是我给 Web 服务写测试性价比最高的一层。注意工厂以 IClassFixture 挂给测试类,整个类的用例共享同一个宿主——用例之间别假设数据隔离,每个用例自己准备自己的前置数据。
冒烟用例只有 200 不够,负路径也要一条——短链服务里”不存在的码返回 404″就是典型:
[Fact]
public async Task MissingCodeReturns404()
{
var resp = await _client.GetAsync("/links/does-not-exist");
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
正路径证明功能在,负路径证明错误处理在;生产事故十有八九藏在后者,别只测 happy path。
CI 里怎么跑
CI 里就一条命令:dotnet test --configuration Release --logger "trx"。用 Release 配置跑测试能暴露调试构建下看不见的问题;trx 日志是主流 CI 平台解析测试报告的通用格式,产物在 TestResults 目录。要覆盖率再加 --collect:"XPlat Code Coverage",起步阶段这就够了。并行失败别关并行——那是测试写脏了的信号,修测试,别关开关。本地开发的日常则是 dotnet watch test:保存即重跑,写测试的反馈循环快到像在改代码。
常见坑清单
- async void 测试。异常逃出 runner 的视野,测试可能假绿;测试方法一律
async Task。 - 同步等异步。
.Result/.Wait()是死锁与线程池饥饿的温床,代码审查见一次拦一次。 - 共享可变夹具状态。类内串行、类间并行,夹具里的可变状态在并行类下必炸;只读资源进夹具,可变状态进测试。
- 引用类型直接 Assert.Equal。没重写
Equals的对象比的是引用,误报的差异让人怀疑人生;record 类型天生值语义,优先用 record 当测试数据。 - 用例里写 if/for 分支。测试代码出现条件分支,说明该拆成两个用例——分支没走进去的那条路径永远测不到自己。
- Console.WriteLine 找不到输出。想写诊断日志注入
ITestOutputHelper output再output.WriteLine(...),这是我迁移期踩的最后一个空转。 - 测试类里写业务逻辑。用例里出现循环造数据、复杂条件判断,说明缺一个测试数据工厂;测试代码自己出 bug 是最冤的一类事故。
v3 迁移与选型建议
- 包名从
xunit换成xunit.v3,断言与特性命名基本延续,存量测试改引用后大多直接过;升级动作本身很小:换包引用、跑一遍、按分析器提示清理,我的体感是一次常规升级的工作量,不是一次迁移工程; - v3 自带平台运行器、并行模型与项目配置更现代,演进节奏快,细节以官方迁移文档为准,别背我这里的口径;
- 给团队的建议:新工程直接 v3,遇到第三方测试组件不兼容 v3 的再回 v2.9.3,两条线的 Fact/Theory 心智完全一致,来回切换没有学习成本;
- 覆盖率用
--collect:"XPlat Code Coverage"起步就够,别一上来就折腾覆盖率门槛——数字好看和真测到了是两回事。
验收清单
dotnet test实跑通过,故意改坏一处断言能看到失败定位到方法;- Theory 参数表覆盖边界值(0、负数、极大值)且能独立增删;
- 测试类用构造函数准备、Dispose 清理,类内没有 static 可变共享状态;
- 异步测试全为
async Task,测试工程里搜不到.Result; - WebApplicationFactory 用例在内存宿主上走通一条真实 HTTP 路径;
- 能说清自己项目用的是 v2 还是 v3、为什么;
- 每个用例过一遍 Arrange-Act-Assert 三段自检,超长的当场拆分。
诚实收尾:xUnit 的 API 面很小,小到你会怀疑”就这?”——对,就这。测试的功力不在框架在用例设计:边界值、失败路径、状态转换才是要练的东西。框架把生命周期和并行模型收拾得这么干净,你要操心的只剩”测什么”,这正是我选它的理由。第一套测试别追求覆盖率数字,追求”改坏了必红”的信任感——有了它,重构才敢下手。语言全貌与后续路线见《C# 学习路线》。
本文数据核对时间:2026-02-16:.NET 10 为当前 LTS(2025-11-11 GA,支持至 2028-11)、.NET 9 为 STS(2024-11-12 GA,支持至 2026-11-10);xUnit v2 线末版 2.9.3(2025-01-08 发布),v3 稳定版 3.0.0(2025-07-13 GA)现处 4.0.x 一线;Microsoft.AspNetCore.Mvc.Testing 随 .NET SDK 提供。均为官方发布口径。