有没有更简单的方法来测试不可变对象中的参数验证和字段初始化?


20

我的领域包括许多简单的不可变类,如下所示:

public class Person
{
    public string FullName { get; }
    public string NameAtBirth { get; }
    public string TaxId { get; }
    public PhoneNumber PhoneNumber { get; }
    public Address Address { get; }

    public Person(
        string fullName,
        string nameAtBirth,
        string taxId,
        PhoneNumber phoneNumber,
        Address address)
    {
        if (fullName == null)
            throw new ArgumentNullException(nameof(fullName));
        if (nameAtBirth == null)
            throw new ArgumentNullException(nameof(nameAtBirth));
        if (taxId == null)
            throw new ArgumentNullException(nameof(taxId));
        if (phoneNumber == null)
            throw new ArgumentNullException(nameof(phoneNumber));
        if (address == null)
            throw new ArgumentNullException(nameof(address));

        FullName = fullName;
        NameAtBirth = nameAtBirth;
        TaxId = taxId;
        PhoneNumber = phoneNumber;
        Address = address;
    }
}

编写空检查和属性初始化已经非常繁琐,但是目前我为这些类中的每一个编写单元测试,以验证参数验证是否正常工作以及所有属性都已初始化。感觉就像无聊的忙碌而带来的好处是不相称的。

真正的解决方案是让C#原生支持不变性和非空引用类型。但是与此同时,我该怎么做才能改善这种情况呢?是否值得编写所有这些测试?为此类编写代码生成器以避免为每个类编写测试是一个好主意吗?


这是我现在根据答案得到的。

我可以简化null检查和属性初始化,使其看起来像这样:

FullName = fullName.ThrowIfNull(nameof(fullName));
NameAtBirth = nameAtBirth.ThrowIfNull(nameof(nameAtBirth));
TaxId = taxId.ThrowIfNull(nameof(taxId));
PhoneNumber = phoneNumber.ThrowIfNull(nameof(phoneNumber));
Address = address.ThrowIfNull(nameof(address));

使用Robert Harvey的以下实现:

public static class ArgumentValidationExtensions
{
    public static T ThrowIfNull<T>(this T o, string paramName) where T : class
    {
        if (o == null)
            throw new ArgumentNullException(paramName);

        return o;
    }
}

使用GuardClauseAssertionfrom 很容易测试空检查AutoFixture.Idioms(感谢建议,Esben Skov Pedersen):

var fixture = new Fixture().Customize(new AutoMoqCustomization());
var assertion = new GuardClauseAssertion(fixture);
assertion.Verify(typeof(Address).GetConstructors());

这可以进一步压缩:

typeof(Address).ShouldNotAcceptNullConstructorArguments();

使用此扩展方法:

public static void ShouldNotAcceptNullConstructorArguments(this Type type)
{
    var fixture = new Fixture().Customize(new AutoMoqCustomization());
    var assertion = new GuardClauseAssertion(fixture);

    assertion.Verify(type.GetConstructors());
}

3
标题/标签讨论的是对字段值的(单元)测试,意味着对对象进行构造后的单元测试,但是代码段显示了输入参数/参数验证;这些不是相同的概念。
Erik Eidt

2
仅供参考,您可以编写一个T4模板来简化这种样板。(您可能还会考虑string.IsEmpty()== null之外。)
Erik Eidt

1
您是否看过自动治具的成语?nuget.org/packages/AutoFixture.Idioms
Esben Skov Pedersen


1
您也可以使用Fody / NullGuard,尽管看起来它没有默认允许模式。
鲍勃

Answers:


5

我正是针对这种情况创建了一个t4模板。为了避免为不可变类编写大量样板。

https://github.com/xaviergonz/T4Immutable T4Immutable是C#.NET应用程序的T4模板,可为不可变类生成代码。

如果您使用此方法,则专门讨论非null测试:

[PreNotNullCheck, PostNotNullCheck]
public string FirstName { get; }

构造函数将是这样的:

public Person(string firstName) {
  // pre not null check
  if (firstName == null) throw new ArgumentNullException(nameof(firstName));

  // assignations + PostConstructor() if needed

  // post not null check
  if (this.FirstName == null) throw new NullReferenceException(nameof(this.FirstName));
}

话虽如此,如果您使用JetBrains注释进行空检查,您也可以这样做:

[JetBrains.Annotations.NotNull, ConstructorParamNotNull]
public string FirstName { get; }

构造函数将是这样的:

public Person([JetBrains.Annotations.NotNull] string firstName) {
  // pre not null check is implied by ConstructorParamNotNull
  if (firstName == null) throw new ArgumentNullException(nameof(firstName));

  FirstName = firstName;
  // + PostConstructor() if needed

  // post not null check implied by JetBrains.Annotations.NotNull on the property
  if (this.FirstName == null) throw new NullReferenceException(nameof(this.FirstName));
}

还有比这更多的一些功能。


谢谢。我已经尝试过了,效果很好。我将其标记为可接受的答案,因为它可以解决原始问题中的所有问题。
BotondBalázs's

16

通过一个简单的重构,您可以得到一些改进,可以缓解编写所有这些栅栏的问题。首先,您需要以下扩展方法:

internal static T ThrowIfNull<T>(this T o, string paramName) where T : class
{
    if (o == null)
        throw new ArgumentNullException(paramName);

    return o;
}

然后,您可以编写:

public class Person
{
    public string FullName { get; }
    public string NameAtBirth { get; }
    public string TaxId { get; }
    public PhoneNumber PhoneNumber { get; }
    public Address Address { get; }

    public Person(
        string fullName,
        string nameAtBirth,
        string taxId,
        PhoneNumber phoneNumber,
        Address address)
    {
        FullName = fullName.ThrowIfNull(nameof(fullName));
        NameAtBirth = nameAtBirth.ThrowIfNull(nameof(nameAtBirth));
        TaxId = taxId.ThrowIfNull(nameof(taxId));
        PhoneNumber = phoneNumber.ThrowIfNull(nameof(fullName));
        Address = address.ThrowIfNull(nameof(address));
    }
}

在扩展方法中返回原始参数会创建一个流畅的界面,因此您可以根据需要使用其他扩展方法来扩展此概念,并将它们链接在一起以进行分配。

其他技术在概念上更优雅,但在执行上却越来越复杂,例如,用[NotNull]属性修饰参数,并像这样使用Reflection 。

也就是说,除非您的类是面向公众的API的一部分,否则您可能不需要所有这些测试。


3
奇怪的是,这被否决了。它与最受支持的答案提供的方法非常相似,只是它不依赖于Roslyn。
罗伯特·哈维

我对此不满意的是,它容易受到构造函数的修改。
jpmc26 '16

@ jpmc26:这是什么意思?
罗伯特·哈维

1
当我阅读您的答案时,建议您不要测试构造函数,而是创建一个在每个参数上调用的通用方法并对其进行测试。如果添加新的属性/参数对而忘记null对构造函数的参数进行测试,则不会导致测试失败。
jpmc26 2016年

@ jpmc26:自然。但是,如果按OP中所示的常规方式编写,也是如此。通用方法所做的全部就是将throw构造函数移到外部。您并没有真正在其他地方的构造函数中转移控制权。如果您选择的话,它只是一种实用方法,也是一种流畅地做事的方法。
罗伯特·哈维

7

短期而言,编写此类测试的乏味性无济于事。但是,由于将在下个月发布的C#(v7)的下一版本中实现,因此抛出表达式有一些帮助:

public class Person
{
    public string FullName { get; }
    public string NameAtBirth { get; }
    public string TaxId { get; }
    public PhoneNumber PhoneNumber { get; }
    public Address Address { get; }

    public Person(
        string fullName,
        string nameAtBirth,
        string taxId,
        PhoneNumber phoneNumber,
        Address address)
    {
        FullName = fullName ?? throw new ArgumentNullException(nameof(fullName));
        NameAtBirth = nameAtBirth ?? throw new ArgumentNullException(nameof(nameAtBirth));
        TaxId = taxId ?? throw new ArgumentNullException(nameof(taxId)); ;
        PhoneNumber = phoneNumber ?? throw new ArgumentNullException(nameof(phoneNumber)); ;
        Address = address ?? throw new ArgumentNullException(nameof(address)); ;
    }
}

您可以通过Try Roslyn webapp尝试使用throw表达式。


更加紧凑,谢谢。一半的行。期待C#7!
BotondBalázs16年

@BotondBalázs,如果需要,可以立即在VS15预览中尝试
user1306322

7

我很惊讶没有人提到NullGuard.Fody。可通过NuGet获得,并将在编译时自动将这些空检查编织到IL中。

所以您的构造函数代码将仅仅是

public Person(
    string fullName,
    string nameAtBirth,
    string taxId,
    PhoneNumber phoneNumber,
    Address address)
{
    FullName = fullName;
    NameAtBirth = nameAtBirth;
    TaxId = taxId;
    PhoneNumber = phoneNumber;
    Address = address;
}

NullGuard会为您添加这些null检查,以将其完全转换为您编写的内容。

但是请注意,NullGuard是opt-out,也就是说,它将为所有方法和构造函数参数,属性getter和setter甚至检查方法返回值添加这些空检查,除非您明确允许[AllowNull]属性使用空值。


谢谢,这看起来是一个非常不错的常规解决方案。尽管很不幸,默认情况下它会修改语言行为。如果它通过添加[NotNull]属性而不是不允许将null作为默认值而起作用,我将更加希望它。
BotondBalázs16年

@BotondBalázs:PostSharp具有此功能以及其他参数验证属性。与Fody不同,它不是免费的。另外,生成的代码将调用PostSharp DLL,因此您需要在产品中包含这些代码。Fody只会在编译期间运行其代码,而在运行时则不需要。
罗曼·赖纳

@BotondBalázs:我最初也发现NullGuard会默认使用默认值,但这确实很奇怪。在任何合理的代码库不允许空应该是比检查空少常见。如果您确实想在某处允许null,则选择退出方法会迫使您对其进行彻底了解。
罗曼·赖纳

5
是的,这是合理的,但它违反了最少惊讶原则。如果新程序员不知道该项目使用了NullGuard,那么他们将大吃一惊!顺便说一句,我也不理解那票选票,这是一个非常有用的答案。
BotondBalázs16年

1
@BotondBalázs/ Roman,看起来NullGuard有一个新的显式模式,默认情况下会更改其工作方式
Kyle W

5

是否值得编写所有这些测试?

不,可能不会。您将其搞砸的可能性有多大?一些语义从您的身下改变出来的可能性有多大?如果有人搞砸了会有什么影响?

如果您花费大量时间对几乎不会损坏的东西进行测试,并且这样做确实是微不足道的修复……也许不值得。

为此类编写代码生成器以避免为每个类编写测试是一个好主意吗?

也许?用反射可以很容易地完成这种事情。要考虑的是在为真实代码生成代码,因此您没有N个类可能会出现人为错误。错误预防>错误检测。


谢谢。也许我对问题的理解不够清楚,但我的意思是写一个生成器来生成不可变的类,而不是对其进行测试
BotondBalázs16

2
我也看过几次,而不是看到if...throw它们ThrowHelper.ArgumentNull(myArg, "myArg"),那样逻辑仍然存在,而且您不会对代码覆盖范围感到厌烦。该ArgumentNull方法将在哪里做if...throw
马修

2

是否值得编写所有这些测试?


不会。因为我很确定您已经通过其他一些使用这些类的逻辑测试来测试了这些属性。

例如,您可以对Factory类进行测试,这些测试具有基于这些属性的断言(Name例如,使用适当分配的属性创建的实例)。

如果那些类暴露给某些第三方/最终用户使用的公共API(@EJoshua,请注意),然后测试预期的 ArgumentNullException可能会有用。

等待C#7时,可以使用扩展方法

public MyClass(string name)
{
    name.ThrowArgumentNullExceptionIfNull(nameof(name));
}

public static void ThrowArgumentNullExceptionIfNull(this object value, string paramName)
{
    if(value == null)
        throw new ArgumentNullException(paramName);
}

对于测试,您可以使用一种参数化测试方法,该方法使用反射null为每个参数创建引用,并为预期的异常声明。


1
对于不测试属性初始化,这是一个令人信服的论点。
BotondBalázs16年

这取决于您如何使用代码。如果代码是面向公众的库的一部分,则您切不可盲目地信任他人以了解您的库(特别是如果他们无权访问源代码);但是,如果您只是在内部使用它来使自己的代码正常工作,那么您应该知道如何使用自己的代码,因此检查的目的较少。
EJoshuaS-恢复莫妮卡

2

您总是可以编写如下方法:

// Just to illustrate how to call this
private static void SomeMethod(string a, string b, string c, string d)
    {
        ValidateArguments(a, b, c, d);
        // ...
    }

    // This is the one to use as a utility function
    private static void ValidateArguments(params object[] args)
    {
        for (int i = 0; i < args.Length; i++)
        {
            if (args[i] == null)
            {
                StackTrace trace = new StackTrace();
                // Get the method that called us
                MethodBase info = trace.GetFrame(1).GetMethod();

                // Get information on the parameter that is null so we can add its name to the exception
                ParameterInfo param = info.GetParameters()[i];

                // Raise the exception on behalf of the caller
                throw new ArgumentNullException(param.Name);
            }
        }
    }

如果您有几种需要这种验证的方法,那么至少可以节省一些键入时间。当然,此解决方案假定没有你的方法的参数都可以null,但你可以修改这个改变,如果你愿意的话。

您还可以扩展它以执行其他特定于类型的验证。例如,如果您有一个规则,即字符串不能完全为空格或为空,则可以添加以下条件:

// Note that we already know based on the previous condition that args[i] is not null
else if (args[i].GetType() == typeof(string))
            {
                string argCast = arg as string;

                if (!argCast.Trim().Any())
                {
                    ParameterInfo param = GetParameterInfo(i);

                    throw new ArgumentException(param.Name + " is empty or consists only of whitespace");
                }
            }

我所引用的GetParameterInfo方法基本上是反射操作(因此,我不必一遍又一遍地键入相同的内容,这将违反DRY原理):

private static ParameterInfo GetParameterInfo(int index)
    {
        StackTrace trace = new StackTrace();

        // Note that we have to go 2 methods back to get the ValidateArguments method's caller
        MethodBase info = trace.GetFrame(2).GetMethod();

        // Get information on the parameter that is null so we can add its name to the exception
        ParameterInfo param = info.GetParameters()[index];

        return param;
    }

1
为什么要对此表示不满?这不是太糟糕
Gaspa79

0

我不建议从构造函数中抛出异常。问题是您将很难测试它,因为您必须传递有效参数,即使它们与您的测试无关。

例如:如果要测试第三个参数是否引发异常,则必须正确传递第一个和第二个参数。因此,您的测试不再孤立了。当第一个和第二个参数的约束更改时,即使测试第三个参数,该测试用例也会失败。

我建议使用Java验证API并外部化验证过程。我建议涉及四个职责(类):

  1. 一个带有Java验证注释参数的建议对象,它带有一个validate方法,该方法返回一组ConstraintViolations。优点之一是,您可以在不声明有效的情况下绕过该对象。您可以将验证延迟到需要时,而不必尝试实例化域对象。验证对象可以在不同的层中使用,因为它是没有层特定知识的POJO。它可以是您的公共API的一部分。

  2. 域对象的工厂,负责创建有效对象。您传入建议对象,如果一切正常,工厂将调用验证并创建域对象。

  3. 域对象本身,对于其他开发人员来说,实例化应该几乎不可访问。出于测试目的,我建议在包范围内使用此类。

  4. 一个公共域接口,用于隐藏具体的域对象并使滥用变得困难。

我不建议检查空值。只要进入域层,只要您没有带有结尾的对象链或单个对象的搜索功能,就无需传递null或返回null。

还有一点:编写尽可能少的代码并不是衡量代码质量的指标。考虑一下32k游戏竞赛。该代码是您可以拥有的最紧凑但也是最混乱的代码,因为它只关心技术问题,而不关心语义。但是语义是使事情变得全面的要点。


1
我谨不同意你的最后一点。由于没有对第100次键入相同的样板有助于减少犯错的机会。假设这是Java,而不是C#。我必须为每个属性定义一个支持字段和一个getter方法。代码将变得完全不可读,而笨拙的基础结构将掩盖重要的内容。
BotondBalázs's
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.