如何在大而复杂的 类 中实施单元测试?
How can I implement unit tests in big and complex classes?
我正在一个涉及多项计算的财务系统中实施单元测试。其中一种方法,通过参数接收一个具有100多个属性的对象,并根据这个对象的属性,计算return。
为了为此方法实施单元测试,我需要用有效值填充此对象的所有内容。
所以...问题:今天这个对象是通过数据库填充的。在我的单元测试中(我使用的是 NUnit),我需要避开数据库并为其创建一个模拟对象,以仅测试方法的 return。我怎样才能用这个巨大的对象有效地测试这个方法?我真的需要手动填写它的所有 100 个属性吗?
有没有一种方法可以使用 Moq(例如)自动创建这个对象?
obs:我正在为已经创建的系统编写单元测试。此时重写所有架构是不可行的。
百万感谢!
如果这 100 个值不相关并且您只需要其中的一些值,那么您有多种选择。
您可以创建新对象(属性将使用默认值初始化,例如 null
用于字符串,0
用于整数)并仅分配所需的属性:
var obj = new HugeObject();
obj.Foo = 42;
obj.Bar = "banana";
您还可以使用像 AutoFixture 这样的库,它会为对象中的所有属性分配虚拟值:
var fixture = new Fixture();
var obj = fixture.Create<HugeObject>();
您可以手动分配所需的属性,也可以使用 fixture builder
var obj = fixture.Build<HugeObject>()
.With(o => o.Foo, 42)
.With(o => o.Bar, "banana")
.Create();
用于相同目的的另一个有用的库是 NBuilder
注意:如果所有属性都与您正在测试的功能相关并且它们应该具有特定值,则没有库可以猜测您的测试所需的值。唯一的方法是手动指定测试值。如果您在每次测试之前设置一些默认值并仅更改特定测试所需的内容,则可以减少很多工作。 IE。创建将创建具有预定义值集的对象的辅助方法:
private HugeObject CreateValidInvoice()
{
return new HugeObject {
Foo = 42,
Bar = "banaba",
//...
};
}
然后在您的测试中覆盖一些字段:
var obj = CreateValidInvoice();
obj.Bar = "apple";
// ...
首先,如果代码当前正在从数据库中提取该对象,您应该通过接口完成该对象的获取。然后你可以在你的单元测试中模拟那个接口 return 任何你想要的。
如果我处在你的位置,我会提取实际的 计算 逻辑并针对新的 "calculator" class(es) 编写测试。尽可能分解所有内容。如果输入有 100 个属性,但并非所有属性都与每个计算相关 - 使用 interfaces 将它们分开。这将使预期的输入可见,同时改进代码。
因此,在您的情况下,如果您的 class 被命名为 BigClass,您可以创建一个将在特定计算中使用的接口。这样您就不会更改现有的 class 或其他代码使用它的方式。提取的计算器逻辑将是独立的、可测试的,并且代码——简单得多。
public class BigClass : ISet1
{
public string Prop1 { get; set; }
public string Prop2 { get; set; }
public string Prop3 { get; set; }
}
public interface ISet1
{
string Prop1 { get; set; }
string Prop2 { get; set; }
}
public interface ICalculator
{
CalculationResult Calculate(ISet1 input)
}
鉴于限制(糟糕的代码设计和技术债务......我的孩子)手动填充单元测试将非常麻烦。在您必须访问实际数据源(而不是生产中的数据源)的情况下,将需要混合集成测试。
潜在药水
复制数据库并仅填充 tables/data 需要填充依赖复合体 class。希望代码足够模块化,数据访问应该能够获取和填充复杂的 class。
模拟数据访问并让它通过备用源导入必要的数据(可能是平面文件?csv)
所有其他代码可以专注于模拟执行单元测试所需的任何其他依赖项。
除了唯一剩下的选项是手动填充 class。
顺便说一句,这到处都是糟糕的代码味道,但这超出了 OP 的范围,因为此时无法更改它。我建议你向决策者提及这一点。
对于我必须获取大量实际正确数据进行测试的情况,我将数据序列化为 JSON 并将其直接放入我的测试 类。可以从您的数据库中获取原始数据,然后进行序列化。像这样:
[Test]
public void MyTest()
{
// Arrange
var data = GetData();
// Act
... test your stuff
// Assert
.. verify your results
}
public MyBigViewModel GetData()
{
return JsonConvert.DeserializeObject<MyBigViewModel>(Data);
}
public const String Data = @"
{
'SelectedOcc': [29, 26, 27, 2, 1, 28],
'PossibleOcc': null,
'SelectedCat': [6, 2, 5, 7, 4, 1, 3, 8],
'PossibleCat': null,
'ModelName': 'c',
'ColumnsHeader': 'Header',
'RowsHeader': 'Rows'
// etc. etc.
}";
当您有很多这样的测试时,这可能不是最佳选择,因为以这种格式获取数据需要相当多的时间。但这可以给你一个基础数据,你可以在完成序列化后为不同的测试修改它。
要获得这个 JSON 你必须单独查询数据库中的这个大对象,通过 JsonConvert.Serialise
将它序列化为 JSON 并将这个字符串记录到你的源代码中 -这一点相对容易,但需要一些时间,因为你需要手动完成...虽然只有一次。
当我必须测试报告呈现并且从数据库获取数据不是当前测试的问题时,我已经成功地使用了这种技术。
p.s。你需要 Newtonsoft.Json
包才能使用 JsonConvert.DeserializeObject
使用 in-memory 数据库进行单元测试
所以...这在技术上不是您所说的单元测试的答案,并且使用 in-memory 数据库使其成为集成测试,而不是单元测试。然而,我发现有时候当面对不可能的限制时,你需要在某个地方做出让步,这可能就是其中之一。
我的建议是在单元测试中使用 SQLite(或类似的)。有一些工具可以将您的实际数据库提取并复制到 SQLite 数据库中,然后您可以生成脚本并将其加载到 in-memory 版本的数据库中。您可以使用依赖注入和存储库模式在 "unit" 测试中将数据库提供程序设置为与实际代码中的不同。
通过这种方式,您可以使用现有数据,在需要时修改它作为您的测试 pre-conditions。您需要承认这不是真正的单元测试...这意味着您仅限于数据库可以真正生成的内容(即 table 约束将阻止测试某些场景),因此您无法进行完整的单元测试从这个意义上说。此外,这些测试将 运行 变慢,因为它们实际上是在进行数据库工作,因此您需要计划 运行 这些测试所需的额外时间。 (虽然它们通常仍然很快。)请注意,您可以模拟任何其他实体(例如,如果除了数据库之外还有一个服务调用,那仍然是一个模拟潜力)。
如果此方法对您有用,请点击此处的一些链接。
SQL 服务器到 SQLite 转换器:
https://www.codeproject.com/Articles/26932/Convert-SQL-Server-DB-to-SQLite-DB
SQLite工作室:
https://sqlitestudio.pl/index.rvt
(使用它来生成内存使用的脚本)
要在内存中使用,请执行以下操作:
TestConnection = new SQLiteConnection("FullUri=file::memory:?cache=shared");
我有一个用于数据库结构和数据加载的单独脚本,但这是个人喜好。
希望对你有所帮助,祝你好运。
我会采用这种方法:
1 - 为 100 个 属性 输入参数对象的每个组合编写单元测试,利用工具为您执行此操作(例如 pex、intellitest)并确保它们都是 运行 绿色。此时将单元测试称为集成测试而不是单元测试,原因稍后会变得明显。
2 - 将测试重构为 SOLID 代码块 - 不调用其他方法的方法可以被认为是真正的单元测试,因为它们不依赖于其他代码。其余方法仍然只能集成测试。
3 - 确保所有集成测试仍然 运行ning 绿色。
4 - 为新的单元测试代码创建新的单元测试。
5 - 使用所有 运行ning green,您可以删除 all/some 多余的原始集成测试 - 由您决定,前提是您觉得这样做很舒服。
6 - 使用所有 运行ning 绿色,您可以开始将单元测试中所需的 100 个属性减少到每个单独方法严格需要的属性。这可能会突出显示额外重构的区域,但无论如何都会简化参数对象。这反过来将使未来的代码维护者的工作减少错误,我敢打赌,当它有 50 个属性时,无法解决参数对象大小的历史失败就是为什么它现在是 100 个。现在不能解决这个问题将意味着它'最终会增加到 150 个参数,让我们面对现实吧,没有人想要。
我正在一个涉及多项计算的财务系统中实施单元测试。其中一种方法,通过参数接收一个具有100多个属性的对象,并根据这个对象的属性,计算return。
为了为此方法实施单元测试,我需要用有效值填充此对象的所有内容。
所以...问题:今天这个对象是通过数据库填充的。在我的单元测试中(我使用的是 NUnit),我需要避开数据库并为其创建一个模拟对象,以仅测试方法的 return。我怎样才能用这个巨大的对象有效地测试这个方法?我真的需要手动填写它的所有 100 个属性吗?
有没有一种方法可以使用 Moq(例如)自动创建这个对象?
obs:我正在为已经创建的系统编写单元测试。此时重写所有架构是不可行的。
百万感谢!
如果这 100 个值不相关并且您只需要其中的一些值,那么您有多种选择。
您可以创建新对象(属性将使用默认值初始化,例如 null
用于字符串,0
用于整数)并仅分配所需的属性:
var obj = new HugeObject();
obj.Foo = 42;
obj.Bar = "banana";
您还可以使用像 AutoFixture 这样的库,它会为对象中的所有属性分配虚拟值:
var fixture = new Fixture();
var obj = fixture.Create<HugeObject>();
您可以手动分配所需的属性,也可以使用 fixture builder
var obj = fixture.Build<HugeObject>()
.With(o => o.Foo, 42)
.With(o => o.Bar, "banana")
.Create();
用于相同目的的另一个有用的库是 NBuilder
注意:如果所有属性都与您正在测试的功能相关并且它们应该具有特定值,则没有库可以猜测您的测试所需的值。唯一的方法是手动指定测试值。如果您在每次测试之前设置一些默认值并仅更改特定测试所需的内容,则可以减少很多工作。 IE。创建将创建具有预定义值集的对象的辅助方法:
private HugeObject CreateValidInvoice()
{
return new HugeObject {
Foo = 42,
Bar = "banaba",
//...
};
}
然后在您的测试中覆盖一些字段:
var obj = CreateValidInvoice();
obj.Bar = "apple";
// ...
首先,如果代码当前正在从数据库中提取该对象,您应该通过接口完成该对象的获取。然后你可以在你的单元测试中模拟那个接口 return 任何你想要的。
如果我处在你的位置,我会提取实际的 计算 逻辑并针对新的 "calculator" class(es) 编写测试。尽可能分解所有内容。如果输入有 100 个属性,但并非所有属性都与每个计算相关 - 使用 interfaces 将它们分开。这将使预期的输入可见,同时改进代码。
因此,在您的情况下,如果您的 class 被命名为 BigClass,您可以创建一个将在特定计算中使用的接口。这样您就不会更改现有的 class 或其他代码使用它的方式。提取的计算器逻辑将是独立的、可测试的,并且代码——简单得多。
public class BigClass : ISet1
{
public string Prop1 { get; set; }
public string Prop2 { get; set; }
public string Prop3 { get; set; }
}
public interface ISet1
{
string Prop1 { get; set; }
string Prop2 { get; set; }
}
public interface ICalculator
{
CalculationResult Calculate(ISet1 input)
}
鉴于限制(糟糕的代码设计和技术债务......我的孩子)手动填充单元测试将非常麻烦。在您必须访问实际数据源(而不是生产中的数据源)的情况下,将需要混合集成测试。
潜在药水
复制数据库并仅填充 tables/data 需要填充依赖复合体 class。希望代码足够模块化,数据访问应该能够获取和填充复杂的 class。
模拟数据访问并让它通过备用源导入必要的数据(可能是平面文件?csv)
所有其他代码可以专注于模拟执行单元测试所需的任何其他依赖项。
除了唯一剩下的选项是手动填充 class。
顺便说一句,这到处都是糟糕的代码味道,但这超出了 OP 的范围,因为此时无法更改它。我建议你向决策者提及这一点。
对于我必须获取大量实际正确数据进行测试的情况,我将数据序列化为 JSON 并将其直接放入我的测试 类。可以从您的数据库中获取原始数据,然后进行序列化。像这样:
[Test]
public void MyTest()
{
// Arrange
var data = GetData();
// Act
... test your stuff
// Assert
.. verify your results
}
public MyBigViewModel GetData()
{
return JsonConvert.DeserializeObject<MyBigViewModel>(Data);
}
public const String Data = @"
{
'SelectedOcc': [29, 26, 27, 2, 1, 28],
'PossibleOcc': null,
'SelectedCat': [6, 2, 5, 7, 4, 1, 3, 8],
'PossibleCat': null,
'ModelName': 'c',
'ColumnsHeader': 'Header',
'RowsHeader': 'Rows'
// etc. etc.
}";
当您有很多这样的测试时,这可能不是最佳选择,因为以这种格式获取数据需要相当多的时间。但这可以给你一个基础数据,你可以在完成序列化后为不同的测试修改它。
要获得这个 JSON 你必须单独查询数据库中的这个大对象,通过 JsonConvert.Serialise
将它序列化为 JSON 并将这个字符串记录到你的源代码中 -这一点相对容易,但需要一些时间,因为你需要手动完成...虽然只有一次。
当我必须测试报告呈现并且从数据库获取数据不是当前测试的问题时,我已经成功地使用了这种技术。
p.s。你需要 Newtonsoft.Json
包才能使用 JsonConvert.DeserializeObject
使用 in-memory 数据库进行单元测试
所以...这在技术上不是您所说的单元测试的答案,并且使用 in-memory 数据库使其成为集成测试,而不是单元测试。然而,我发现有时候当面对不可能的限制时,你需要在某个地方做出让步,这可能就是其中之一。
我的建议是在单元测试中使用 SQLite(或类似的)。有一些工具可以将您的实际数据库提取并复制到 SQLite 数据库中,然后您可以生成脚本并将其加载到 in-memory 版本的数据库中。您可以使用依赖注入和存储库模式在 "unit" 测试中将数据库提供程序设置为与实际代码中的不同。
通过这种方式,您可以使用现有数据,在需要时修改它作为您的测试 pre-conditions。您需要承认这不是真正的单元测试...这意味着您仅限于数据库可以真正生成的内容(即 table 约束将阻止测试某些场景),因此您无法进行完整的单元测试从这个意义上说。此外,这些测试将 运行 变慢,因为它们实际上是在进行数据库工作,因此您需要计划 运行 这些测试所需的额外时间。 (虽然它们通常仍然很快。)请注意,您可以模拟任何其他实体(例如,如果除了数据库之外还有一个服务调用,那仍然是一个模拟潜力)。
如果此方法对您有用,请点击此处的一些链接。
SQL 服务器到 SQLite 转换器:
https://www.codeproject.com/Articles/26932/Convert-SQL-Server-DB-to-SQLite-DB
SQLite工作室: https://sqlitestudio.pl/index.rvt
(使用它来生成内存使用的脚本)
要在内存中使用,请执行以下操作:
TestConnection = new SQLiteConnection("FullUri=file::memory:?cache=shared");
我有一个用于数据库结构和数据加载的单独脚本,但这是个人喜好。
希望对你有所帮助,祝你好运。
我会采用这种方法:
1 - 为 100 个 属性 输入参数对象的每个组合编写单元测试,利用工具为您执行此操作(例如 pex、intellitest)并确保它们都是 运行 绿色。此时将单元测试称为集成测试而不是单元测试,原因稍后会变得明显。
2 - 将测试重构为 SOLID 代码块 - 不调用其他方法的方法可以被认为是真正的单元测试,因为它们不依赖于其他代码。其余方法仍然只能集成测试。
3 - 确保所有集成测试仍然 运行ning 绿色。
4 - 为新的单元测试代码创建新的单元测试。
5 - 使用所有 运行ning green,您可以删除 all/some 多余的原始集成测试 - 由您决定,前提是您觉得这样做很舒服。
6 - 使用所有 运行ning 绿色,您可以开始将单元测试中所需的 100 个属性减少到每个单独方法严格需要的属性。这可能会突出显示额外重构的区域,但无论如何都会简化参数对象。这反过来将使未来的代码维护者的工作减少错误,我敢打赌,当它有 50 个属性时,无法解决参数对象大小的历史失败就是为什么它现在是 100 个。现在不能解决这个问题将意味着它'最终会增加到 150 个参数,让我们面对现实吧,没有人想要。