标签: C#软件开发 2026-05-25 次
你有没有过这种时候,突然冒出个念头:“为啥不回顾一下原来做过的一些软件系统,挑挑毛病,再做个更好的版本呢?”于是就有了现在这篇文章。
我记得这个项目是因为一些项目里真有需求才搞起来的。当时我们有很多用Postman测的API,后来决定迁移到NUnit和C#上。现在回头看,两年多了,发现好多地方都能改进。

新接口要自动化的测试如下:
接口 | 测试内容 | 预期结果 |
|---|---|---|
/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测试时,我们做两处改动:首先把所有校验移到一个新类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:
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 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的话,重跑测试前得加几步:
建个JSON文件,放Allure必需的最少信息(放项目根目录):
{
"allure": {
"directory": "allure-results"
}
}往.csproj里加这些信息:
<ItemGroup> <None Update="allureConfig.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup>
在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流程里。