Статьи

Модульное тестирование, когда мешают объекты значения

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

В середине мы находим функциональные тесты, которые осуществляют группу объектов . Повторяющаяся проблема заключается в том, что соседние классы C переходят в юнит-тесты не связанных классов; эта ситуация преобразует то, что будет модульным тестом исходного класса O, в функциональные тесты O и C вместе (возможно, с участием нескольких классов C).

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

Рассмотрим этот тест, где исходный класс — DocumentsDeclarationNodeCommand, а сотрудничающий — InMemoryDocumentCopy:

    @Test
    public void shouldSendTheListOfDocumentsAndWaitForAcknowledgement() throws ConnectionClosedException
    {
        UpstreamConnection upstream = mock(UpstreamConnection.class);
        DownstreamConnection downstream = mock(DownstreamConnection.class);
        InMemoryDocumentCopy first = new InMemoryDocumentCopy("1.txt", "hello");
        InMemoryDocumentCopy second = new InMemoryDocumentCopy("2.txt", "hello2");
        DocumentsDeclarationNodeCommand command = DocumentsDeclarationNodeCommand.fromDocumentCopies(
                Arrays.<DocumentCopy>asList(first, second), 10001);
        
        command.execute(upstream, downstream);
        
        InOrder inOrder = inOrder(upstream, downstream);
        inOrder.verify(upstream).command("DOCUMENTS|PORT=10001");
        inOrder.verify(upstream).command("1.txt|5");
        inOrder.verify(upstream).command("2.txt|6");
        inOrder.verify(upstream).endCommandSection();
        inOrder.verify(downstream, times(1)).readResponse();
    }

Два ожидания для command () делают этот тест функциональным: изменение формата текстовой сериализации InMemoryDocumentCopy (например, «1.txt | sha1_hash | 5» ) прервет эту проверку, даже если DocumentsDeclarationNodeCommand все еще работает. Однако мы не можем избежать проверки того, что документы действительно отправляются на сервер этим объектом.

Функциональные тесты можно снова преобразовать в модульные тесты, протестировав O с помощью Test Double вместо C (заглушка или макет). Единственной оставшейся зависимостью будет интерфейс интерфейса C, который можно даже извлечь в независимый сущность (первоклассный интерфейс на языке, который поддерживает их, такие как Java, C # и PHP.)

Чистые функции

Что происходит, когда вы не можете легко ввести Test Double для поддержания тестов на уровне единиц? Эта проблема существует в функциональных языках, где функции вызывают дерево других функций.

Аналоговый подход к внедрению зависимостей состоит в том, чтобы внедрить функцию как параметр, но, вероятно, он не масштабируется до уровня внедрения, которое мы выполняем над объектами: каждая сигнатура функции должна получать все сотрудничающие в качестве дополнительных параметров. Существуют даже фальшивые фреймворки для функциональных языков, таких как язык Марика, которые способны изолировать функцию от ее соавторов.

Дядя Боб вместо этого использует шаблон « Производное ожидание» :

testing "update-all"
  (let [
    o1 (make-object ...)
    o2 (make-object ...)
    o3 (make-object ...)
    os [o1 o2 o3]
    us (update-all os)
    ]
    (is (= (nth us 0) (reposition (accelerate (accumulate-forces os o1)
    (is (= (nth us 1) (reposition (accelerate (accumulate-forces os o2)
    (is (= (nth us 2) (reposition (accelerate (accumulate-forces os o3)
    )
  )

Функция update-all вызывает внутреннее перемещение, ускорение и накопление сил (или вызывает другие функции, которые в свою очередь вызывают эти три). Вместо указания нечитаемых буквальных ожиданий в таких тестах, как (1.096, 4.128), этот подход позволяет тесту указывать ссылку update-all на другие функции без введения магических чисел. Таким образом, это модульный тест для обновления всего, тогда как тот же тест, содержащий числа, будет функциональным тестом.

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

Тесты с полученными ожиданиями

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

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

  • они неизменны , поэтому их методы всегда возвращают один и тот же результат.
  • Их код, как правило, самодостаточен и прост , поэтому методу трудно создать исключение или выполнить внутренний разрыв после правильного построения объекта значения.
  • Будучи простыми, финальными классами, они не реализуют явный интерфейс; и они обычно не заменяются двойниками . Их поведение смешивается с объектами, использующими их.

Тест становится:

   @Test
    public void shouldSendTheListOfDocumentsAndWaitForAcknowledgement() throws ConnectionClosedException
    {
        UpstreamConnection upstream = mock(UpstreamConnection.class);
        DownstreamConnection downstream = mock(DownstreamConnection.class);
        InMemoryDocumentCopy first = new InMemoryDocumentCopy("1.txt", "hello");
        InMemoryDocumentCopy second = new InMemoryDocumentCopy("2.txt", "hello2");
        DocumentsDeclarationNodeCommand command = DocumentsDeclarationNodeCommand.fromDocumentCopies(
                Arrays.<DocumentCopy>asList(first, second), 10001);
        
        command.execute(upstream, downstream);
        
        InOrder inOrder = inOrder(upstream, downstream);
        inOrder.verify(upstream).command("DOCUMENTS|PORT=10001");
        inOrder.verify(upstream).command(first.toString());
        inOrder.verify(upstream).command(second.toString());
        inOrder.verify(upstream).endCommandSection();
        inOrder.verify(downstream, times(1)).readResponse();
    }

Вывод

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

В этом примере DocumentsDeclarationNodeCommand тестируется с привлечением реального соавтора, но с его настройкой вместо производных литеральных ожиданий. В результате этот тест привязывается только к сигнатурам метода соавтора, а не к реальному поведению (выходной формат toString ()).

Эту технику не нужно часто использовать: ее цель — изолировать от неизменяемого объекта, не вводя Test Double.