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

На очередном собеседовании, спрашивая о 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 подходит для Вас лично и для проекта в целом.