1. 测试类型简介
在 Visual Studio 中,您可以编写和运行多种类型的测试,最核心的是以下两种:
- 单元测试 (Unit Test)
- 目标: 隔离并验证应用程序中一个最小的可测试单元(通常是一个方法或一个类)的内部逻辑是否正确。
- 特点: 速度极快、不依赖外部环境(如网络、数据库)、数量最多。
- 执行者: 主要是开发者,作为编写代码时的“安全网”。
- 集成测试 (Integration Test)
- 目标: 将多个单元“组装”起来,测试它们协同工作以及与外部系统(如数据库、文件系统、第三方API)交互时是否正确。
- 特点: 速度较慢、需要配置真实(或测试专用)的外部环境。
- 执行者: 主要是开发者,用于验证组件间的连接和配置。
2. 单元测试:从零开始
我们将以最流行的 xUnit 框架和 Moq 模拟框架为例,展示如何搭建和编写单元测试。
步骤 2.1: 创建单元测试项目
- 打开解决方案: 在 Visual Studio 中打开您的主项目所在的解决方案。
- 添加新项目:
- 在“解决方案资源管理器”中,右键单击最顶层的解决方案 -> 添加 -> 新建项目。
- 搜索 “xUnit 测试项目” (xUnit Test Project),选择它并点击“下一步”。
- 命名项目: 遵循标准规范,将项目命名为 [YourProjectName].Tests(例如 image-coper.Tests)。
- 选择框架: 确保选择与您主项目完全一致的 .NET 框架版本(例如 .NET 8.0),然后点击“创建”。
步骤 2.2: 添加依赖 (关键步骤)
- 添加项目引用:
- 在新创建的 .Tests 项目上右键 -> 添加 -> 项目引用。
- 勾选您的主项目(例如 image-coper),点击“确定”。这使得测试项目可以访问主项目中的 public 类和方法。
- 安装 Moq 模拟框架:
- 在 .Tests 项目上右键 -> 管理 NuGet 程序包。
- 在“浏览”选项卡中搜索 Moq 并安装。
步骤 2.3: 编写您的第一个单元测试
单元测试普遍遵循一个清晰的 AAA 模式:Arrange (准备), Act (执行), Assert (断言)。
- [Fact] 特性: 用于标记一个无参数的、独立的测试用例。
- [Theory] 特性: 用于标记一个有参数的、数据驱动的测试用例,通常与 [InlineData] 或 [MemberData] 配合使用,可以用不同的数据多次运行同一个测试。
using Xunit;
using Moq;
using Microsoft.Extensions.Logging;
using image_coper.Controllers;
using Microsoft.AspNetCore.Mvc;
namespace image_coper.Tests
{
public class Image3ControllerTests
{
// Arrange (准备)
private readonly Mock<ILogger<ImageController>> _mockLogger;
private readonly Image3Controller _controller;
// 构造函数会在每个测试方法运行前执行,适合做通用的准备工作
public Image3ControllerTests()
{
_mockLogger = new Mock<ILogger<ImageController>>();
var mockHttpClientFactory = new Mock<IHttpClientFactory>();
// 创建 Controller 实例,并注入“假的”依赖对象
_controller = new Image3Controller(_mockLogger.Object, mockHttpClientFactory.Object);
}
// 这是一个 Fact 测试,用于测试单一场景
[Fact]
public void GetImage_InvalidPath_ReturnsBadRequest()
{
// Act (执行)
var result = _controller.GetImage("../secret-file.txt");
// Assert (断言)
Assert.IsType<BadRequestObjectResult>(result);
}
// 这是一个 Theory 测试,用于测试多个相似场景
[Theory]
[InlineData("20251020", 2025, 10, 20)]
[InlineData("20240229", 2024, 2, 29)]
public void HandleTime_WithValidDateStrings_ReturnsCorrectDateTime(string input, int year, int month, int day)
{
var result = CommonHelper.HandleTime(input);
Assert.NotNull(result);
Assert.Equal(year, result.Value.Year);
}
}
}
步骤 2.4: 理解断言 (Assert)
Assert 类是 xUnit 的核心,用于验证结果是否符合预期。
| 分类 | 常用方法 | 作用 |
|---|---|---|
| 相等性 | Assert.Equal(expected, actual) / NotEqual | 验证两个值相等/不相等。 |
| 布尔值 | Assert.True(condition) / False | 验证条件为 true / false。 |
| Null/Not Null | Assert.Null(object) / NotNull | 验证对象为 null / 不为 null。 |
| 集合 | Assert.Contains(expected, collection) / DoesNotContain | 验证集合中包含/不包含某个元素。 |
| 类型 | Assert.IsType<T>(object) | 验证对象的精确类型是 T。 |
| 异常 | Assert.Throws<TException>(() => code) | 验证 code 代码块会抛出 TException 异常。 |
步骤 2.5: 理解模拟 (Mocking)
为什么需要 Mock? 单元测试的核心是隔离。我们只想测试 Image3Controller 的内部逻辑,而不希望测试真的去访问网络、文件系统或数据库。Mock 对象(由 Moq 库创建)就是这些外部依赖的“特技替身”。
Moq 的核心用法:
- new Mock<IYourInterface>(): 创建一个接口的模拟对象。
- .Object: 获取这个模拟对象的实例,用于依赖注入。
- .Setup(mock => mock.Method(args)).Returns(value): “编排”模拟对象的行为。告诉它当 Method 方法被以 args 参数调用时,应该返回 value。
- .Verify(mock => mock.Method(args), Times.Once): 验证 Method 方法是否被以 args 参数调用了指定的次数。
[Fact]
public void GetUser_ExistingId_ReturnsUser()
{
// Arrange
var fakeUser = new User { Id = 1, Name = "Alice" };
var mockRepo = new Mock<IUserRepository>();
// Setup: 编排 Mock 对象的行为
mockRepo.Setup(repo => repo.GetUserById(1)).Returns(fakeUser);
var controller = new UserController(mockRepo.Object);
// Act
var result = controller.GetUser(1);
// Assert
// ...
}
3. 常用 方法/特性
这是非常好的问题!在 xUnit(C# 最常用的测试框架)中,除了 [Fact] 和 Assert,还有一套强大的工具箱可以帮助你应对各种复杂的测试场景。
我把它们分为 “特性(Attributes)” 和 “断言(Assertions)” 两部分来详细介绍。
一、 常用特性 (Attributes)
[Fact] 只是基础,当你需要传入参数、跳过测试或分类时,会用到以下特性:
1. [Theory] 和 [InlineData] (参数化测试) —— 最常用
如果你想测同一个方法,但是输入不同的参数(例如测 Add(1,1) 和 Add(10,20)),不要复制粘贴代码,用这个组合。
- [Theory]: 告诉 xUnit 这个测试方法需要参数。
- [InlineData]: 提供具体的参数值。
C#
[Theory]
[InlineData(1, 2, 3)] // 1 + 2 = 3
[InlineData(-1, -1, -2)] // -1 + -1 = -2
[InlineData(100, 0, 100)]
public void Add_MultipleInputs_ReturnCorrectSum(int a, int b, int expected)
{
var calc = new Calculator();
var result = calc.Add(a, b);
Assert.Equal(expected, result);
}
2. [Fact(Skip = "原因")] (跳过测试)
当某个功能正在重构,或者依赖的外部服务挂了,你不想删除测试但想暂时忽略它时使用。
C#
[Fact(Skip = "等待修复 Bug #123")]
public void TemporaryBrokenTest()
{
// 这个测试在运行时会被忽略,并在结果中标记为黄色警告
}
3. [Trait] (测试分类)
用于给测试打标签。在 CI/CD 流水线中,你可以只运行 Category=Unit 的测试,跳过慢速的 Category=Integration 测试。
C#
[Fact]
[Trait("Category", "Integration")] // 标记为集成测试
[Trait("Priority", "High")]
public void Database_Connection_Test()
{
// ...
}
4. [MemberData] (复杂数据输入)
[InlineData] 只能传常量(int, string),如果你要传一个复杂的对象(比如 User 类或 List),需要用 [MemberData]。
C#
// 定义一个静态属性提供数据
public static IEnumerable<object[]> GetUserTestData =>
new List<object[]>
{
new object[] { new User { Name = "Admin", Role = "Admin" }, true },
new object[] { new User { Name = "Guest", Role = "User" }, false },
};
[Theory]
[MemberData(nameof(GetUserTestData))]
public void CheckPermission_ReturnsExpected(User user, bool expectedResult)
{
// ...
}
二、 常用断言 (Assert) —— 验证结果对不对
xUnit 的 Assert 类提供了非常丰富的方法。注意:xUnit 的习惯是将 expected (期望值) 放在第一个参数,actual (实际值) 放在第二个参数。
1. 相等性判断
Assert.Equal(expected, actual): 值相等(最常用)。Assert.NotEqual(expected, actual): 值不相等。Assert.Same(expected, actual): 引用相等(判断是否是内存中的同一个对象实例)。
C#
Assert.Equal(4, 2 + 2);
Assert.Equal("Hello", "hello", ignoreCase: true); // 忽略大小写
2. 布尔值与空值
Assert.True(condition)/Assert.False(condition)Assert.Null(object)/Assert.NotNull(object)
C#
Assert.True(result > 0, "结果必须大于0"); // 第二个参数可以写自定义错误信息
Assert.NotNull(returnedUser);
3. 集合/列表判断 (Collection)
Assert.Contains(expectedItem, collection): 集合包含某元素。Assert.DoesNotContain(item, collection): 集合不包含某元素。Assert.Empty(collection): 集合为空。Assert.Single(collection): 集合里只有一个元素。
C#
var names = new List<string> { "Alice", "Bob", "Charlie" };
Assert.Contains("Bob", names);
Assert.Equal(3, names.Count);
4. 字符串判断
Assert.StartsWith("Exp", actualString)Assert.EndsWith("Log", actualString)Assert.Matches(@"^\d{3}-\d{4}$", phoneNumber): 正则表达式匹配。
5. 类型判断
Assert.IsType<string>(obj): 严格判断类型。Assert.IsAssignableFrom<IEnumerable>(obj): 判断是否继承自某类型或接口。
C#
var result = service.GetResult();
Assert.IsType<SuccessResult>(result); // 验证返回的是成功结果对象
6. 异常断言 (Throws) —— 非常重要
测试代码是否在错误输入下正确抛出了异常。
Assert.Throws<ExceptionType>(Action)
C#
// 场景:注册时邮箱为空,应该抛出 ArgumentException
var service = new UserService();
// 验证:执行括号内的代码时,是否抛出了 ArgumentException
var exception = Assert.Throws<ArgumentException>(() => service.Register(""));
// 进一步验证异常消息是否正确
Assert.Equal("Email cannot be empty", exception.Message);
对于 异步方法 (Async/Await) ,要用 ThrowsAsync:
C#
await Assert.ThrowsAsync<Exception>(() => service.RegisterAsync(""));
三、 总结速查表 (Cheat Sheet)
| 类别 | 关键字 | 用途 |
|---|---|---|
| 特性 | [Fact] | 普通的、无参数的测试。 |
| 特性 | [Theory] + [InlineData] | 参数化测试,一组逻辑测多组数据。 |
| 特性 | [Fact(Skip="...")] | 暂时忽略该测试。 |
| 断言 | Assert.Equal(exp, act) | 验证值是否相等(最常用)。 |
| 断言 | Assert.True/False | 验证布尔逻辑。 |
| 断言 | Assert.NotNull | 验证对象不是 null。 |
| 断言 | Assert.Throws<T> | 验证代码是否抛出了预期的异常。 |
3. 运行与调试测试
Visual Studio 提供了强大的测试资源管理器来管理和运行测试。
- 打开测试资源管理器: 通过顶部菜单 测试 (Test) -> 测试资源管理器 (Test Explorer) 打开。
- 自动发现: 该窗口会自动发现您项目中所有被 [Fact] 或 [Theory] 标记的测试方法。
- 运行测试:
- 点击窗口左上角的 “全部运行” 按钮(绿色播放按钮)来运行所有测试。
- 在某个测试分组或单个测试上右键,选择 “运行”。
- 调试测试:
- 在您的测试代码或被测的业务代码中设置断点。
- 在测试资源管理器中,在某个测试上右键,选择 “调试”。
- 调试会话会直接从该测试开始,让您能快速、隔离地调试特定逻辑,这通常比启动整个应用进行调试要高效得多。
4. 集成测试简介
与单元测试不同,集成测试需要与真实的外部系统(或其测试版本)进行交互。
- 配置: 通常需要一个专用的测试数据库(例如 SQLite 内存数据库)和一个临时的文件系统目录。
- 工具: 对于 ASP.NET Core API,Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<T> 是进行集成测试的利器。它可以在内存中启动您的整个应用程序,并提供一个 HttpClient 让你能像真实客户端一样发送 HTTP 请求。
- 关注点: 验证从 HTTP 请求到数据库(或文件系统)操作,再到 HTTP 响应的完整流程是否正确。它能发现单元测试无法发现的配置错误、连接问题和组件间协作问题。