您正在查看静态缓存页面 · 查看完整动态版本 · 登录 参与讨论

第十七章:测试驱动开发 (TDD) 与单元测试

✨步子哥 (steper) 2026年02月17日 05:29 0 次浏览

第十七章:测试驱动开发 (TDD) 与单元测试

本章导读:想象你是一位建造精密钟表的工匠。每当你完成一个齿轮的打磨,你不会急着把它装进表壳,而是先用放大镜仔细检查它的每一个齿距是否精确,转动是否流畅。只有当这个零件通过了所有质量检验,你才会将它与其他部件组装在一起。软件测试的哲学与此如出一辙:我们不等待产品完工才发现问题,而是在每一个环节都建立质量关卡,让 bug 在萌芽阶段就被捕获。本章将带你深入理解如何在 Uno Platform 项目中构建这套"质量关卡系统",让你的跨平台应用在每一次迭代中都保持稳定可靠。

🛡️ 17.1 跨平台开发的"信心保障"

在开发单平台应用时,你只需要关注一种运行环境的行为。但在 Uno Platform 的世界里,你的 C# 代码将在多达七种不同的运行时环境中执行——Windows、macOS、iOS、Android、WebAssembly、Linux,甚至嵌入式设备。这种多样性带来了巨大的复杂性:一个在 Windows 上完美运行的正则表达式,可能因为 WebAssembly 环境的内存限制而崩溃;一个在 iOS 上流畅的动画效果,可能在低端 Android 设备上卡顿不堪。

这就是为什么在 Uno 开发中,测试绝不是可选的"锦上添花"功能,而是维持项目不崩塌的"承重墙"。没有测试保障的跨平台项目,就像一座没有地基的高楼——可能在初期看起来一切正常,但随着代码量的增长和功能的累积,隐患会在不经意间演变成灾难性的故障。

第一性原理:为什么测试对于软件质量如此重要?答案在于人类认知的局限性。无论多么资深的开发者,都无法在修改代码时同时考虑到所有可能的影响。测试用例实际上是一份"可执行的需求文档"——它们精确地描述了系统应该表现出什么行为,并且能够自动验证这些行为是否被保持。当代码库增长到数万行时,这份"可执行的规范"比任何文字文档都更加可靠。
测试驱动开发(Test-Driven Development,简称 TDD)将测试的重要性提升到了一个新的高度。它提倡在编写功能代码之前先编写测试代码,让测试引导整个开发过程。这种"先写考试题,再学习答题"的方式,能够帮助开发者更清晰地思考需求,编写出更加模块化、更加易于测试的代码。

🔬 17.2 单元测试:纯逻辑的守卫者

单元测试是测试金字塔的基础层,它的目标是验证那些与用户界面无关的纯逻辑代码。这些代码通常位于 ViewModel 或 Service 层,负责处理业务规则、数据转换、状态管理等核心功能。由于它们不依赖于特定的 UI 框架或平台 API,单元测试可以在本地开发环境中极速运行,无需启动模拟器或部署到真机。

🧱 17.2.1 架构准备:为可测试性而设计

为了让业务逻辑易于测试,你的代码必须遵循依赖注入(Dependency Injection,DI)原则。这个原则的核心思想是:一个类不应该自己创建它所依赖的对象,而应该从外部接收这些依赖。这种设计使得在测试环境中用"假"对象替换"真"对象变得轻而易举。

考虑下面这个反面示例:ViewModel 直接创建了 HttpClient 实例。这种写法看起来简洁,但为测试埋下了隐患——每次运行测试都会发起真实的网络请求,不仅速度慢,而且结果不可预测(网络可能中断,服务器可能返回意外数据)。

// ❌ 不推荐:直接创建依赖对象
// 这种写法使得单元测试必须依赖真实的网络环境
public class BadViewModel
{
    private readonly HttpClient _httpClient;

    public BadViewModel()
    {
        // 直接在构造函数中创建 HttpClient
        // 问题:测试时无法替换为模拟对象
        _httpClient = new HttpClient();
    }

    public async Task LoadDataAsync()
    {
        // 真实的网络调用,测试时不可控
        var response = await _httpClient.GetStringAsync("https://api.example.com/data");
        // ... 处理响应
    }
}

正确的做法是定义一个接口来抽象网络访问操作,然后通过构造函数注入这个接口的实现。这样,在正式运行时可以注入真正发起网络请求的实现,而在测试时可以注入返回预设数据的模拟实现。

// ✅ 推荐:通过接口抽象依赖
// 这种写法使得测试时可以轻松注入模拟实现

// 定义数据访问接口
// 这个接口描述了"获取数据"这一行为,但不关心具体实现
public interface IDataService
{
    /// <summary>
    /// 异步获取数据列表
    /// </summary>
    Task<IEnumerable<Product>> GetProductsAsync();

    /// <summary>
    /// 异步获取单个产品详情
    /// </summary>
    Task<Product?> GetProductByIdAsync(int id);
}

// 生产环境的实现:真正的网络请求
public class ApiDataService : IDataService
{
    private readonly HttpClient _httpClient;
    private readonly string _apiBaseUrl;

    // 通过依赖注入接收 HttpClient
    // 在 App.xaml.cs 中配置为单例
    public ApiDataService(HttpClient httpClient, string apiBaseUrl)
    {
        _httpClient = httpClient;
        _apiBaseUrl = apiBaseUrl;
    }

    public async Task<IEnumerable<Product>> GetProductsAsync()
    {
        var response = await _httpClient.GetStringAsync($"{_apiBaseUrl}/products");
        return JsonSerializer.Deserialize<List<Product>>(response) ?? new List<Product>();
    }

    public async Task<Product?> GetProductByIdAsync(int id)
    {
        var response = await _httpClient.GetStringAsync($"{_apiBaseUrl}/products/{id}");
        return JsonSerializer.Deserialize<Product>(response);
    }
}

// 视图模型:通过构造函数接收接口
public class ProductsViewModel
{
    private readonly IDataService _dataService;
    private readonly ILogger<ProductsViewModel> _logger;

    // 依赖通过构造函数注入
    // IDataService 可以是真实的 ApiService,也可以是测试用的 Mock
    public ProductsViewModel(IDataService dataService, ILogger<ProductsViewModel> logger)
    {
        _dataService = dataService;
        _logger = logger;
    }

    public async Task LoadProductsAsync()
    {
        _logger.LogInformation("开始加载产品数据...");

        // 调用接口方法,不关心底层实现细节
        var products = await _dataService.GetProductsAsync();

        _logger.LogInformation("成功加载 {Count} 个产品", products.Count());
        // ... 更新 UI 状态
    }
}

✅ 17.2.2 编写你的第一个 xUnit 测试

假设我们有一个计算商品折扣价格的业务逻辑类。这个类的方法接收原价和折扣率两个参数,返回打折后的价格。为了验证这个方法在各种情况下的行为,我们需要编写相应的测试用例。

// DiscountService.cs - 待测试的业务逻辑类
// 这个类提供了商品折扣计算功能

namespace MyApp.Services;

/// <summary>
/// 折扣计算服务
/// 提供商品价格折扣相关的计算功能
/// </summary>
public class DiscountService
{
    /// <summary>
    /// 计算折扣后的价格
    /// </summary>
    /// <param name="originalPrice">原价(必须大于等于0)</param>
    /// <param name="discountRate">折扣率(0到1之间,0.2表示20%的折扣)</param>
    /// <returns>折后价格</returns>
    /// <exception cref="ArgumentException">当参数无效时抛出</exception>
    public decimal Calculate(decimal originalPrice, decimal discountRate)
    {
        // 参数验证:确保价格和折扣率在合理范围内
        if (originalPrice < 0)
        {
            throw new ArgumentException("原价不能为负数", nameof(originalPrice));
        }

        if (discountRate < 0 || discountRate > 1)
        {
            throw new ArgumentException("折扣率必须在0到1之间", nameof(discountRate));
        }

        // 计算并返回折后价格
        // 使用 decimal 类型确保货币计算的精度
        return originalPrice * (1 - discountRate);
    }

    /// <summary>
    /// 根据会员等级获取对应的折扣率
    /// </summary>
    public decimal GetDiscountRateByMemberLevel(MemberLevel level)
    {
        return level switch
        {
            MemberLevel.Regular => 0m,        // 普通会员:无折扣
            MemberLevel.Silver => 0.05m,      // 白银会员:5% 折扣
            MemberLevel.Gold => 0.1m,         // 黄金会员:10% 折扣
            MemberLevel.Platinum => 0.15m,    // 铂金会员:15% 折扣
            _ => 0m
        };
    }
}

public enum MemberLevel
{
    Regular,
    Silver,
    Gold,
    Platinum
}
// DiscountServiceTests.cs - 单元测试类
// 使用 xUnit 框架编写测试用例

using Xunit;
using MyApp.Services;

namespace MyApp.Tests;

/// <summary>
/// DiscountService 的单元测试
/// 每个测试方法应该只验证一个行为
/// </summary>
public class DiscountServiceTests
{
    // [Fact] 标记表示这是一个测试方法
    // xUnit 测试运行器会自动发现并执行这个方法
    [Fact]
    public void Calculate_WithValidInput_ReturnsCorrectDiscountedPrice()
    {
        // ==================== Arrange(准备)====================
        // 创建被测试对象(通常称为 SUT - System Under Test)
        var service = new DiscountService();

        // 定义测试输入数据
        var originalPrice = 100m;  // 原价:100元
        var discountRate = 0.2m;   // 折扣率:20%

        // 定义期望输出
        var expectedPrice = 80m;   // 期望结果:80元

        // ==================== Act(执行)====================
        // 调用被测试方法,获取实际结果
        var actualPrice = service.Calculate(originalPrice, discountRate);

        // ==================== Assert(断言)====================
        // 验证实际结果是否符合期望
        // Assert.Equal 比较两个值是否相等
        Assert.Equal(expectedPrice, actualPrice);
    }

    [Fact]
    public void Calculate_WithZeroDiscount_ReturnsOriginalPrice()
    {
        // 测试边界情况:零折扣
        var service = new DiscountService();
        var price = 100m;
        var zeroDiscount = 0m;

        var result = service.Calculate(price, zeroDiscount);

        // 零折扣应该返回原价
        Assert.Equal(100m, result);
    }

    [Fact]
    public void Calculate_WithFullDiscount_ReturnsZero()
    {
        // 测试边界情况:100%折扣
        var service = new DiscountService();
        var price = 100m;
        var fullDiscount = 1m;  // 100% 折扣

        var result = service.Calculate(price, fullDiscount);

        // 100% 折扣应该返回 0
        Assert.Equal(0m, result);
    }

    [Fact]
    public void Calculate_WithNegativePrice_ThrowsArgumentException()
    {
        // 测试异常情况:负数价格应该抛出异常
        var service = new DiscountService();
        var negativePrice = -50m;
        var discount = 0.1m;

        // Assert.Throws 验证是否抛出了指定类型的异常
        // 注意:这里使用 lambda 表达式包装方法调用
        var exception = Assert.Throws<ArgumentException>(
            () => service.Calculate(negativePrice, discount)
        );

        // 进一步验证异常消息包含预期的关键字
        Assert.Contains("原价不能为负数", exception.Message);
    }

    [Fact]
    public void Calculate_WithInvalidDiscountRate_ThrowsArgumentException()
    {
        // 测试异常情况:无效的折扣率
        var service = new DiscountService();
        var price = 100m;
        var invalidDiscount = 1.5m;  // 150% 折扣是无效的

        Assert.Throws<ArgumentException>(
            () => service.Calculate(price, invalidDiscount)
        );
    }

    // [Theory] 标记表示这是一个参数化测试
    // 可以用多组数据运行同一个测试逻辑
    [Theory]
    [InlineData(MemberLevel.Regular, 0m)]      // 普通会员:无折扣
    [InlineData(MemberLevel.Silver, 0.05m)]    // 白银会员:5%
    [InlineData(MemberLevel.Gold, 0.1m)]       // 黄金会员:10%
    [InlineData(MemberLevel.Platinum, 0.15m)]  // 铂金会员:15%
    public void GetDiscountRateByMemberLevel_ReturnsCorrectRate(
        MemberLevel level,
        decimal expectedRate)
    {
        // 参数化测试:xUnit 会为每组数据运行一次这个方法
        var service = new DiscountService();

        var actualRate = service.GetDiscountRateByMemberLevel(level);

        Assert.Equal(expectedRate, actualRate);
    }
}
费曼技巧提问:为什么单元测试要遵循 Arrange-Act-Assert 模式?试着把这个模式想象成一个科学实验的过程:首先你要准备实验器材和材料(Arrange),然后你要执行实验操作(Act),最后你要观察和记录实验结果(Assert)。这种清晰的三段式结构不仅让测试代码更容易阅读,也让其他开发者能够快速理解每个测试的目的和验证内容。

🎭 17.3 模拟 (Mocking) 平台原生 API

当你的代码涉及到地理位置、摄像头、本地存储等平台特定功能时,单元测试会面临特殊的挑战。你不能为了跑一个测试而真的去户外跑一圈来模拟位置变化,也不可能为了测试相机功能而手动拍摄照片。这时,模拟对象(Mock Object)技术就派上了用场。

模拟对象是真实对象的"替身演员",它们能够模仿真实对象的行为接口,但返回的是预设的固定数据。通过这种技术,你可以在完全可控的测试环境中验证那些依赖硬件或外部服务的代码逻辑。

// 首先需要安装 Moq 包:dotnet add package Moq

using Moq;
using Xunit;

// 假设我们有一个依赖地理位置服务的视图模型
public class WeatherViewModel
{
    private readonly IGeolocationService _geolocationService;
    private readonly IWeatherService _weatherService;

    // 属性用于数据绑定显示
    public string CityName { get; private set; } = string.Empty;
    public string Temperature { get; private set; } = string.Empty;

    public WeatherViewModel(
        IGeolocationService geolocationService,
        IWeatherService weatherService)
    {
        _geolocationService = geolocationService;
        _weatherService = weatherService;
    }

    /// <summary>
    /// 根据当前位置获取天气信息
    /// </summary>
    public async Task RefreshWeatherAsync()
    {
        // 获取当前位置
        var location = await _geolocationService.GetCurrentLocationAsync();

        // 根据位置获取天气
        var weather = await _weatherService.GetWeatherAsync(location.Latitude, location.Longitude);

        // 更新显示属性
        CityName = weather.CityName;
        Temperature = $"{weather.TemperatureCelsius}°C";
    }
}

// 地理位置服务接口
public interface IGeolocationService
{
    Task<Location> GetCurrentLocationAsync();
}

// 天气服务接口
public interface IWeatherService
{
    Task<WeatherInfo> GetWeatherAsync(double latitude, double longitude);
}

// 数据模型
public record Location(double Latitude, double Longitude);
public record WeatherInfo(string CityName, double TemperatureCelsius);

// ==================== 测试类 ====================
public class WeatherViewModelTests
{
    [Fact]
    public async Task RefreshWeatherAsync_WhenLocationAvailable_UpdatesCityName()
    {
        // ==================== Arrange(准备)====================

        // 创建地理位置服务的模拟对象
        var mockGeolocation = new Mock<IGeolocationService>();

        // 配置模拟行为:当调用 GetCurrentLocationAsync 时
        // 返回一个固定的纽约坐标
        mockGeolocation
            .Setup(service => service.GetCurrentLocationAsync())
            .ReturnsAsync(new Location(40.7128, -74.0060));  // 纽约坐标

        // 创建天气服务的模拟对象
        var mockWeather = new Mock<IWeatherService>();

        // 配置模拟行为:当调用 GetWeatherAsync 时
        // 返回预设的天气数据
        mockWeather
            .Setup(service => service.GetWeatherAsync(40.7128, -74.0060))
            .ReturnsAsync(new WeatherInfo("New York", 22.5));

        // 使用模拟对象创建视图模型
        // .Object 属性获取模拟对象的真实接口实现
        var viewModel = new WeatherViewModel(
            mockGeolocation.Object,
            mockWeather.Object
        );

        // ==================== Act(执行)====================
        await viewModel.RefreshWeatherAsync();

        // ==================== Assert(断言)====================

        // 验证城市名称是否正确更新
        Assert.Equal("New York", viewModel.CityName);

        // 验证温度显示是否正确
        Assert.Equal("22.5°C", viewModel.Temperature);

        // 验证模拟对象的方法是否被调用
        mockGeolocation.Verify(
            service => service.GetCurrentLocationAsync(),
            Times.Once  // 确保只调用了一次
        );
    }

    [Fact]
    public async Task RefreshWeatherAsync_WhenLocationFails_DoesNotUpdateProperties()
    {
        // 测试异常情况:地理位置获取失败
        var mockGeolocation = new Mock<IGeolocationService>();
        var mockWeather = new Mock<IWeatherService>();

        // 配置模拟行为:抛出异常模拟位置获取失败
        mockGeolocation
            .Setup(service => service.GetCurrentLocationAsync())
            .ThrowsAsync(new LocationUnavailableException("无法获取位置"));

        var viewModel = new WeatherViewModel(
            mockGeolocation.Object,
            mockWeather.Object
        );

        // 执行并捕获异常
        await Assert.ThrowsAsync<LocationUnavailableException>(
            () => viewModel.RefreshWeatherAsync()
        );

        // 验证天气服务没有被调用(因为位置获取失败)
        mockWeather.Verify(
            service => service.GetWeatherAsync(It.IsAny<double>(), It.IsAny<double>()),
            Times.Never
        );
    }
}

// 自定义异常类
public class LocationUnavailableException : Exception
{
    public LocationUnavailableException(string message) : base(message) { }
}
技术术语Mock(模拟对象)是一种测试替身(Test Double),它能够验证被测试代码与依赖对象之间的交互。与 Stub(桩对象)不同,Mock 不仅返回预设数据,还记录方法调用次数、参数等信息,可以在测试中进行验证。Moq 是 .NET 生态中最流行的 Mock 框架,它使用 lambda 表达式和流畅 API 来定义模拟行为。

🤖 17.4 UI 自动化测试:Uno.UITest

单元测试虽然能够验证业务逻辑的正确性,但它无法捕捉与用户界面相关的缺陷——比如按钮被其他元素遮挡、布局在不同屏幕尺寸下错乱、导航流程无法正常跳转等问题。Uno.UITest 是 Uno Platform 提供的 UI 自动化测试解决方案,它基于业界标准的 Appium 框架,允许你编写一套测试脚本,然后在真实设备或模拟器上自动执行。

📱 17.4.1 编写 UI 测试脚本

UI 自动化测试的核心理念是"模拟用户操作"——测试脚本会像真实用户一样点击按钮、输入文本、滑动屏幕,然后验证界面是否呈现出预期的状态。

// LoginTests.cs - UI 自动化测试示例
// 这个类演示了如何测试登录功能

using NUnit.Framework;
using Uno.UITest;

namespace MyApp.UITests;

/// <summary>
/// 登录流程的 UI 测试
/// 这类测试需要连接到运行中的应用(模拟器或真机)
/// </summary>
[TestFixture]
public class LoginTests
{
    // IApp 是与被测应用交互的主要接口
    // 它提供了查找元素、模拟用户输入、获取元素属性等方法
    private IApp _app = null!;

    // [SetUp] 标记的方法会在每个测试前执行
    // 用于初始化测试环境
    [SetUp]
    public void BeforeEachTest()
    {
        // 创建应用配置
        // Uno.UITest 支持配置不同的平台目标
        _app = ConfigureApp
            .iOS   // 或 Android,取决于测试目标
            .StartApp();
    }

    [Test]
    public void When_EnterValidCredentials_And_TapLogin_Then_NavigateToHomePage()
    {
        // ==================== Arrange ====================
        // 定位登录页面的输入框和按钮
        // 使用 x:Name 或 AutomationId 来标识元素

        const string validUsername = "testuser@example.com";
        const string validPassword = "Test@123456";

        // ==================== Act ====================

        // 在用户名输入框中输入文本
        // Tap 方法先点击元素获得焦点,然后 EnterText 输入文本
        _app.Tap(c => c.Marked("UsernameTextBox"));
        _app.EnterText(validUsername);

        // 等待键盘收起后,点击密码输入框
        _app.DismissKeyboard();
        _app.Tap(c => c.Marked("PasswordTextBox"));
        _app.EnterText(validPassword);

        // 隐藏键盘,然后点击登录按钮
        _app.DismissKeyboard();
        _app.Tap(c => c.Marked("LoginButton"));

        // ==================== Assert ====================

        // 等待首页的某个标志性元素出现
        // WaitForElement 会在指定超时时间内轮询查找元素
        // 如果元素在超时时间内没有出现,测试会失败
        var homeIndicator = _app.WaitForElement(
            c => c.Marked("HomeWelcomeText"),
            "首页欢迎文本未在预期时间内出现",
            TimeSpan.FromSeconds(10)
        );

        // 验证首页显示的欢迎消息
        Assert.That(homeIndicator, Is.Not.Null, "应该成功导航到首页");

        // 进一步验证欢迎文本的内容
        var welcomeText = homeIndicator.First().Text;
        Assert.That(welcomeText, Does.Contain("欢迎"), "欢迎消息应该包含'欢迎'字样");
    }

    [Test]
    public void When_EnterInvalidPassword_And_TapLogin_Then_ShowErrorMessage()
    {
        // 测试错误处理:输入错误密码应该显示错误提示

        _app.Tap(c => c.Marked("UsernameTextBox"));
        _app.EnterText("testuser@example.com");

        _app.DismissKeyboard();
        _app.Tap(c => c.Marked("PasswordTextBox"));
        _app.EnterText("wrongpassword");  // 错误的密码

        _app.DismissKeyboard();
        _app.Tap(c => c.Marked("LoginButton"));

        // 等待错误提示出现
        var errorElement = _app.WaitForElement(
            c => c.Marked("ErrorMessageText"),
            "错误提示未显示",
            TimeSpan.FromSeconds(5)
        );

        // 验证错误消息的内容
        var errorText = errorElement.First().Text;
        Assert.That(errorText, Does.Contain("密码错误"), "应该显示密码错误提示");
    }

    [Test]
    public void When_LeaveFieldsEmpty_And_TapLogin_Then_LoginButtonIsDisabled()
    {
        // 测试表单验证:必填字段为空时,登录按钮应该禁用

        // 不输入任何内容,直接检查登录按钮状态
        var loginButton = _app.Query(c => c.Marked("LoginButton")).First();

        // 验证按钮处于禁用状态
        // 注意:这需要在 XAML 中正确绑定 IsEnabled 属性
        Assert.That(loginButton.Enabled, Is.False, "输入为空时登录按钮应该被禁用");
    }
}
<!-- 在 XAML 中设置 AutomationId,使元素可被测试识别 -->
<!-- 这是实现 UI 自动化测试的关键步骤 -->

<Page x:Class="MyApp.LoginPage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <StackPanel VerticalAlignment="Center" Padding="20" Spacing="15">
        
        <!-- 使用 AutomationId 属性为元素设置唯一标识符 -->
        <!-- 测试代码通过这个标识符来定位和操作元素 -->
        <TextBox x:Name="UsernameTextBox"
                 AutomationId="UsernameTextBox"
                 Header="用户名"
                 PlaceholderText="请输入邮箱地址" />

        <PasswordBox x:Name="PasswordTextBox"
                     AutomationId="PasswordTextBox"
                     Header="密码"
                     PlaceholderText="请输入密码" />

        <Button x:Name="LoginButton"
                AutomationId="LoginButton"
                Content="登录"
                IsEnabled="{x:Bind ViewModel.CanLogin, Mode=OneWay}"
                Command="{x:Bind ViewModel.LoginCommand}" />

        <!-- 用于显示错误或成功消息的文本块 -->
        <TextBlock x:Name="ErrorMessageText"
                   AutomationId="ErrorMessageText"
                   Text="{x:Bind ViewModel.ErrorMessage, Mode=OneWay}"
                   Foreground="Red"
                   Visibility="{x:Bind ViewModel.HasError, Converter={StaticResource BoolToVisibilityConverter}}" />
    </StackPanel>
</Page>
第一性原理:为什么 UI 测试需要使用 AutomationId 而不是直接通过文本内容定位元素?答案在于"稳定性"和"国际化"。如果通过按钮文字"登录"来定位元素,当应用被翻译成英文后,按钮文字变成了"Login",测试就会失败。AutomationId 是专门为自动化测试设计的属性,它不会因为 UI 文案变更或语言切换而改变,能够提供稳定的元素定位能力。

📸 17.5 快照测试:捕获视觉回归

对于 UI 布局来说,传统的断言方式很难发现"按钮错位了 2 像素"或"字体颜色偏淡"这类视觉问题。快照测试(Snapshot Testing)通过对比 UI 截图来解决这个问题,它能够捕获任何像素级别的视觉变化。

快照测试的工作原理是:首次运行时,它会截取 UI 的"基准图片"并保存下来;后续运行时,它会再次截取当前 UI 的图片,与基准图片进行对比。如果两张图片存在任何差异,测试就会失败,并生成一份差异对比图,让你一眼就能看出哪里发生了视觉变化。

// 使用 Verify 进行快照测试的示例
// 需要安装 Verify.Xunit 包

using Xunit;
using VerifyXunit;

namespace MyApp.SnapshotTests;

[UsesVerify]  // 启用 Verify 的测试功能
public class VisualRegressionTests
{
    [Fact]
    public Task SettingsPage_ShouldMatchSnapshot()
    {
        // 这个测试会捕获设置页面的截图
        // 并与之前保存的基准图片进行对比

        // 在实际项目中,这里需要渲染页面并截图
        // Verify 会自动处理图片比较
        return Verify("settings_page_screenshot.png");
    }
}

快照测试特别适合用于验证那些样式复杂、视觉效果重要的页面。但它也有一个需要注意的问题:任何对 UI 的有意修改都会导致测试失败,需要手动更新基准图片。因此,快照测试最适合在代码审查流程中使用——当 PR 中的 UI 变更导致快照测试失败时,团队成员可以检查这些视觉变化是否符合预期。


🔄 17.6 在 CI 中集成测试

测试的价值在于持续执行。如果测试只运行在开发者的本地机器上,它们很容易被遗忘,也无法防止有问题的代码被合并到主分支。将测试集成到持续集成(CI)流水线中,是确保代码质量的关键步骤。

一个完善的 CI 测试策略应该包含多个层次:

首先是编译时测试,这是最基本的保障。每次代码推送到仓库后,CI 系统会自动编译项目并运行所有单元测试。由于单元测试执行速度快、不依赖外部环境,这个过程通常只需要几分钟。如果任何测试失败,PR 就不应该被合并。

其次是发布前测试,这是更全面的质量关卡。当代码准备合并到主分支时,CI 系统会在云端设备农场(如 App Center、Sauce Labs 或 Firebase Test Lab)中运行 UI 自动化测试。这些测试在真实的设备或模拟器上执行,能够捕获单元测试无法发现的问题。

# .github/workflows/test.yml - GitHub Actions CI 配置示例
# 这个配置展示了如何在 CI 中运行测试

name: Run Tests

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  unit-tests:
    name: 单元测试
    runs-on: windows-latest

    steps:
    - name: 检出代码
      uses: actions/checkout@v4

    - name: 设置 .NET
      uses: actions/setup-dotnet@v4
      with:
        dotnet-version: '8.0.x'

    - name: 恢复依赖
      run: dotnet restore

    - name: 构建项目
      run: dotnet build --configuration Release --no-restore

    - name: 运行单元测试
      run: dotnet test --configuration Release --no-build --verbosity normal
          --collect:"XPlat Code Coverage"
          --results-directory ./coverage

    - name: 上传测试报告
      uses: actions/upload-artifact@v4
      with:
        name: test-results
        path: ./coverage

  ui-tests:
    name: UI 测试
    runs-on: macos-latest  # macOS 可以同时运行 iOS 和 Android 模拟器
    needs: unit-tests      # 依赖单元测试通过

    steps:
    - name: 检出代码
      uses: actions/checkout@v4

    - name: 运行 Android UI 测试
      run: |
        dotnet test ./tests/MyApp.UITests
          --filter "Platform=Android"
          --configuration Release
费曼技巧提问:为什么要在 CI 中运行测试,而不是依赖开发者手动运行?想象一个多人协作的团队,每个人都有不同的工作习惯。有些人可能很自律,每次提交前都会跑测试;有些人可能因为赶进度而跳过测试。CI 系统就像一个公正无私的质检员,它对所有人的代码一视同仁,严格执行测试标准,确保主分支的代码始终处于可工作状态。

📝 本章小结

本章我们深入探讨了测试在 Uno Platform 项目中的核心地位和实践方法。从单元测试保障业务逻辑的正确性,到模拟技术解决外部依赖问题,从 UI 自动化测试验证用户交互流程,到快照测试捕获视觉回归——每一层测试都在为应用的质量构建一道防线。

测试驱动开发的真正价值不仅在于发现 bug,更在于它塑造了代码的设计方式。为了编写可测试的代码,你会自然地采用依赖注入、关注点分离、单一职责等设计原则。这些原则带来的好处远超测试本身——它们让你的代码更加模块化、更加易于维护、更加灵活应变。

在下一章中,我们将进入交付阶段,探讨如何通过 持续集成与发布(CI/CD)自动化流水线 将代码转化为用户手中的产品,让高质量的应用以最快的速度触达用户。


动手实验
  1. 为你的 Uno 项目创建一个 xUnit 测试项目。编写至少五个测试用例,覆盖一个业务逻辑类的正常情况和边界情况。使用 [Theory][InlineData] 来参数化你的测试。
  2. 使用 Moq 框架为一个依赖外部服务的类编写单元测试。模拟外部服务返回不同的数据,验证被测类在各种情况下的行为。
  3. 为你的应用中的关键流程(如登录、提交表单)编写 UI 自动化测试。确保为所有需要定位的元素设置了 AutomationId。在本地模拟器上运行这些测试,观察测试执行过程。

讨论回复

0 条回复

还没有人回复