在 C# 里写单元测试,用 xUnit 或 NUnit------[Fact]、[Theory]、[InlineData]、Assert.Equal(),测试方法要标记特性,测试类要遵循命名规范,测试项目要单独创建。
在 Python 里写单元测试,用 pytest------函数名以 test_ 开头就行,没有特性标记,没有测试类要求,测试文件名以 test_ 开头就行。简单得像在写普通函数。
当我第一次在 Python 里用 pytest 写测试的时候,我的内心是:"这就完了?我的 [Fact] 呢?我的 Assert.Equal() 呢?我的测试类呢?"
后来我才明白,Python 的测试哲学是:测试就是普通函数,不需要额外的仪式感 。而 C# 的测试哲学是:测试需要明确的标记和结构,便于发现和执行。
Python 的测试哲学是:测试就是普通函数,不需要额外的仪式感。
基础语法对比
C# 版本(xUnit):
public class CalculatorTests
{
[Fact]
public void Add_TwoNumbers_ReturnsSum()
{
var calculator = new Calculator();
int result = calculator.Add(2, 3);
Assert.Equal(5, result);
}
[Theory]
[InlineData(1, 1, 2)]
[InlineData(2, 3, 5)]
[InlineData(-1, 1, 0)]
public void Add_MultipleCases_ReturnsCorrectSum(int a, int b, int expected)
{
var calculator = new Calculator();
int result = calculator.Add(a, b);
Assert.Equal(expected, result);
}
}
Python 版本(pytest):
def add(a, b):
return a + b
def test_add_two_numbers():
result = add(2, 3)
assert result == 5
def test_add_multiple_cases():
assert add(1, 1) == 2
assert add(2, 3) == 5
assert add(-1, 1) == 0
对比一下:
| 对比项 | C#(xUnit) | Python(pytest) |
|---|---|---|
| 测试标记 | [Fact]、[Theory] |
函数名 test_ 开头 |
| 参数化 | [InlineData] |
@pytest.mark.parametrize |
| 断言 | Assert.Equal(expected, actual) |
assert expected == actual |
| 测试类 | 必须,但可以没有 | 可选 |
| 测试文件 | 必须单独项目 | 文件名 test_ 开头 |
| 运行 | dotnet test |
pytest |
Python 的测试像在写普通函数------函数名以 test_ 开头就行,没有额外的仪式感。C# 的测试像在写合同------每个测试方法都得标记 [Fact],参数化测试得用 [Theory] + [InlineData]。
参数化测试
C# 版本(xUnit):
[Theory]
[InlineData(1, 1, 2)]
[InlineData(2, 3, 5)]
[InlineData(-1, 1, 0)]
[InlineData(0, 0, 0)]
public void Add_ReturnsCorrectSum(int a, int b, int expected)
{
var calculator = new Calculator();
int result = calculator.Add(a, b);
Assert.Equal(expected, result);
}
Python 版本(pytest):
import pytest
@pytest.mark.parametrize("a, b, expected", [
(1, 1, 2),
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
])
def test_add_returns_correct_sum(a, b, expected):
assert add(a, b) == expected
C# 用 [InlineData] 一行一个测试用例,Python 用 @pytest.mark.parametrize 传入参数列表。Python 的方式更灵活------你可以用函数生成参数:
def generate_test_cases():
return [(i, i, i*2) for i in range(10)]
@pytest.mark.parametrize("a, b, expected", generate_test_cases())
def test_add_generated_cases(a, b, expected):
assert add(a, b) == expected
C# 做不到这么灵活------[InlineData] 的参数必须是编译时常量。
Fixture(测试夹具)
C# 版本(xUnit):
public class DatabaseTests : IDisposable
{
private readonly DbContext _context;
public DatabaseTests()
{
_context = new DbContext();
_context.Database.EnsureCreated();
}
public void Dispose()
{
_context.Database.EnsureDeleted();
_context.Dispose();
}
[Fact]
public void AddUser_SavesToDatabase()
{
_context.Users.Add(new User { Name = "Alice" });
_context.SaveChanges();
Assert.Single(_context.Users);
}
}
Python 版本(pytest):
import pytest
@pytest.fixture
def db_session():
session = create_session()
yield session
session.close()
def test_add_user_saves_to_database(db_session):
db_session.add(User(name="Alice"))
db_session.commit()
assert db_session.query(User).count() == 1
C# 用构造函数和 IDisposable 来管理测试夹具,Python 用 @pytest.fixture 和 yield。Python 的方式更灵活------你可以在 yield 前后做任何清理工作。
Mock(模拟)
C# 版本(Moq):
public class UserServiceTests
{
[Fact]
public void GetUser_ReturnsUserFromRepository()
{
var mockRepo = new Mock<IUserRepository>();
mockRepo.Setup(r => r.GetById(1)).Returns(new User { Name = "Alice" });
var service = new UserService(mockRepo.Object);
var user = service.GetUser(1);
Assert.Equal("Alice", user.Name);
mockRepo.Verify(r => r.GetById(1), Times.Once);
}
}
Python 版本(pytest-mock):
from unittest.mock import Mock, patch
def test_get_user_returns_user_from_repository():
mock_repo = Mock()
mock_repo.get_by_id.return_value = User(name="Alice")
service = UserService(mock_repo)
user = service.get_user(1)
assert user.name == "Alice"
mock_repo.get_by_id.assert_called_once_with(1)
C# 用 Moq 库,Python 用内置的 unittest.mock。Python 的 Mock 更灵活------你可以用 patch 来替换整个模块的函数:
@patch('my_module.requests.get')
def test_api_call(mock_get):
mock_get.return_value.json.return_value = {"status": "ok"}
result = call_api()
assert result["status"] == "ok"
异常测试
C# 版本(xUnit):
[Fact]
public void Divide_ByZero_ThrowsDivideByZeroException()
{
var calculator = new Calculator();
Assert.Throws<DivideByZeroException>(() => calculator.Divide(10, 0));
}
Python 版本(pytest):
import pytest
def test_divide_by_zero_raises_exception():
with pytest.raises(ZeroDivisionError):
divide(10, 0)
C# 用 Assert.Throws<T>(),Python 用 pytest.raises()。两者都支持断言异常消息:
// C#
var ex = Assert.Throws<DivideByZeroException>(() => calculator.Divide(10, 0));
Assert.Contains("除以零", ex.Message);
# Python
with pytest.raises(ZeroDivisionError, match="除以零"):
divide(10, 0)
测试覆盖率
C# 用 coverlet:
<!-- .csproj -->
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="3.1.0" />
</ItemGroup>
dotnet test /p:CollectCoverage=true
Python 用 pytest-cov:
pip install pytest-cov
pytest --cov=my_module --cov-report=html
两者都能生成覆盖率报告,但 Python 的方式更简单------不需要修改项目文件。
测试发现
C# 的测试发现:
dotnet test # 自动发现所有 [Fact] 和 [Theory] 方法
Python 的测试发现:
pytest # 自动发现所有 test_*.py 文件中的 test_* 函数
Python 的测试发现更灵活------你可以用 conftest.py 来配置测试发现规则。
参数化测试的高级用法
C# 版本(xUnit):
[Theory]
[MemberData(nameof(TestCases))]
public void Add_ReturnsCorrectSum(int a, int b, int expected)
{
var calculator = new Calculator();
int result = calculator.Add(a, b);
Assert.Equal(expected, result);
}
public static IEnumerable<object[]> TestCases => new List<object[]>
{
new object[] { 1, 1, 2 },
new object[] { 2, 3, 5 },
new object[] { -1, 1, 0 }
};
Python 版本(pytest):
import pytest
def generate_test_cases():
return [(i, i, i*2) for i in range(10)]
@pytest.mark.parametrize("a, b, expected", generate_test_cases())
def test_add_returns_correct_sum(a, b, expected):
assert add(a, b) == expected
Python 的参数化更灵活------你可以用函数生成参数,C# 的 [MemberData] 也能做到,但语法更复杂。
迁移指南:C# 开发者最容易犯的错
-
测试函数命名 :C# 的测试方法名可以很长(
Add_TwoNumbers_ReturnsSum),Python 的测试函数名用下划线分隔(test_add_two_numbers_returns_sum) -
测试类可选:Python 的测试类不是必须的,函数也能做测试
-
fixture 用 yield :Python 的 fixture 用
yield来分隔 setup 和 teardown -
Mock 用内置库 :Python 的
unittest.mock已经足够强大,不需要第三方库
坑点提醒
测试函数没有 test_ 前缀------pytest 不会发现:
def add_test(): # 这不是测试函数!
assert add(1, 1) == 2
def test_add(): # 这才是测试函数
assert add(1, 1) == 2
fixture 作用域------默认是函数级别:
@pytest.fixture
def db_session():
session = create_session()
yield session
session.close() # 每个测试函数都会执行一次
@pytest.fixture(scope="module")
def db_session():
session = create_session()
yield session
session.close() # 整个模块只执行一次
Mock 的 side_effect------模拟异常:
mock_repo.get_by_id.side_effect = Exception("数据库错误")
真实案例 :我有个同事从 C# 转 Python,写了一个 API 测试脚本。他用 unittest.TestCase 来组织测试,每个测试方法都继承 TestCase。后来发现用 pytest 可以直接写函数,不需要继承任何类,代码量减少了一半。
测试插件生态
Python 的 pytest 有丰富的插件:
| 插件 | 功能 |
|---|---|
pytest-cov |
覆盖率 |
pytest-xdist |
并行测试 |
pytest-mock |
Mock |
pytest-asyncio |
异步测试 |
pytest-django |
Django 测试 |
pytest-flask |
Flask 测试 |
C# 的 xUnit/NUnit 也有插件,但 Python 的插件生态更丰富。
一句话总结
C# 的测试像在写合同------每个测试方法都得标记
[Fact];Python 的测试像在写普通函数------函数名以test_开头就行,简单直接。
第四阶段的五篇文章到这里就全部完成了。下一篇咱们进入第五阶段,聊聊异步编程和高级特性------C# 的 async/await vs Python 的 asyncio,两种语言的"异步哲学"又有啥不同。
📦 示例代码:C# 转 Python 全系列配套练习代码(含 48 章示例)
💬 欢迎点赞、收藏、转发,你的支持是我持续创作的动力!