C# 测试入门:xUnit 与 dotnet test

《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 提供。均为官方发布口径。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇