Visual Studio 添加测试项目

89 阅读5分钟

1. 测试类型简介

在 Visual Studio 中,您可以编写和运行多种类型的测试,最核心的是以下两种:

  • 单元测试 (Unit Test)
    • 目标: 隔离并验证应用程序中一个最小的可测试单元(通常是一个方法或一个类)的内部逻辑是否正确。
    • 特点: 速度极快、不依赖外部环境(如网络、数据库)、数量最多。
    • 执行者: 主要是开发者,作为编写代码时的“安全网”。
  • 集成测试 (Integration Test)
    • 目标: 将多个单元“组装”起来,测试它们协同工作以及与外部系统(如数据库、文件系统、第三方API)交互时是否正确。
    • 特点: 速度较慢、需要配置真实(或测试专用)的外部环境。
    • 执行者: 主要是开发者,用于验证组件间的连接和配置。

2. 单元测试:从零开始

我们将以最流行的 xUnit 框架和 Moq 模拟框架为例,展示如何搭建和编写单元测试。

步骤 2.1: 创建单元测试项目

  1. 打开解决方案: 在 Visual Studio 中打开您的主项目所在的解决方案。
  2. 添加新项目:
    • 在“解决方案资源管理器”中,右键单击最顶层的解决方案 -> 添加 -> 新建项目
    • 搜索 “xUnit 测试项目” (xUnit Test Project),选择它并点击“下一步”。
  3. 命名项目: 遵循标准规范,将项目命名为 [YourProjectName].Tests(例如 image-coper.Tests)。
  4. 选择框架: 确保选择与您主项目完全一致的 .NET 框架版本(例如 .NET 8.0),然后点击“创建”。

步骤 2.2: 添加依赖 (关键步骤)

  1. 添加项目引用:
    • 在新创建的 .Tests 项目上右键 -> 添加 -> 项目引用
    • 勾选您的主项目(例如 image-coper),点击“确定”。这使得测试项目可以访问主项目中的 public 类和方法。
  2. 安装 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 NullAssert.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 提供了强大的测试资源管理器来管理和运行测试。

  1. 打开测试资源管理器: 通过顶部菜单 测试 (Test) -> 测试资源管理器 (Test Explorer) 打开。
  2. 自动发现: 该窗口会自动发现您项目中所有被 [Fact] 或 [Theory] 标记的测试方法。
  3. 运行测试:
    • 点击窗口左上角的 “全部运行” 按钮(绿色播放按钮)来运行所有测试。
    • 在某个测试分组或单个测试上右键,选择 “运行”
  4. 调试测试:
    • 在您的测试代码被测的业务代码中设置断点。
    • 在测试资源管理器中,在某个测试上右键,选择 “调试”
    • 调试会话会直接从该测试开始,让您能快速、隔离地调试特定逻辑,这通常比启动整个应用进行调试要高效得多。

4. 集成测试简介

与单元测试不同,集成测试需要与真实的外部系统(或其测试版本)进行交互。

  • 配置: 通常需要一个专用的测试数据库(例如 SQLite 内存数据库)和一个临时的文件系统目录。
  • 工具: 对于 ASP.NET Core API,Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<T> 是进行集成测试的利器。它可以在内存中启动您的整个应用程序,并提供一个 HttpClient 让你能像真实客户端一样发送 HTTP 请求。
  • 关注点: 验证从 HTTP 请求到数据库(或文件系统)操作,再到 HTTP 响应的完整流程是否正确。它能发现单元测试无法发现的配置错误、连接问题和组件间协作问题。