Есть ли более простой способ проверить валидацию аргумента и инициализацию поля в неизменяемом объекте?

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 # изначально поддерживать неизменяемость и необнуляемые ссылочные типы. Но что я могу сделать, чтобы улучшить ситуацию? Стоит ли писать все эти тесты? Было бы хорошей идеей написать генератор кода для таких классов, чтобы избежать написания тестов для каждого из них?


Вот что я сейчас основал на ответах.

Я мог бы упростить нулевые проверки и инициализацию свойств, чтобы они выглядели так:

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

Используя следующую реализацию Роберта Харви :

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(спасибо за предложение, Эсбен Сков Педерсен ):

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
Заголовок / тег говорит о (модульном) тестировании значений полей, подразумевающем модульное тестирование объекта после построения, но фрагмент кода показывает проверку входного аргумента / параметра; это не одни и те же понятия.
Эрик Эйдт
2
К вашему сведению, вы могли бы написать шаблон T4, который бы упростил этот пример. (Вы можете также рассмотреть string.IsEmpty () за пределами == null.)
Эрик Эйдт
1
Вы смотрели на идиомы автокрепления? nuget.org/packages/AutoFixture.Idioms
Эсбен Сков Педерсен
1
Связанный: stackoverflow.com/questions/291340/…
Док Браун
1
Вы также можете использовать Fody / NullGuard , хотя, похоже, у него нет режима разрешения по умолчанию.
Боб

Ответы:

5

Я создал шаблон t4 именно для таких случаев. Чтобы не писать много шаблонов для неизменных классов.

https://github.com/xaviergonz/T4Immutable T4Immutable - это шаблон T4 для приложений на C # .NET, который генерирует код для неизменяемых классов.

Говоря конкретно о ненулевых тестах, тогда, если вы используете это:

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

Также есть еще несколько функций, чем этот.

Хавьер Г.
источник
Спасибо. Я только что попробовал, и это сработало отлично. Я отмечаю это как принятый ответ, так как он решает все проблемы исходного вопроса.
Ботонд Balázs
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]атрибутом и использование отражения таким образом .

Тем не менее, вам могут не понадобиться все эти тесты, если ваш класс не является частью общедоступного API.

Роберт Харви
источник
3
Странно, что за это проголосовали. Это очень похоже на подход, предоставленный наиболее голосуемым ответом, за исключением того, что он не зависит от Roslyn.
Роберт Харви
Что мне не нравится в этом, так это то, что он уязвим для изменений в конструкторе.
jpmc26
@ jpmc26: Что это значит?
Роберт Харви
1
Когда я читаю ваш ответ, он предлагает вам не тестировать конструктор , а вместо этого создать какой-то общий метод, вызываемый для каждого параметра, и протестировать его. Если вы добавите новую пару свойство / аргумент и забудете проверить nullаргумент в конструкторе, тест не будет пройден.
jpmc26
@ jpmc26: естественно. Но это также верно, если вы пишете это обычным способом, как показано в ОП. Все, что делает общий метод - это перемещает throwвнешнюю часть конструктора; вы не передаете контроль в конструкторе где-то еще. Это просто полезный метод и способ бегать , если вы того пожелаете.
Роберт Харви
7

В краткосрочной перспективе, вы не можете ничего сделать с утомительностью написания таких тестов. Тем не менее, есть некоторая помощь с выражениями throw, которые должны быть реализованы как часть следующего выпуска 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)); ;
    }
}

Вы можете поэкспериментировать с выражениями throw через веб-приложение Try Roslyn .

Дэвид Арно
источник
Гораздо компактнее, спасибо. В два раза меньше строк. С нетерпением жду C # 7!
Ботонд Balázs
@ BotondBalázs, вы можете попробовать его в предварительном просмотре VS15 прямо сейчас, если хотите
user1306322
7

Я удивлен, что никто не упомянул NullGuard . Он доступен через 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 добавит эти нулевые проверки для вас, превратив его именно в то, что вы написали.

Тем не менее, обратите внимание, что NullGuard отключен , то есть он будет добавлять эти нулевые проверки к каждому методу и аргументу конструктора, получателю и установщику свойства и даже проверять возвращаемые значения метода, если вы явно не разрешите нулевое значение с [AllowNull]атрибутом.

Римский рейнер
источник
Спасибо, это выглядит как очень хорошее общее решение. Хотя очень жаль, что по умолчанию он изменяет поведение языка. Я бы хотел, чтобы это было намного больше, если бы он работал путем добавления [NotNull]атрибута, а не для того, чтобы значение по умолчанию было пустым.
Ботонд Balázs
@ BotondBalázs: PostSharp имеет это вместе с другими атрибутами проверки параметров. В отличие от Фоди это не бесплатно, хотя. Кроме того, сгенерированный код будет вызывать библиотеки PostSharp DLL, поэтому вам необходимо включить их в ваш продукт. Fody будет запускать свой код только во время компиляции и не потребуется во время выполнения.
Роман Рейнер
@ BotondBalázs: Сначала мне показалось странным, что NullGuard применяется по умолчанию, но это действительно имеет смысл. В любой разумной кодовой базе разрешение на null должно встречаться гораздо реже, чем проверка на null. Если вы действительно хотите разрешить использование null где-то, подход отказа от участия заставляет вас быть очень внимательным с ним.
Роман Рейнер
5
Да, это разумно, но это нарушает принцип наименьшего удивления . Если новый программист не знает, что проект использует NullGuard, их ждет большой сюрприз! Кстати, я тоже не понимаю, понижающий голос, это очень полезный ответ.
Ботонд Balázs
1
@ BotondBalázs / Roman, похоже, в NullGuard появился новый явный режим, который меняет способ работы по умолчанию
Кайл W
5

Стоит ли писать все эти тесты?

Нет, наверное нет. Какова вероятность, что вы собираетесь все испортить? Какова вероятность того, что какая-то семантика изменится из-под вас? Как это повлияет, если кто-то все испортит?

Если вы тратите кучу времени на тестирование чего-то, что будет редко ломаться, и это тривиальное исправление, если оно есть ... возможно, оно того не стоит.

Было бы хорошей идеей написать генератор кода для таких классов, чтобы избежать написания тестов для каждого из них?

Может быть? Подобные вещи можно легко сделать с помощью рефлексии. Стоит подумать о создании кода для реального кода, поэтому у вас нет N классов, которые могут иметь человеческую ошибку. Предотвращение ошибок> обнаружение ошибок.

Telastyn
источник
Спасибо. Возможно, я не был достаточно ясен в своем вопросе, но я хотел написать генератор, который генерирует неизменяемые классы, а не тесты для них
Botond Balázs
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ссылки для каждого параметра и подтверждения ожидаемого исключения.

Fabio
источник
1
Это убедительный аргумент в пользу того, чтобы не тестировать инициализацию свойств.
Ботонд Balázs
Это зависит от того, как вы используете код. Если код является частью общедоступной библиотеки, вы никогда не должны слепо доверять другим людям знать, как работать с вашей библиотекой (особенно если у них нет доступа к исходному коду); однако, если это то, что вы просто используете внутренне, чтобы заставить свой собственный код работать, вы должны знать, как использовать свой собственный код, поэтому проверки имеют меньшую цель.
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;
    }
EJoshuaS - Восстановить Монику
источник
1
Почему это понижается? Это не так уж плохо
Gaspa79
0

Я не предлагаю бросать исключения из конструктора. Проблема в том, что вам будет трудно проверить это, так как вам нужно передать действительные параметры, даже если они не имеют отношения к вашему тесту.

Например: если вы хотите проверить, вызывает ли третий параметр исключение, вы должны правильно передать первый и второй. Таким образом, ваш тест больше не изолирован. когда ограничения первого и второго параметра изменяются, этот тестовый случай не пройден, даже если он проверяет третий параметр.

Я предлагаю использовать API проверки Java и выполнить внешний процесс проверки. Я предлагаю задействовать четыре класса (занятия):

  1. Объект предложения с проверкой Java аннотировал параметры с помощью метода validate, который возвращает набор ConstraintViolations. Одним из преимуществ является то, что вы можете обойти эти объекты без утверждения, чтобы быть действительным. Вы можете отложить проверку, пока она не станет необходимой, без попытки создания экземпляра объекта домена. Объект проверки может использоваться на разных уровнях, так как это POJO без специальных знаний уровня. Это может быть частью вашего публичного API.

  2. Фабрика для объекта домена, который отвечает за создание допустимых объектов. Вы передаете объект предложения, и фабрика вызовет проверку и создаст объект домена, если все в порядке.

  3. Сам объект домена, который должен быть в основном недоступен для реализации другими разработчиками. Для целей тестирования я предлагаю иметь этот класс в объеме пакета.

  4. Публичный интерфейс домена, чтобы скрыть конкретный объект домена и затруднить злоупотребление.

Я не предлагаю проверять на нулевые значения. Как только вы попадаете в слой домена, вы избавляетесь от перехода на нуль или возврата ноля, если у вас нет цепочек объектов, которые имеют концы, или функции поиска для отдельных объектов.

Еще один момент: написание как можно меньшего количества кода не является метрикой качества кода. Подумайте о соревнованиях 32k игр. Этот код - самый компактный, но и самый грязный код, который вы можете иметь, потому что он заботится только о технических проблемах и не заботится о семантике. Но семантика - это то, что делает вещи всеобъемлющими.

oopexpert
источник
1
Я с уважением не согласен с вашим последним пунктом. Не имея вводить один и тот же шаблонного на 100 - й раз это поможет уменьшить вероятность сделать ошибку. Представьте, если бы это была Java, а не C #. Я должен был бы определить вспомогательное поле и метод получения для каждого свойства. Код станет полностью нечитаемым, а то, что важно, будет скрыто неуклюжей инфраструктурой.
Ботонд Балаш