Gibt es eine einfachere Möglichkeit, die Argumentvalidierung und Feldinitialisierung in einem unveränderlichen Objekt zu testen?

20

Meine Domain besteht aus vielen einfachen unveränderlichen Klassen wie dieser:

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;
    }
}

Das Schreiben der Nullprüfungen und der Eigenschaftsinitialisierung wird bereits sehr mühsam. Derzeit schreibe ich Unit-Tests für jede dieser Klassen, um zu überprüfen, ob die Argumentvalidierung ordnungsgemäß funktioniert und alle Eigenschaften initialisiert wurden. Das fühlt sich nach extrem langweiliger Arbeit mit unangemessenem Nutzen an.

Die eigentliche Lösung wäre, dass C # Unveränderlichkeit und nicht nullbare Referenztypen nativ unterstützt. Aber was kann ich in der Zwischenzeit tun, um die Situation zu verbessern? Lohnt es sich, all diese Tests zu schreiben? Wäre es eine gute Idee, einen Codegenerator für solche Klassen zu schreiben, um zu vermeiden, dass Tests für jede einzelne geschrieben werden?


Hier ist, was ich jetzt basierend auf den Antworten habe.

Ich könnte die Nullprüfungen und die Eigenschaftsinitialisierung so vereinfachen:

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));

Mit der folgenden Implementierung von 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;
    }
}

Das Testen der Nullprüfungen ist mit dem GuardClauseAssertionvon einfach AutoFixture.Idioms(danke für den Vorschlag, Esben Skov Pedersen ):

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

Dies könnte noch weiter komprimiert werden:

typeof(Address).ShouldNotAcceptNullConstructorArguments();

Mit dieser Erweiterungsmethode:

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

    assertion.Verify(type.GetConstructors());
}
Botond Balázs
quelle
3
Der Titel / das Tag spricht vom (Einheiten-) Testen von Feldwerten, was bedeutet, dass das Objekt nach der Konstruktion mit Einheiten getestet wird. Dies sind nicht die gleichen Konzepte.
Erik Eidt
2
Zu Ihrer Information, Sie könnten eine T4-Vorlage schreiben, die diese Art von Boilerplate einfach macht. (Sie könnten auch string.IsEmpty () über == null betrachten.)
Erik Eidt
1
Haben Sie sich die Redewendungen der Autofixierung angesehen? nuget.org/packages/AutoFixture.Idioms
Esben Skov Pedersen
1
Sie können auch Fody / NullGuard verwenden , obwohl es anscheinend keinen Standard- Zulassungsmodus gibt.
Bob

Antworten:

5

Genau für diese Art von Fällen habe ich eine t4-Vorlage erstellt. Um zu vermeiden, dass für unveränderliche Klassen viel Boilerplate geschrieben wird.

https://github.com/xaviergonz/T4Immutable T4Immutable ist eine T4-Vorlage für C # .NET-Apps, die Code für unveränderliche Klassen generiert.

Wenn Sie dies verwenden, sprechen Sie insbesondere von Nicht-Null-Tests:

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

Der Konstruktor wird dies sein:

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));
}

Wenn Sie jedoch JetBrains Annotations für die Nullprüfung verwenden, können Sie auch Folgendes tun:

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

Und der Konstruktor wird dies sein:

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));
}

Es gibt auch ein paar mehr Funktionen als diese.

Javier G.
quelle
Vielen Dank. Ich habe es gerade ausprobiert und es hat perfekt funktioniert. Ich markiere dies als die akzeptierte Antwort, da alle Probleme in der ursprünglichen Frage behandelt werden.
Botond Balázs
16

Mit einem einfachen Refactoring können Sie einige Verbesserungen erzielen, die das Problem des Schreibens all dieser Zäune erleichtern. Zunächst benötigen Sie diese Erweiterungsmethode:

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

    return o;
}

Sie können dann schreiben:

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));
    }
}

Wenn Sie den ursprünglichen Parameter in der Erweiterungsmethode zurückgeben, wird eine flüssige Schnittstelle erstellt, sodass Sie dieses Konzept bei Bedarf mit anderen Erweiterungsmethoden erweitern und alle in Ihrer Zuweisung verketten können.

Andere Techniken haben ein eleganteres Konzept, sind jedoch zunehmend komplexer in der Ausführung, z. B. das Dekorieren des Parameters mit einem [NotNull]Attribut und die Verwendung von Reflection wie folgt .

Abgesehen davon benötigen Sie möglicherweise nicht alle diese Tests, es sei denn, Ihre Klasse ist Teil einer öffentlich zugänglichen API.

Robert Harvey
quelle
3
Seltsam, dass dies abgelehnt wurde. Es ist dem Ansatz der am besten bewerteten Antwort sehr ähnlich, außer dass es nicht von Roslyn abhängt.
Robert Harvey
Was ich daran nicht mag, ist, dass es anfällig für Änderungen am Konstruktor ist.
jpmc26
@ jpmc26: Was heißt das?
Robert Harvey
1
Beim Lesen Ihrer Antwort wird empfohlen, den Konstruktor nicht zu testen , sondern eine allgemeine Methode zu erstellen, die für jeden Parameter aufgerufen wird, und diese zu testen. Wenn Sie ein neues Eigenschafts- / Argumentpaar hinzufügen und vergessen, nulldas Argument für den Konstruktor zu testen , wird kein fehlgeschlagener Test ausgegeben.
jpmc26
@ jpmc26: Natürlich. Das gilt aber auch, wenn Sie es auf herkömmliche Weise schreiben, wie es im OP dargestellt ist. Die übliche Methode besteht lediglich darin, das throwÄußere des Konstruktors zu verschieben. Sie übertragen nicht wirklich die Kontrolle innerhalb des Konstruktors an einen anderen Ort. Es ist nur eine nützliche Methode und eine Möglichkeit, Dinge flüssig zu machen , wenn Sie dies wünschen.
Robert Harvey
7

Kurzfristig kann man nicht viel gegen die Mühsamkeit tun, solche Tests zu schreiben. Es gibt jedoch einige Hilfestellungen für Throw-Ausdrücke, die im Rahmen der nächsten Version von C # (v7) implementiert werden sollen, wahrscheinlich in den nächsten Monaten:

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)); ;
    }
}

Sie können mit Throw-Ausdrücken über die Try Roslyn-Webapp experimentieren .

David Arno
quelle
Viel kompakter, danke. Halb so viele Zeilen. Ich freue mich auf C # 7!
Botond Balázs
@ BotondBalázs können Sie es jetzt in der Vorschau VS15 versuchen, wenn Sie wollen
user1306322
7

Ich bin überrascht, dass noch niemand NullGuard erwähnt hat . Es ist über NuGet verfügbar und speichert diese Null-Checks während der Kompilierungszeit automatisch in der IL.

Also wäre Ihr Konstruktorcode einfach

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

und NullGuard fügt diese Nullprüfungen hinzu, damit Sie sie in genau das umwandeln, was Sie geschrieben haben.

Beachten Sie aber, dass NullGuard ist Opt-out , das heißt, es wird diese Null - Kontrollen hinzuzufügen jede Methode und Konstruktorargument, Eigenschaft Getter und Setter und sogar überprüfen Methode Rückgabewerte , es sei denn Sie explizit erlauben einen Nullwert mit dem [AllowNull]Attribut.

Roman Reiner
quelle
Vielen Dank, das sieht nach einer sehr schönen allgemeinen Lösung aus. Es ist jedoch sehr bedauerlich, dass das Sprachverhalten standardmäßig geändert wird. Ich würde es viel mehr mögen, wenn es durch Hinzufügen eines [NotNull]Attributs funktioniert , anstatt zuzulassen, dass Null der Standardwert ist.
Botond Balázs
@ BotondBalázs: PostSharp hat dies zusammen mit anderen Attributen zur Parameterüberprüfung. Im Gegensatz zu Fody ist es jedoch nicht kostenlos. Der generierte Code ruft auch die PostSharp-DLLs auf, sodass Sie diese in Ihr Produkt aufnehmen müssen. Fody führt seinen Code nur während der Kompilierung aus und wird zur Laufzeit nicht benötigt.
Roman Reiner
@ BotondBalázs: Ich fand es anfangs auch komisch, dass NullGuard standardmäßig angewendet wird, aber es macht wirklich Sinn. In jeder vernünftigen Codebasis sollte das Zulassen von Null viel seltener vorkommen als das Überprüfen auf Null. Wenn Sie wirklich irgendwo null zulassen möchten, zwingt Sie der Opt-out-Ansatz, sehr gründlich damit umzugehen.
Roman Reiner
5
Ja, das ist vernünftig, verstößt aber gegen das Prinzip des geringsten Erstaunens . Wenn ein neuer Programmierer nicht weiß, dass das Projekt NullGuard verwendet, wird er eine große Überraschung erleben! Übrigens verstehe ich die Ablehnung auch nicht, das ist eine sehr nützliche Antwort.
Botond Balázs
1
@ BotondBalázs / Roman, sieht aus wie NullGuard hat einen neuen expliziten Modus, der die Art und Weise ändert, wie es standardmäßig funktioniert
Kyle W
5

Lohnt es sich, all diese Tests zu schreiben?

Nein wahrscheinlich nicht. Was ist die Wahrscheinlichkeit, dass Sie das vermasseln werden? Wie groß ist die Wahrscheinlichkeit, dass sich eine bestimmte Semantik unter Ihnen ändert? Was ist die Auswirkung, wenn jemand es vermasselt?

Wenn Sie eine Menge Zeit damit verbringen, Tests für etwas durchzuführen, das selten kaputt geht, und wenn dies der Fall ist, ist es eine triviale Lösung ... vielleicht nicht wert.

Wäre es eine gute Idee, einen Codegenerator für solche Klassen zu schreiben, um zu vermeiden, dass Tests für jede einzelne geschrieben werden?

Vielleicht? So etwas könnte man leicht mit Nachdenken machen. Es ist zu beachten, dass die Codegenerierung für den realen Code durchgeführt wird, sodass es keine N Klassen gibt, bei denen möglicherweise ein menschlicher Fehler vorliegt. Bug Prevention> Fehlererkennung.

Telastyn
quelle
Vielen Dank. Vielleicht war mir in meiner Frage nicht klar genug, aber ich wollte einen Generator schreiben, der unveränderliche Klassen generiert, keine Tests für sie
Botond Balázs
2
Ich habe auch mehrmals statt eines gesehen if...throwsie haben ThrowHelper.ArgumentNull(myArg, "myArg"), auf diese Weise die Logik immer noch da ist und Sie nicht auf Code - Coverage - dinged erhalten. Wo die ArgumentNullMethode das macht if...throw.
Matthew
2

Lohnt es sich, all diese Tests zu schreiben?

Nein,
weil ich ziemlich sicher bin, dass Sie diese Eigenschaften durch einige andere Logiktests getestet haben, bei denen diese Klassen verwendet werden.

Sie können beispielsweise Tests für die Factory-Klasse durchführen, deren Zusicherung auf diesen Eigenschaften basiert ( Namez. B. Instanz, die mit einer ordnungsgemäß zugewiesenen Eigenschaft erstellt wurde).

Wenn diese Klassen der öffentlichen API ausgesetzt sind, die von einem Drittanbieter / Endbenutzer verwendet wird (@EJoshua, danke für die Kenntnisnahme), können Tests für "erwartet ArgumentNullException" nützlich sein.

Während Sie auf C # 7 warten, können Sie die Erweiterungsmethode verwenden

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

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

Zum Testen können Sie eine parametrisierte Testmethode verwenden, die mithilfe der Reflektion eine nullReferenz für jeden Parameter erstellt und die erwartete Ausnahme bestätigt.

Fabio
quelle
1
Dies ist ein überzeugendes Argument dafür, die Initialisierung von Eigenschaften nicht zu testen.
Botond Balázs
Das hängt davon ab, wie Sie den Code verwenden. Wenn der Code Teil einer öffentlich zugänglichen Bibliothek ist, sollten Sie anderen Personen niemals blind vertrauen, dass sie wissen, wie sie mit Ihrer Bibliothek umgehen sollen (insbesondere, wenn sie keinen Zugriff auf den Quellcode haben). Wenn es sich jedoch um etwas handelt, das Sie nur intern verwenden, um Ihren eigenen Code zum Laufen zu bringen, sollten Sie wissen, wie Sie Ihren eigenen Code verwenden, damit die Prüfungen weniger einen Sinn haben.
EJoshuaS - Wiedereinsetzung von Monica
2

Sie könnten immer eine Methode wie die folgende schreiben:

// 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);
            }
        }
    }

Dies erspart Ihnen zumindest Tipparbeit, wenn Sie über mehrere Methoden verfügen, für die diese Art der Validierung erforderlich ist. Bei dieser Lösung wird natürlich davon ausgegangen, dass keiner der Parameter Ihrer Methode zulässig ist. nullSie können dies jedoch ändern, um dies zu ändern, wenn Sie dies wünschen.

Sie können dies auch erweitern, um eine andere typspezifische Validierung durchzuführen. Wenn Sie beispielsweise die Regel haben, dass Zeichenfolgen keine Leerzeichen oder Leerzeichen sein dürfen, können Sie die folgende Bedingung hinzufügen:

// 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");
                }
            }

Die GetParameterInfo-Methode, auf die ich mich beziehe, übernimmt im Grunde genommen die Reflektion (damit ich nicht immer wieder dasselbe eingeben muss, was eine Verletzung des DRY-Prinzips darstellen würde ):

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;
    }
EJoshuaS - Setzen Sie Monica wieder ein
quelle
1
Warum wird das abgelehnt? Es ist nicht schlecht
Gaspa79
0

Ich schlage nicht vor, Ausnahmen vom Konstruktor auszulösen. Das Problem ist, dass Sie Schwierigkeiten haben werden, dies zu testen, da Sie gültige Parameter übergeben müssen, auch wenn diese für Ihren Test irrelevant sind.

Beispiel: Wenn Sie testen möchten, ob der dritte Parameter eine Ausnahme auslöst, müssen Sie den ersten und den zweiten Parameter korrekt übergeben. Ihr Test ist also nicht mehr isoliert. Wenn sich die Bedingungen des ersten und des zweiten Parameters ändern, schlägt dieser Testfall fehl, auch wenn der dritte Parameter getestet wird.

Ich schlage vor, die Java-Validierungs-API zu verwenden und den Validierungsprozess zu externalisieren. Ich schlage vor, vier Aufgaben (Klassen) zu haben:

  1. Ein Vorschlagsobjekt mit mit Java-Validierung versehenen Parametern mit einer Validierungsmethode, die einen Satz von ConstraintViolations zurückgibt. Ein Vorteil ist, dass Sie diese Objekte umgehen können, ohne dass die Aussage gültig ist. Sie können die Überprüfung verzögern, bis sie erforderlich ist, ohne zu versuchen, das Domänenobjekt zu instanziieren. Das Validierungsobjekt kann in verschiedenen Ebenen verwendet werden, da es sich um ein POJO ohne schichtspezifische Kenntnisse handelt. Es kann Teil Ihrer öffentlichen API sein.

  2. Eine Factory für das Domänenobjekt, die für die Erstellung gültiger Objekte verantwortlich ist. Sie übergeben das Vorschlagsobjekt und die Factory ruft die Validierung auf und erstellt das Domänenobjekt, wenn alles in Ordnung ist.

  3. Das Domain-Objekt selbst, auf das für andere Entwickler zumeist nicht zugegriffen werden kann. Zu Testzwecken empfehle ich, diese Klasse im Paketumfang zu haben.

  4. Eine Public Domain-Schnittstelle, um das konkrete Domain-Objekt zu verbergen und Missbrauch zu erschweren.

Ich schlage nicht vor, auf Nullwerte zu prüfen. Sobald Sie in die Domänenebene gelangen, müssen Sie nicht mehr null übergeben oder null zurückgeben, solange Sie keine Objektketten haben, die über End- oder Suchfunktionen für einzelne Objekte verfügen.

Ein weiterer Punkt: Das Schreiben von möglichst wenig Code ist kein Maßstab für die Codequalität. Denken Sie an 32.000 Spiele-Wettbewerbe. Dieser Code ist der kompakteste, aber auch der chaotischste Code, den Sie haben können, da er sich nur um technische Probleme und nicht um Semantik kümmert. Aber Semantik sind die Punkte, die die Dinge umfassend machen.

oopexpert
quelle
1
Ich bin mit Ihrem letzten Punkt nicht einverstanden. Wenn Sie nicht zum 100. Mal dasselbe Boilerplate eingeben müssen , verringert sich das Risiko, einen Fehler zu machen. Stellen Sie sich vor, dies wäre Java, nicht C #. Ich müsste für jede Eigenschaft ein Hintergrundfeld und eine Getter-Methode definieren. Der Code würde völlig unlesbar und das, was wichtig ist, durch die klobige Infrastruktur verdeckt.
Botond Balázs