Pull to refresh

TDD для начинающих. Ответы на популярные вопросы

Reading time 8 min
Views 50K
Исходники проекта написанного с помощью TDD. Visual Studio 2008/C#
Для написания тестов использована библиотека xUnit, для создания mock-объектов – Moq.




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

Мне задают много вопросов про TDD. Из этих вопрсов я выбрал ключевые и написал на них ответы. Сами вопросы вы можете найти в тексте, они выделены курсивом.

В качестве отправной точки мы будем решать бизнес-задачу:

Задача состоит из нескольких подзадач: 1) написать консольное приложение, которое отправляет отчеты. 2) Каждый второй сформированный отчет надо отправлять ещё и аудиторам. 3) Если ни одного отчета не сформировано, то отправляем сообщение руководству о том, что отчетов нет. 4) После отправки всех отчётов, нужно вывести в консоль количество отправленных.

Вопрос: С чего начать писать код при TDD?

Начинаем с того, что будем решать одну небольшую задачу. Мы будем описывать бизнес-требование в коде с помощью теста.


Итак, я создал новый проект консольного приложения (финальный код можно скачать из SVN). Сейчас в нём нет ни одной строчки кода. Надо придумать, как вообще будет работать моё приложение. Внимание, начало! Создаем тест, который описывает 4-ое требование:

public class ReporterTests
{
  [Fact]
  public void ReturnNumberOfSentReports()
  {
    var reporter = new Reporter();

    var reportCount = reporter.SendReports();

    Assert.Equal(2, reportCount);
  }
}


* This source code was highlighted with Source Code Highlighter.


Класс Assert проверяет равно ли количество отосланных отчетов 2. Тест запускается консольной утилитой xUnit, либо каким-нибудь плагином к Visual Studio.

Только что мы спроектировали API нашего приложения. Мы будем использовать объект Reporter с функцией SendReports. Функция SendReports возвращает количество отправленных отчетов, это показывает тест с помощью утверждения Assert.Equal. Если переменная reportCount не будет равена 2, то тест не пройдет.

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

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


В нашем проекте на данный момент есть только один класс ReporterTests. Пора создать тестируемый класс Reporter. Добавляем в проект объект Reporter и создаем у него пустую функцию SendReports. Для того, чтобы тест прошёл, функция SendReports должна вернуть цифру 2. Пока не понятно, как задать начальные условия в объекте Reporter, чтобы функция SendReports вернула цифру 2.

Возвращаемся к проектированию. Я думаю, что у меня будет отдельный класс для создания отчётов, и класс для отправки отчётов. Сам объект Reporter будет управлять логикой взаимодействия этих классов. Назовем первый объект IReportBuilder, а второй – IReportSender. Попроектировали, пора написать код:

[Fact]
public void ReturnNumberOfSentReports()
{
  IReportBuilder reportBuilder;
  IReportSender reportSender;

  var reporter = new Reporter(reportBuilder, reportSender);

  var reportCount = reporter.SendReports();

  Assert.Equal(2, reportCount);
}


* This source code was highlighted with Source Code Highlighter.


Вопрос: есть ли правила для именования тестовых методов?
Да, есть. Желательно, чтобы название тестового метода показывало, что проверяет тест и какого результата мы ожидаем. В данном случае название говорит нам: «Возвращается количество отправленных отчётов».


Как будут работать классы, реализующие эти интерфейсы, сейчас не имеет значения. Главное, что мы можем сформировать IReportBuilder'ом все отчёты и отправить их с помощью IReportSender'а.

Вопрос: почему стоит использовать интерфейсы IReportBuilder и IReportSender, а не создать конкретные классы?
Реализовать объект для создания отчётов и объект для отправки отчётов можно по-разному. Сейчас удобнее скрыть будущие реализации этих классов за интерфейсами.

Вопрос: Как задать поведение объектов, с которыми взаимодействует наш тестируемый класс?
Вместо реальных объектов, с которыми взаимодействует наш тестируемый класс удобнее всего использовать заглушки или mock-объекты. В текущем приложении мы будем создавать mock-объекты с помощью библиотеки Moq.


[Fact]
public void ReturnNumberOfSentReports()
{
  var reportBuilder = new Mock<IReportBuilder>();
  var reportSender = new Mock<IReportSender>();

  // задаем поведение для интерфейса IReportBuilder
  // Здесь говорится: "При вызове функции CreateReports вернуть List<Report> состоящий из 2х объектов"
  reportBuilder.Setup(m => m.CreateRegularReports())
    .Returns(new List<Report> {new Report(), new Report()});

  var reporter = new Reporter(reportBuilder.Object, reportSender.Object);

  var reportCount = reporter.SendReports();

  Assert.Equal(2, reportCount);
}


* This source code was highlighted with Source Code Highlighter.


Запускаем тест – он не проходит, потому что мы не реализовали функцию SendReports. Программируем самую простую из возможных реализаций:

public class Reporter
{
  private readonly IReportBuilder reportBuilder;
  private readonly IReportSender reportSender;

  public Reporter(IReportBuilder reportBuilder, IReportSender reportSender)
  {
    this.reportBuilder = reportBuilder;
    this.reportSender = reportSender;
  }

  public int SendReports()
  {
    return reportBuilder.CreateRegularReports().Count;
  }
}


* This source code was highlighted with Source Code Highlighter.


Запускаем тест и он проходит. Мы реализовали 4-ое требование. При этом записали его в виде теста. Таким образом, мы составляем документацию нашей системы. Как показала практика – эта документация самая актуальная в любой момент времени и никогда не устаревает. Идем дальше.

Вопрос: Есть ли стандартный шаблон для написания теста?
Да. Он называется Arrange-Act-Assert (AAA). Т.е. тест состоит из трех частей. Arrange (Устанавливаем) – производим настройку входных данных для теста. Act (Действуем) – выполняем действие, результаты которого тестируем. Assert (Проверяем) – проверяем результаты выполнения. Я подпишу соответствующие этапы в следующем тесте.


Теперь займёмся первым требованием – отправлением отчётов. Тест будет проверять, что все созданные отчёты отправлены:

[Fact]
public void SendAllReports()
{
  // arrange
  var reportBuilder = new Mock<IReportBuilder>();
  var reportSender = new Mock<IReportSender>();

  reportBuilder.Setup(m => m.CreateRegularReports())
    .Returns(new List<Report> {new Report(), new Report()});

  var reporter = new Reporter(reportBuilder.Object, reportSender.Object);

  // act
  reporter.SendReports();

  // assert
  reportSender.Verify(m => m.Send(It.IsAny<Report>()), Times.Exactly(2));
}


* This source code was highlighted with Source Code Highlighter.


Вопрос: Надо ли писать тесты для всех объектов приложения в одном тестовом классе?
Очень нежелательно. В этом случае тестовый класс разрастется до огромных размеров. Лучше всего на каждый тестируемый класс создать отдельный файл с тестами.


Запускаем тест, он не проходит, потому что мы не реализовали отправку отчётов в функции SendReports. На этом, как обычно мы проектировать заканчиваем и переходим к кодированию:

public int SendReports()
{
  IList<Report> reports = reportBuilder.CreateRegularReports();

  foreach (Report report in reports)
  {
    reportSender.Send(report);
  }

  return reports.Count;
}


* This source code was highlighted with Source Code Highlighter.


Запускаем тесты – оба проходят. Мы реализовали ещё одно бизнес-требование. К тому же, запустив оба теста мы убедились, что не сломали функциональность, которую делали 5 минут назад.

Вопрос: Как часто надо запускать все тесты?
Чем чаще, тем лучше. Любое изменение в коде может неожиданно для вас отразится на других частях системы. Особенно, если этот код писали не Вы. В идеале все тесты должны запускаться автоматически системой интеграции (Continuous Integration) при каждой сборке проекта.

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


Пора подумать о том, как реализовывать третье требование. С чего начнем? Нарисуем UML-диаграммы или просто помедитируем сидя в кресле? Начнём с теста! Запишем 3-е бизнес-требование в коде:

[Fact]
public void SendSpecialReportToAdministratorIfNoReportsCreated()
{
  var reportBuilder = new Mock<IReportBuilder>();
  var reportSender = new Mock<IReportSender>();

  reportBuilder.Setup(m => m.CreateRegularReports()).Returns(new List<Report>());
  reportBuilder.Setup(m => m.CreateSpecialReport()).Returns(new SpecialReport());

  var reporter = new Reporter(reportBuilder.Object, reportSender.Object);

  reporter.SendReports();

  reportSender.Verify(m => m.Send(It.IsAny<Report>()), Times.Never());
  reportSender.Verify(m => m.Send(It.IsAny<SpecialReport>()), Times.Once());
}


* This source code was highlighted with Source Code Highlighter.


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

public int SendReports()
{
  IList<Report> reports = reportBuilder.CreateRegularReports();

  if (reports.Count == 0)
  {
    reportSender.Send(reportBuilder.CreateSpecialReport());
  }

  foreach (Report report in reports)
  {
    reportSender.Send(report);
  }

  return reports.Count;
}


* This source code was highlighted with Source Code Highlighter.


Запускаем тесты – все 3 теста проходят. Мы реализовали новую функции и не сломали старые. Это не может не радовать!

Вопрос: Как узнать какой код уже протестирован?
Покрытие кода тестами можно проверить с помощью различных утилит. Для начала могу посоветовать PartCover.

Вопрос: Надо ли стремиться покрыть код тестами на 100%?
Нет. Это потебует слишком больших усилий на создание таких тестов и ещё больше на их поддержку. Нормальное прокрытие колеблется от 50 до 90%. Т.е. должна быть покрыта вся бизнес-логика без обращений к базе данных, внешним сервисам и файловой системе.


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

Вопрос: Как же мне протестировать взаимодействие с базой данных, работу с SMTP-сервером или файловой системой?
Действительно, тестировать это нужно. Но это делается не модульными тестами. Потому что модульные тесты должны проходить быстро и не зависеть от внешних источников. Иначе вы будете запускать их раз в неделю. Более подробно об этом написано в статье «Эффективный модульный тест».

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


Заключение



Я желаю каждому разработчику попробовать эту практику. После этого можно решить, насколько TDD подходит для Вас лично и для проекта в целом.
Tags:
Hubs:
+28
Comments 65
Comments Comments 65

Articles