电话&微信

18600577194

重写原来的软件项目NUnit API测试:使其更简洁、更快、更好

标签: C#软件开发 2026-05-25 

你有没有过这种时候,突然冒出个念头:“为啥不回顾一下原来做过的一些软件系统,挑挑毛病,再做个更好的版本呢?”于是就有了现在这篇文章。

我记得这个项目是因为一些项目里真有需求才搞起来的。当时我们有很多用Postman测的API,后来决定迁移到NUnit和C#上。现在回头看,两年多了,发现好多地方都能改进。

解决方案架构师之路.webp

待自动化测试及项目回顾

新接口要自动化的测试如下:

接口

测试内容

预期结果

/users

获取所有用户

应返回200状态码和用户信息的列表

/users/add

创建新用户

应返回200状态码和用户数据信息

主要目标是让项目更健壮,在不同场景下更容易复用,优化请求处理,方便在不同项目间重用,支持未来CI/CD集成,并确保敏感数据受保护。

创建自动化测试

第一个大改进是API请求的搭建方式。我第一篇文章里,每个API请求都单独写个方法。现在我把它们弄得更抽象、更易维护了:

using RestSharp;

namespace ApiTestsWithNUnit.Common;

public class Requests(string baseUrl)
{
    private readonly RestClient _client = new(baseUrl);

    public async Task<RestResponse<T>> GetAsync<T>(
        string endpoint,
        Dictionary<string, string>? headers = null)
    {
        var request = new RestRequest(endpoint, Method.Get);

        if (headers != null)
        {
            foreach (var header in headers)
                request.AddHeader(header.Key, header.Value);
        }

        return await _client.ExecuteAsync<T>(request);
    }

    public async Task<RestResponse<T>> PostAsync<T>(
        string endpoint,
        object? body = null,
        Dictionary<string, string>? headers = null)
    {
        var request = new RestRequest(endpoint, Method.Post);

        if (body != null)
            request.AddJsonBody(body);

        if (headers != null)
        {
            foreach (var header in headers)
                request.AddHeader(header.Key, header.Value);
        }

        return await _client.ExecuteAsync<T>(request);
    }
}

测试里会这么用:

using System.Net;
using ApiTestsWithNUnit.Entities;
using FluentAssertions;
using NUnit.Framework;
using RestSharp;

namespace ApiTestsWithNUnit.Tests;

public class Users
{
    private Common.Requests _api;
    private Dictionary<string, string> _headers;

    [SetUp]
    public void Setup()
    {
        _api = new Common.Requests("https://dummyjson.com");
        _headers  = new Dictionary<string, string>
        {
            { "Content-Type", "application/json" }
        };
    }

    [Test]
    public async Task GetAllUsers()
    {
        var response = await _api.GetAsync<object>("/users");
        response.Should().NotBeNull();
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        var users = response.Content;
        users.Should().NotBeNull();
    }

    [Test]
    public async Task CreateNewUser()
    {
        var bodyRequest = new UsersRequestBody
        {
            firstName = "John",
            lastName = "Doe",
            email = "john.do@example.com",
            age = 20
        };

        var response = await _api.PostAsync<object>("/users/add", bodyRequest, _headers);
        response.Should().NotBeNull();
        response.StatusCode.Should().Be(HttpStatusCode.Created);
        var users = response.Content;
        users.Should().Contain(bodyRequest.firstName);
        users.Should().Contain(bodyRequest.lastName);
        users.Should().Contain(bodyRequest.email);
    }
}

更新NASA API测试

更新NASA API测试时,我们做两处改动:首先把所有校验移到一个新类CheckBodyResponse.cs里:

using FluentAssertions;
using LearningNUnit.BackEnd.Entities;

namespace ApiTestsWithNUnit.Common;

public class CheckBodyResponse
{
    public void CheckBodyResponseNasaApi(NasaApiEntity response)
    {
        response.Date.Should().NotBeNullOrEmpty();
        response.Explanation.Should().NotBeNullOrEmpty();
        response.Media_type.Should().NotBeNullOrEmpty();
        response.Service_version.Should().NotBeNullOrEmpty();
        response.Title.Should().NotBeNullOrEmpty();
        response.Url.Should().NotBeNullOrEmpty();
    }
}

重构后的测试大概长这样:

using System.Net;
using System.Text.Json;
using ApiTestsWithNUnit.Common;
using ApiTestsWithNUnit.Config;
using ApiTestsWithNUnit.Entities;
using FluentAssertions;
using LearningNUnit.BackEnd.Entities;
using NUnit.Framework;
using RestSharp.Serializers;

namespace ApiTestsWithNUnit.Tests;

public class NasaApiTest
{
    private Common.Requests _api;
    private Dictionary<string, string> _headers;
    private string _api_key;
    private CheckBodyResponse CheckBodyResponse = new();

    [SetUp]
    public void Setup()
        {
        _headers  = new Dictionary<string, string>
        {
            { "Content-Type", "application/json" }
        };
        _config = TestConfig.GetConfig();
        string nasa_url= "https://api.nasa.gov";
        _api = new Common.Requests(nasa_url);
        _api_key = $"api_key=API_KEY";
    }

    [Test]
    public async Task SearchApodSucess()
    {
        var response = await _api.GetAsync<string>($"/planetary/apod?{_api_key}", _headers);
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        if (response.Content != null)
        {
            var data = JsonSerializer.Deserialize<NasaApiEntity>(
                response.Content,
                new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
            );
            CheckBodyResponse.CheckBodyResponseNasaApi(data);
        }
    }
    [Test]
    public async Task SearchApodWithDate()
    {
            var queryParameters = "date=2023-05-01";
            var response = await _api.GetAsync<string>($"/planetary/apod?{_api_key}&{queryParameters}", _headers);
            response.StatusCode.Should().Be(HttpStatusCode.OK);
            if (response.Content != null)
            {
                var data = JsonSerializer.Deserialize<NasaApiEntity>(
                    response.Content,
                    new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
                );
                CheckBodyResponse.CheckBodyResponseNasaApi(data);
            }
    }

    [Test]
    public async Task SearchApodWithDateWrongFormat()
    {
        var queryParameters = "date=2023/05/01";
        var response = await _api.GetAsync<string>($"/planetary/apod?{_api_key}&{queryParameters}", _headers);
        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }

    [Test]
    public async Task SearchApodWithStartDateEndDate()
    {
        const string startDate = "2023-05-01";
        const string endDate = "2023-06-01";
        var queryParameters = $"start_date={startDate}&end_date={endDate}";
        var response = await _api.GetAsync<string>($"/planetary/apod?{_api_key}&{queryParameters}", _headers);
        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }

    [Test]
    public async Task SearchApodWithStartDateBiggerThanEndDate()
    {
        const string startDate = "2023-12-01";
        const string endDate = "2023-11-01";
        var queryParameters = $"start_date={startDate}&end_date={endDate}";
        var response = await _api.GetAsync<string>($"/planetary/apod?{_api_key}&{queryParameters}", _headers);
        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }
    [Test]
    public async Task SearchApodWithInvalidToken()
    {
        var token = "invalidToken";
        var response = await _api.GetAsync<string>($"/planetary/apod?{token}", _headers);
        response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
    }
}

这个版本的改进点

  • Setup注解:让测试更整洁,避免重复定义变量。

  • 抽象请求:一个方法应对多个接口,不用重复写代码。

  • 更易维护:测试可读性更高,模块化更好。

  • 让测试异步执行

  • 注意敏感数据

安全测试用户数据总归有点麻烦。这里我们借助Rider/Visual Studio的.runsettings文件:

.runsettings是个XML文件,能在Visual Studio或dotnet test里配置测试参数、环境、超时时间和其他TestRunner行为。

<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
  <TestRunParameters>
    <Parameter name="BaseUrl" value="https://dummyjson.com" />
    <Parameter name="UserName" value="John" />
    <Parameter name="LastName" value="do" />
    <Parameter name="Email" value="john.do@example.com" />
    <Parameter name="ApiToken" value="YOUR_API_KEY" />
    <Parameter name="NasaBaseUrl" value="https://api.nasa.gov" />
  </TestRunParameters>
  <RunConfiguration>
    <TargetFrameworkVersion>net7.0</TargetFrameworkVersion>
    <ResultsDirectory>TestResults</ResultsDirectory>
  </RunConfiguration>
</RunSettings>

⚠️ 别把这个文件提交到版本控制!把它加到.gitignore里。

接着我们可以建个配置类,处理本地和CI环境的差异:

using NUnit.Framework;

namespace ApiTestsWithNUnit.Config;

public class Config
{
    public string BaseUrl { get; set; } = string.Empty;
    public string UserName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty; 
    public string Email { get; set; } = string.Empty;
    public string ApiToken { get; set; } = string.Empty;
    public string NasaBaseUrl { get; set; } = string.Empty;
}

public class TestConfig
{
    public static Config GetConfig()
    {
        // 检查是否在CI环境运行
        bool isCi = Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true";

        if (isCi)   
        {
            return new Config
            {
                BaseUrl = Environment.GetEnvironmentVariable("BASE_URL") 
                          ?? throw new Exception("CI中未找到BASE_URL"),
                UserName = Environment.GetEnvironmentVariable("API_KEY") 
                           ?? throw new Exception("CI中未找到USER_NAME"),
                LastName = Environment.GetEnvironmentVariable("LAST_NAME") 
                           ?? throw new Exception("CI中未找到LAST_NAME"),
                Email = Environment.GetEnvironmentVariable("LAST_NAME") 
                           ?? throw new Exception("CI中未找到EMAIL"),
                ApiToken = Environment.GetEnvironmentVariable("API_TOKEN") 
                           ?? throw new Exception("CI中未找到API_TOKEN"),
                NasaBaseUrl = Environment.GetEnvironmentVariable("NASA_BASE_URL") 
                           ?? throw new Exception("CI中未找到NASA_BASE_URL")
            };
        }
        return new Config
        {
            BaseUrl = TestContext.Parameters["BaseUrl"] ?? "",
            UserName = TestContext.Parameters["UserName"] ?? "",
            LastName = TestContext.Parameters["LastName"] ?? "",
            Email = TestContext.Parameters["Email"] ?? "",
            ApiToken = TestContext.Parameters["ApiToken"] ?? "",
            NasaBaseUrl = TestContext.Parameters["NasaBaseUrl"] ?? ""
        };
    }
}

这样我们就能在本地或CI/CD流水线里无缝运行同一套测试了。

更新后的测试大概长这样:

namespace ApiTestsWithNUnit.Tests;

public class NasaApiTest
{
    private Common.Requests _api;
    private Dictionary<string, string> _headers;
    private Config.Config _config;
    private string _api_key;
    private CheckBodyResponse CheckBodyResponse = new();

    [SetUp]
    public void Setup()
    {
        _headers  = new Dictionary<string, string>
        {
            { "Content-Type", "application/json" }
        };
        _config = TestConfig.GetConfig();
        _api = new Common.Requests(_config.NasaBaseUrl);
        _api_key = $"api_key={_config.ApiToken}";
    }

    [Test]
    public async Task SearchApodSucess()
    {
        var response = await _api.GetAsync<string>($"/planetary/apod?{_api_key}", _headers);
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        if (response.Content != null)
        {
            var data = JsonSerializer.Deserialize<NasaApiEntity>(
                response.Content,
                new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
            );
            CheckBodyResponse.CheckBodyResponseNasaApi(data);
        }
    }
 }

在Rider/Visual Studio中使用runsettings文件

Rider

File / Settings → Build, Execution, Deployment → Unit Testing → Test Runner → 找到“Use specific .runconfig/.testsettings settings file”,选你的.runsettings文件。

Visual Studio

Test → Configure Run Settings → 确认是否用了.runsettings文件。

给测试添加Allure报告

我们会用Allure报告看测试结果,安装方法在这——Allure install(注:保留原文链接提示)。

给项目加Allure很简单:直接在IDE的Nuget包管理里搜Allure装最新版,或者运行:

dotnet add ⟨项目路径⟩ package Allure.NUnit

现在开始给项目加些注解:先给测试类加个[AllureNunit]注解,这样就能用Allure的功能了。

下面这些注解对示例有用:

注解

说明

AllureSeverity

测试的严重级别

AllureOwner

测试负责人

AllureFeature

待测功能

AllureBefore

测试前的Setup操作

这些注解能让报告更清晰,所以测试大概会长这样:

[TestFixture]
[AllureNUnit]
[AllureFeature("Nasa API Tests")]
public class NasaApiTest
{
    private Common.Requests _api;
    private Dictionary<string, string> _headers;
    private Config.Config _config;
    private string _api_key;
    private CheckBodyResponse CheckBodyResponse = new();

    [SetUp]
    [AllureBefore("加载测试变量")]
    public void Setup()
    {
        _headers  = new Dictionary<string, string>
        {
            { "Content-Type", "application/json" }
        };
        _config = TestConfig.GetConfig();
        _api = new Common.Requests(_config.NasaBaseUrl);
        _api_key = $"api_key={_config.ApiToken}";
    }

    [Test]
    [AllureOwner("@m4rri4nne")]
    [AllureSeverity(SeverityLevel.critical)]
    [AllureDescription("搜索APOD")]
    public async Task SearchApodSucess()
    {
        var response = await _api.GetAsync<string>($"/planetary/apod?{_api_key}", _headers);
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        if (response.Content != null)
        {
            var data = JsonSerializer.Deserialize<NasaApiEntity>(
                response.Content,
                new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
            );
            CheckBodyResponse.CheckBodyResponseNasaApi(data);
        }
    }
  }

配置Allure

配置Allure的话,重跑测试前得加几步:

  1. 建个JSON文件,放Allure必需的最少信息(放项目根目录):

{
  "allure": {
    "directory": "allure-results"
  }
}
  1. 往.csproj里加这些信息:

<ItemGroup>
  <None Update="allureConfig.json">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
</ItemGroup>
  1. AllureConfig.cs文件里建个全局设置,每次执行都注册一下:

using NUnit.Framework;
using Allure.Net.Commons;

[SetUpFixture]
public class AllureSetup
{
    [OneTimeSetUp]
    public void GlobalSetup()
    {
        AllureLifecycle.Instance.CleanupResultDirectory();
    }
}

之后别忘了:跑测试前先清理、还原并生成项目。

跑完测试后,用这个命令看结果:

allure serve ./bin/Debug/net8.0/allure-results


最后想说的

重温几年前北京心玥软件公司开发的一些项目还挺有意思。其实还能改更多地方,但这次重点放在清晰度、可维护性,还有引入Allure报告上。

一个重要心得:用.runsettings管环境变量挺好,但项目大了,变量一多就不好管了。以后或许可以用JSON文件编排凭证,再加到CI/CD流程里。