Модульное тестирование в мире «без сеттера»

23

Я не считаю себя экспертом DDD, но, как архитектор решений, стараюсь по возможности применять лучшие практики. Я знаю, что вокруг «за» и «против» нет (публичного) «стиля» сеттера в DDD, много споров, и я вижу обе стороны аргумента. Моя проблема в том, что я работаю в команде с широким разнообразием навыков, знаний и опыта, и это означает, что я не могу поверить, что каждый разработчик будет делать вещи «правильным» образом. Например, если наши доменные объекты спроектированы так, что изменения внутреннего состояния объекта выполняются методом, но предоставляют установщики открытых свойств, кто-то неизбежно установит свойство вместо вызова метода. Используйте этот пример:

public class MyClass
{
    public Boolean IsPublished
    {
        get { return PublishDate != null; }
    }

    public DateTime? PublishDate { get; set; }

    public void Publish()
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        PublishDate = DateTime.Today;

        Raise(new PublishedEvent());
    }
}

Мое решение состояло в том, чтобы сделать установщики свойств частными, что возможно, потому что ORM, который мы используем для гидратации объектов, использует отражение, чтобы иметь возможность доступа к частным установщикам. Однако это создает проблему при попытке написания модульных тестов. Например, когда я хочу написать модульный тест, который проверяет требование, которое мы не можем повторно опубликовать, я должен указать, что объект уже был опубликован. Конечно, я могу сделать это, дважды позвонив в Publish, но тогда мой тест предполагает, что Publish реализован правильно для первого вызова. Это кажется немного вонючим.

Давайте сделаем сценарий немного более реальным с помощью следующего кода:

public class Document
{
    public Document(String title)
    {
        if (String.IsNullOrWhiteSpace(title))
            throw new ArgumentException("title");

        Title = title;
    }

    public String ApprovedBy { get; private set; }
    public DateTime? ApprovedOn { get; private set; }
    public Boolean IsApproved { get; private set; }
    public Boolean IsPublished { get; private set; }
    public String PublishedBy { get; private set; }
    public DateTime? PublishedOn { get; private set; }
    public String Title { get; private set; }

    public void Approve(String by)
    {
        if (IsApproved)
            throw new InvalidOperationException("Already approved.");

        ApprovedBy = by;
        ApprovedOn = DateTime.Today;
        IsApproved = true;

        Raise(new ApprovedEvent(Title));
    }

    public void Publish(String by)
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        if (!IsApproved)
            throw new InvalidOperationException("Cannot publish until approved.");

        PublishedBy = by;
        PublishedOn = DateTime.Today;
        IsPublished = true;

        Raise(new PublishedEvent(Title));
    }
}

Я хочу написать модульные тесты, которые проверяют:

  • Я не могу опубликовать, если документ не был утвержден
  • Я не могу повторно опубликовать документ
  • Когда опубликовано, значения ОпубликованоBy и ОпубликованоOn установлены правильно
  • Когда опубликовано, ОпубликованоEvent повышается

Без доступа к установщикам я не могу перевести объект в состояние, необходимое для выполнения тестов. Открытие доступа к сеттерам сводит на нет цель предотвращения доступа.

Как (есть) вы решили (d) эту проблему?

SonOfPirate
источник
Чем больше я думаю об этом, тем больше я думаю, что вся ваша проблема заключается в использовании методов с побочными эффектами. Вернее, изменчивый неизменный объект. В мире DDD не следует ли вам возвращать новый объект Document как из Approve, так и из Publish, вместо того, чтобы обновлять внутреннее состояние этого объекта?
фунтовые
1
Быстрый вопрос, какой O / RM вы используете. Я большой поклонник EF, но объявление сеттеров как защищенных действительно немного меня теряет.
Майкл Браун
Сейчас у нас есть смесь из-за разработки свободного диапазона, в которой я был обвинен в споре. Некоторые ADO.NET используют AutoMapper для гидратации из DataReader, пару моделей Linq-to-SQL (это будет следующая замена ) и некоторые новые модели EF.
SonOfPirate
Позвонить в «Публикация дважды» совсем не воняет, и это способ сделать это.
Петр Перак

Ответы:

27

Я не могу поставить объект в состояние, необходимое для выполнения тестов.

Если вы не можете перевести объект в состояние, необходимое для выполнения теста, вы не можете перевести объект в состояние в производственном коде, поэтому нет необходимости проверять это состояние. Очевидно, что это не так в вашем случае, вы можете перевести ваш объект в нужное состояние, просто вызовите Approve.

  • Я не могу опубликовать, если Документ не был утвержден: напишите тест, в котором вызов публикации перед вызовом утверждения вызывает правильную ошибку без изменения состояния объекта.

    void testPublishBeforeApprove() {
        doc = new Document("Doc");
        AssertRaises(doc.publish, ..., NotApprovedException);
    }
    
  • Я не могу повторно опубликовать документ: напишите тест, который утверждает объект, затем вызовите публикацию, если один раз успешно, но второй раз вызывает правильную ошибку без изменения состояния объекта.

    void testRePublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        AssertRaises(doc.publish, ..., RepublishException);
    }
    
  • Когда публикуется, значения ОпубликованоBy и ОпубликованоOn установлены правильно: напишите тест, который вызывает утверждение, затем вызовите публикацию, утверждая, что состояние объекта изменяется правильно

    void testPublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        Assert(doc.PublishedBy, ...);
        ...
    }
    
  • При публикации публикуется событиеEventEvent: подключите систему событий и установите флаг, чтобы убедиться, что она вызывается.

Вам также нужно написать тест для утверждения.

Другими словами, не проверяйте отношения между внутренними полями и IsPublished и IsApproved, ваш тест будет довольно хрупким, если вы сделаете это, поскольку изменение вашего поля будет означать изменение кода вашего теста, поэтому тест будет совершенно бессмысленным. Вместо этого вы должны проверить связь между вызовами открытых методов, таким образом, даже если вы изменяете поля, вам не нужно изменять тест.

Ли Райан
источник
При утверждении перерывов несколько тестов перерываются. Вы больше не тестируете блок кода, вы тестируете полную реализацию.
фунтовые
Я разделяю озабоченность pdr, поэтому я не решался идти в этом направлении. Да, это кажется самым чистым, но мне не нравится иметь многократные причины, по которым отдельный тест может провалиться.
SonOfPirate
4
Я еще не видел модульное тестирование, которое могло бы провалиться только по одной возможной причине. Кроме того, вы можете поместить части «манипуляции с состоянием» теста в setup()метод, а не в сам тест.
Питер К.
12
Почему зависит approve()как-то хрупко, но setApproved(true)как-то нет? approve()является законной зависимостью в тестах, потому что это зависимость в требованиях. Если бы зависимость существовала только в тестах, это было бы другой проблемой.
Карл Билефельдт
2
@pdr, как бы вы протестировали класс стека? Вы пытаетесь бы проверить push()и pop()методы самостоятельно?
Уинстон Эверт
2

Еще один подход заключается в создании конструктора класса, который позволяет устанавливать внутренние свойства при создании экземпляра:

 public Document(
  String approvedBy,
  DateTime? approvedOn,
  Boolean isApproved,
  Boolean isPublished,
  String publishedBy,
  DateTime? publishedOn,
  String title)
{
  ApprovedBy = approvedBy;
  ApprovedOn = approvedOn;
  IsApproved = isApproved;
  IsApproved = isApproved;
  PublishedBy = publishedBy;
  PublishedOn = publishedOn;
}
Питер К.
источник
2
Это не очень хорошо масштабируется. Мой объект может иметь гораздо больше свойств, причем любое количество из них имеет или не имеет значений в любой заданной точке жизненного цикла объекта. Я следую принципу, согласно которому конструкторы содержат параметры для свойств, которые необходимы для того, чтобы объект находился в допустимом начальном состоянии, или зависимости, необходимые для функционирования объекта. Назначение свойств в этом примере - захват текущего состояния при манипулировании объектом. Наличие конструктора с каждым свойством или перегрузками с различными комбинациями - огромный запах и, как я уже сказал, не масштабируется.
SonOfPirate
Понял. В вашем примере не упоминалось много других свойств, и число в этом примере «на пороге» того, чтобы иметь это как правильный подход. Кажется, это говорит вам кое-что о вашем дизайне: вы не можете перевести свой объект в любое допустимое состояние при создании экземпляра. Это означает, что вам нужно перевести его в правильное начальное состояние, и они манипулируют им в правильном состоянии для проверки. Это подразумевает, что ответ Ли Райана - путь .
Питер К.
Даже если объект имеет одно свойство и никогда не изменится, это решение плохо. Что мешает кому-либо использовать этот конструктор в производстве? Как вы отметите этот конструктор [TestOnly]?
Петр Перак,
Почему это плохо на производстве? (Действительно, я хотел бы знать). Иногда при воссоздании необходимо воссоздать точное состояние объекта, а не только одного действительного исходного объекта.
Питер К.
1
Таким образом, хотя это помогает перевести объект в допустимое начальное состояние, для проверки поведения объекта, находящегося в нем, в течение его жизненного цикла требуется, чтобы объект был изменен из исходного состояния. Мой OP имеет отношение к тестированию этих дополнительных состояний, когда вы не можете просто установить свойства для изменения состояния объекта.
SonOfPirate
1

Одна из стратегий заключается в том, что вы наследуете класс (в данном случае Document) и пишете тесты для унаследованного класса. Унаследованный класс позволяет каким-то образом установить состояние объекта в тестах.

В C # одна стратегия может заключаться в том, чтобы сделать сеттеры внутренними, а затем подвергнуть внутренние компоненты тестовому проекту.

Вы также можете использовать API класса, как вы описали («Я, конечно, могу сделать это, дважды позвонив в Publish»). Это будет установка состояния объекта с использованием общедоступных сервисов объекта, мне это не кажется слишком вонючим. В случае с вашим примером, вероятно, я бы так и сделал.

simoraman
источник
Я думал об этом как о возможном решении, но колебался, чтобы сделать мои свойства переопределенными или выставить сеттеры защищенными, потому что мне казалось, что я открываю объект и нарушаю инкапсуляцию. Я думаю, что создание защищенных свойств, безусловно, лучше, чем публичное или даже внутреннее / дружеское. Я определенно уделю этому подходу больше внимания. Это просто и эффективно. Иногда это лучший подход. Если кто-то не согласен, пожалуйста, добавьте комментарии со спецификой.
SonOfPirate
1

Чтобы протестировать в абсолютной изоляции команды и запросы, которые получают доменные объекты, я использовал для каждого теста сериализацию объекта в ожидаемом состоянии. В разделе аранжировки теста загружается объект для тестирования из файла, который я предварительно подготовил. Сначала я начал с двоичных сериализаций, но оказалось, что json гораздо проще поддерживать. Это доказало свою эффективность, когда абсолютная изоляция в тестах дает реальную ценность.

отредактируйте только примечание, иногда JSON-сериализация завершается неудачно (как в случае с циклическими графами объектов, которые являются запахом, кстати). В таких ситуациях я спасаю бинарную сериализацию. Это немного прагматично, но работает. :-)

Джакомо Тесио
источник
И как подготовить объект в ожидаемом состоянии, если нет установщика, и вы не хотите вызывать его публичные методы для его установки?
Петр Перак,
Я написал небольшой инструмент для этого. Он загружает класс с помощью отражения, который создает новый объект, используя его открытый конструктор (обычно с использованием только идентификатора), и вызывает массив Action <TEntity> для него, сохраняя снимок после каждой операции (с обычным именем, основанным на индексе действия и название объекта). Инструмент выполняется вручную при каждом рефакторинге кода объекта, и снимки отслеживаются DCVS. Очевидно , каждое действие вызывает публичную команду субъекта, но это делается из прогонов тестов , что этот способ действительно Unit тест.
Джакомо Тесио
Я не понимаю, как это что-то меняет. Если он все еще вызывает публичные методы для sut (тестируемой системы), то он ничем не отличается от простого вызова этих методов в тесте.
Петр Перак
После создания снимков они сохраняются в файлах. Каждый тест зависит не от последовательности операций, необходимых для получения начального состояния объекта, а от самого состояния (загружается из моментального снимка). Затем сам тестируемый метод изолируется от изменений других методов.
Джакомо Тесио
Что, когда кто-то изменяет открытый метод, который использовался для подготовки сериализованного состояния для ваших тестов, но забывает запустить инструмент для регенерации сериализованного объекта? Тесты все еще зеленые, даже если в коде есть ошибка. Тем не менее я говорю, что это ничего не меняет. Вы по-прежнему запускаете публичные методы, поэтому настройте тестируемые объекты. Но вы запускаете их задолго до запуска тестов.
Петр Перак
-7

Ты говоришь

старайтесь по возможности применять лучшие практики

а также

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

и я должен думать, что использование отражения для обхода контроля доступа в ваших классах - это не то, что я бы назвал «лучшей практикой» Это будет ужасно медленно тоже.


Лично я бы отказался от вашей среды модульных тестов и занялся чем-то в классе - похоже, вы пишете тесты с точки зрения тестирования всего класса в любом случае, и это хорошо. В прошлом для некоторых хитрых компонентов, которые требовали тестирования, я встраивал код утверждений и настройки в сам класс (раньше это был общий шаблон проектирования, в котором метод test () использовался в каждом классе), поэтому вы создавали клиент это просто создает экземпляр объекта и вызывает тестовый метод, который может настроить себя, как вам нравится, без всяких мерзостей, таких как рефлексы.

Если вы беспокоитесь о раздувании кода, просто оберните методы тестирования в #ifdefs, чтобы сделать их доступными только в отладочном коде (вероятно, самой лучшей практикой)

gbjbaanb
источник
4
-1: Отказ от вашей тестовой среды и возврат к методам тестирования внутри класса вернутся в эпоху юнит-тестирования.
Роберт Джонсон
9
Нет -1 от меня, но включение тестового кода в производство, как правило, плохо .
Питер К.
что еще делает ОП? Придерживаться завинчивания частных сеттеров ?! Это как выбирать, какой яд вы хотите выпить. Мое предложение для OP состояло в том, чтобы поместить модульный тест в отладочный код, а не в рабочий. По моему опыту, размещение модульных тестов в другом проекте просто означает, что проект в любом случае тесно связан с оригиналом, так что от разработчика PoV есть небольшое различие.
gbjbaanb