Максимизация охвата тестированием и минимизация перекрытия / дублирования


Каковы стратегии людей по максимальному охвату тестами при минимизации дублирования и перекрытия тестов, особенно между модульными тестами и функциональными или интеграционными тестами? Проблема не связана с каким-либо конкретным языком или фреймворком, но просто в качестве примера, скажем, у вас есть приложение Rails, которое позволяет пользователям оставлять комментарии. У вас может быть модель пользователя, которая выглядит примерно так:

class User < ActiveRecord::Base
  def post_comment(attributes)
    comment = self.comments.create(attributes)
    notify_friends('created', comment)
    share_on_facebook('created', comment)
    share_on_twitter('created', comment)
    award_badge('first_comment') unless self.comments.size > 1
  end

  def notify_friends(action, object)
    friends.each do |f|
      f.notifications.create(subject: self, action: action, object: object)
    end
  end

  def share_on_facebook(action, object)
    FacebookClient.new.share(subject: self, action: action, object: object)
  end

  def share_on_twitter(action, object)
    TwitterClient.new.share(subject: self, action: action, object: object)
  end

  def award_badge(badge_name)
    self.badges.create(name: badge_name)
  end
end

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

В любом случае, модульное тестирование метода post_comment довольно простое. Вы бы написали тесты, чтобы утверждать, что:

  • комментарий создается с заданными атрибутами
  • друзья пользователя получают уведомления о создании комментария пользователем
  • метод share вызывается на экземпляре FacebookClient с ожидаемым хэшем params
  • то же самое для TwitterClient
  • пользователь получает значок 'first_comment', когда это первый комментарий пользователя
  • пользователь не получает значок 'first_comment', когда у него есть предыдущие комментарии
Но тогда как вы пишете свои функциональные и / или интеграционные тесты, чтобы убедиться, что контроллер действительно вызывает эту логику и выдает желаемые результаты во всех различных сценариях?

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

Другой подход заключается в том, чтобы просто проверить, что контроллер вызывает метод post_comment у пользователя с ожидаемыми парами. Затем вы можете положиться на модульный тест post_comment, чтобы охватить все соответствующие тестовые случаи и проверить результаты. Это кажется более простым способом достижения желаемого покрытия, но теперь ваши тесты связаны с конкретной реализацией базового кода. Предположим, вы обнаружили, что ваши модели раздулись и их трудно поддерживать, и вы рефакторируете всю эту логику в объект обслуживания, подобный этому:

class PostCommentService
  attr_accessor :user, :comment_attributes
  attr_reader :comment

  def initialize(user, comment_attributes)
    @user = user
    @comment_attributes = comment_attributes
  end

  def post
    @comment = self.user.comments.create(self.comment_attributes)
    notify_friends('created', comment)
    share_on_facebook('created', comment)
    share_on_twitter('created', comment)
    award_badge('first_comment') unless self.comments.size > 1
  end

  private

  def notify_friends(action, object)
    self.user.friends.each do |f|
      f.notifications.create(subject: self.user, action: action, object: object)
    end
  end

  def share_on_facebook(action, object)
    FacebookClient.new.share(subject: self.user, action: action, object: object)
  end

  def share_on_twitter(action, object)
    TwitterClient.new.share(subject: self.user, action: action, object: object)
  end

  def award_badge(badge_name)
    self.user.badges.create(name: badge_name)
  end
end

Возможно, такие действия, как уведомление друзей, обмен информацией в twitter и т. д. логично было бы также преобразовать их в собственные объекты обслуживания. Независимо от того, как и почему вы рефакторируете, ваш функциональный или интеграционный тест теперь нужно было бы переписать, если бы он ранее ожидал, что контроллер вызовет post_comment на объекте пользователя. Кроме того, эти типы утверждений могут быть довольно громоздкими. В случае этого конкретного рефакторинга теперь необходимо будет утверждать, что конструктор PostCommentService вызывается с соответствующими атрибутами объекта пользователя и комментария, а затем утверждать, что метод post вызывается для возвращаемого объекта. Это становится грязным.

Кроме того, ваш результаты тестирования гораздо менее полезны в качестве документации, если функциональные и интеграционные тесты описывают реализацию, а не поведение. Например, следующий тест (с использованием Rspec) не очень полезен:

it "creates a PostCommentService object and executes the post method on it" do
  ...
end

Я бы предпочел такие тесты:

it "creates a comment with the given attributes" do
  ...
end

it "creates notifications for the user's friends" do
  ...
end

Как люди решают эту проблему? Есть ли другой подход, который я не рассматриваю? Не перегибаю ли я палку, пытаясь добиться полного покрытия кода?

1 2

1 ответ:

Голый со мной, я говорю с точки зрения .Net/C# здесь, но я думаю, что это в целом применимо...

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

Похоже, что в вашем оригинальном примере модульного теста выше вы говорите о тестировании всей логики (например, пост в facebook, twitter и т. д...) в тех же тестах. Я бы сказал, что если эти тесты написаны таким образом, то это уже функциональный тест. Теперь, если вы абсолютно не можете изменить тестируемый класс вообще, написание модульного теста на этом этапе было бы ненужным дублирование. Но если вы можете модифицировать тестируемый класс, рефакторингируя таким образом, чтобы зависимости находились за интерфейсами, вы можете иметь набор модульных тестов для каждого отдельного объекта, и меньший набор функциональных тестов, которые проверяют всю систему вместе, кажется, ведут себя правильно.

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