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

28 января 2010 г.

Исходники проекта написанного с помощью TDD (git, C#)

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

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

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

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

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

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

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

public class ReporterTests
{
    [Fact]
    public void ReturnNumberOfSentReports()
    {
        var reporter = new Reporter();
        
        var reportCount = reporter.SendReports();
        
        Assert.Equal(2, reportCount);
    }
}

Класс 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);
}

Вопрос: есть ли правила для именования тестовых методов?

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

Как будут работать классы, реализующие эти интерфейсы, сейчас не имеет значения. Главное, что мы можем сформировать 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);
}

Запускаем тест – он не проходит, потому что мы не реализовали функцию 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;
    }
}

Запускаем тест и он проходит. Мы реализовали 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));
}

Вопрос: Надо ли писать тесты для всех объектов приложения в одном тестовом классе?

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

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

public int SendReports()
{
    IList<Report> reports = reportBuilder.CreateRegularReports();
 
    foreach (Report report in reports)
    {
        reportSender.Send(report);
    }
 
    return reports.Count;
}

Запускаем тесты – оба проходят. Мы реализовали ещё одно бизнес-требование. К тому же, запустив оба теста мы убедились, что не сломали функциональность, которую делали 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());
}

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

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;
}

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

Вопрос: Как узнать какой код уже протестирован?

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

Вопрос: Надо ли стремиться покрыть код тестами на 100%?

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

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

Вопрос: Как же мне протестировать взаимодействие с базой данных, работу с SMTP-сервером или файловой системой?

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

Вопрос: Когда я могу применять TDD?

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

Заключение

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

Ссылки:

48 комментариев:

  1. Спасибо, много нового для себя почерпнул

    ОтветитьУдалить
  2. Спасибо за статью, но так, к сожалению, и не удалось найти ответа на вопрос - зачем это нужно и почему это так важно. Буду благодарен, если укажете, где об этом можно прочитать или как понять - почему это стоит ипользовать в работе. Для меня это пока остается необязательным наворотом, если есть время. И еще вопрос - работаю над проектом, который построен по принципу table module. Как в данном случае нужно строить тесты - ведь основная работа происходит с обработкой данных таблиц.

    ОтветитьУдалить
  3. @El

    Вообще надо исходить из ваших потребностей или проблем в проекте. У вас все идет отлично? Тогда вам не нужно TDD. Разрабатывайте как привыкли.

    Но в вашем проекте могут быть и проблемы. Если возникают трудности с тем, что после года разработки вы боитесь добавить новую функцию в систему. Изменяете один класс, при этом ломается функциональность, которая вроде как от этого класса и не зависит.

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

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

    ОтветитьУдалить
  4. Как вы относитесь к наличию в одном тестовом методе нескольких Assert'ов?

    ОтветитьУдалить
  5. @Gwynbleidd

    Спасибо за вопрос, видно вы уже применяли TDD ;)

    Вообще я привык разрабатывать тест с написания Assert'а. Т.е. Assert в тестовом методе скорее всего будет один, но бывают и исключения.

    ОтветитьУдалить
  6. @Gwynbleidd

    А вы сами как относитесь к нескольким Assert'ам? Ваше мнение?

    ОтветитьУдалить
  7. @Александр Бындю

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

    Но есть разные случае, конечно. Иногда надо протестировать некоторый набор значений, и проще сделать в одном методе, когда интересует толькот ответ на вопрос "получилось или нет с этими данными".

    Да и некоторые новшества в NUnit'e, которым я обычно пользуюсь (value-атрибуты, табличные тесты) по сути своей несколько Assert'ов и есть. Правда, у них есть преимущество - сразу показаны все случаи, когда тестовый метод провалился. И это серьезный аргумент против нескольких Assert в одном методе - NUnit (и, насколько мне известно, остальные популярные тестовые фреймворки) показывают только тот Assert, который вызвал провал теста, это менее наглядно.

    В общем, стараюсь избегать нескольких Assert в одном методе.

    ОтветитьУдалить
  8. @Gwynbleidd
    Согласен, ещё добавлю. Если в тестовом методе несколько Assert'ов, то довольно трудно придумать ему название. Скорее всего оно будет очень абстрактрое, типа: "Save" :)

    ОтветитьУдалить
  9. Александр, если есть проект, который разрабатывался на протяжении нескольких лет, но без применения модульного тестирования. Стоит ли для него писать модульные тесты? хотя бы для нового функционала, который в него внедрятеся? Или все же TDD стоит применять с "чистого листа" - на новом проекте.

    ОтветитьУдалить
  10. @wanderer

    Отличный вопрос! Многие его задают.

    Я считаю непростительной тратой времени покрывать тестами уже существующий проект, который разрабатывался в течение многих лет. Вместо этого, как вы уже заметили, стоит использовать TDD только для 1) нового кода, 2) при рефакторинге существующего.

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

    ОтветитьУдалить
  11. Полностью согласен с вами. Жаль, что среди руководителей проектов есть люди, которые не хотят принимать TDD, когда он действительно необходим, и продолжают придерживаются более консервативного взгляда на разработку ПО. Хотя, возможно это опят-таки субъективное мнение - использовать или нет TDD.

    спасибо за интересные статьи!

    ОтветитьУдалить
  12. @Ejik
    Конечно в боевых :)
    И не я один, вся команда.

    ОтветитьУдалить
  13. @Ejik
    А ты использовал TDD? В боевых проектах?

    ОтветитьУдалить
  14. Александр, если не сектет как у вас поставлен процесс разработки? Сколько человек в команде? и как распределены роли? Кто-то пишет юнит-тесты, т.е. спецификацию к классам, а кто-то их реализует? Хотелось бы подробнее узнать про сам процесс, - идея TDD мне понятна, но как применить ее на практике - тоже вопрос.

    ОтветитьУдалить
  15. @Wanderer
    Хм, ну раз будет интересно, то напишу на эту тему. Это конечно не секрет. Где-то через недельку будет отдельный пост про это. Подождете? =)

    ОтветитьУдалить
  16. Конечно, отдельная статья это даже лучше, чем просто ответ на вопрос

    ОтветитьУдалить
  17. У меня 2 беды: 1С и Delphi ))
    В 1-й вообще нереально что-либо подобное.
    Во 2-й довольно скудный набор инструментов.

    В статье используется ручная инжекция зависимостей. Интересно было бы рассмотреть данный пример с использованием какого-нибудь DI конетейнера, например Unity. Он как раз лидер голосования.

    ОтветитьУдалить
  18. @Ejik
    Уже есть, только с Ninject: http://blog.byndyu.ru/2009/12/blog-post.html

    ОтветитьУдалить
  19. Кстати, относительно вопросов по TDD, я давно пытаюсь найти ответ на такой вопрос, но никак не получается:

    http://codehelper.ru/questions/215/new/как-тестировать-иерархию-классов

    ОтветитьУдалить
  20. @admax

    Вопрос: Имеется иерархия классов — базовый абстрактный класс и несколько его потомков. Как правильно организовать модульное тестирование такого набора классов? Нужно ли создавать параллельную иерархию тестов?

    Отдельный тестовый класс на каждого наследника сделать скорее всего надо будет. А вот наследовать эти тестовые классы друг от друга не вижу смысла. По крайней мере у нас такой необходимости еще никогда не возникало.

    Если есть конкретный пример, давай его разберем.

    ОтветитьУдалить
  21. Ну скажем пример такой. Есть интерфейс команды. Команда в нашем контексте - это объект, инкапсулирующий действие - стандартная реализация паттерна с одним методом типа Execute. Далее у нас появляется иерархия команд. Скажем базовая команда вызывает абстрактный метод изменения обекта и сохраняет измененный объект в репозиторий. Конкретные реализации определяют логику изменения объекта. Получается, что и базовый родительский и дочерний классы содержат логику, которую нужно протестировать.

    ОтветитьУдалить
  22. @admax

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

    Единственное, возможно (возможно!) при годится один базовый класс для всех тестовых классов, чтобы избавиться от дублирования.

    ОтветитьУдалить
  23. @Ejik

    Можешь мне письмо написать? Дело есть :)

    ОтветитьУдалить
  24. Хорошая статья. Иногда, конечно, нужно все так разжевать...

    ОтветитьУдалить
  25. Я думаю отлично было бы это оформить в виде веб-каста (что-то наподобие http://www.viddler.com/explore/dcazzulino/videos/ только на русском). Сам давно хотел подобное сделать, но лень-матушка вперёд меня родилась :) В вебкасте хорошо было бы видно как из ничего, используя TDD, и команды студии/решарпера, такие как "создать класс/интерфейс", "создать метод", можно быстро создать реально функционирующий и протестированный модуль.

    ОтветитьУдалить
  26. @Konstantin Savelev
    Отличная идея, возможно я этим займусь. Как думаете, может прямо по материалам этой статьи сделать?

    И еще, куда этот веб-каст выложить?

    ОтветитьУдалить
  27. Александр, можешь попробовать на techdays.ru выложить. Там ни одного веб-каста нет про TDD, но есть по различным фреймвормакам для модульного тестирования.

    ОтветитьУдалить
  28. >> Как думаете, может прямо по материалам этой статьи сделать?

    Да

    >> И еще, куда этот веб-каст выложить?

    Выложить лучше повсюду (YouTube, Vimeo, ...) :)
    И на хабре в отдельном посте дать ссылки на вебкаст.

    ОтветитьУдалить
  29. @Konstantin Savelev
    Дело стоящее, видимо возьмусь :)

    ОтветитьУдалить
  30. Спасибо за статью - отличный пример, чтобы разжевать студентам про TDD. Посмотрим, что из этого получиться.

    ОтветитьУдалить
  31. Добрый вечер!
    У тебя в статье по поводу тестов взаимодействия с БД и т.д. написано:
    "Действительно, тестировать это нужно. Но это делается не модульными тестами. Потому что модульные тесты должны проходить быстро и не зависеть от внешних источников. Иначе вы будете запускать их раз в неделю."

    Собственно вопрос: как поступить, если я портирую библиотеку, которая в основном решает 3 задачи:
    0. Формирование запроса(плейсхолдеры, экранирование).
    1. Выполнение запроса.
    2. Разбор результата(получить на выходе массив записей, конкретную запись, один столбец, один элемент столбца, итератор и т.д.).

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

    С другой хочется что бы пользователи библиотеки(если такие вообще будут) могли по желанию запускать тесты не утруждая себя созданием тестовой БД.

    Как лучше поступить?

    P.S. Если это критично, разработка ведется на PHP

    ОтветитьУдалить
  32. @dAllonE
    Я думаю, что первые 3 пункта ты без труда можешь протестировать с помощью модульных тестов.

    Само выполнение запроса - это видимо самая низкоуровневая часть твоей библиотеки и тестировать ее с заглушками явно бессмысленно. Здесь ты вполне можешь написать интеграционные тесты. Перед началом тестов ты можешь создавать тестовую базу, например, на MySql. Если скорость не критична, то пересоздавать ее перед каждым тестом, если критична, то выполнять каждый тест в транзакции, а после теста ее откатывать. Вариантов может быть много и зависит от твоих потребностей. Если хочешь подробно разобрать, то пиши на мыло.

    Мой коллега недавно ради развлечения написал ORM на PHP, может он чем-нибудь тебе поможет =)

    ОтветитьУдалить
  33. > Если хочешь подробно разобрать, то пиши на мыло.
    Ты все подробно разобрал. Просто с тестами столкнулся совсем недавно и пока еще плаваю в материале.

    P.S. Блог у тебя замечательный, большое тебе спасибо за него вообще и за ответ на мой вопрос в частности.
    :)

    ОтветитьУдалить
  34. Спасибо большое, Замечательная статья!

    ОтветитьУдалить
  35. Объясните пожалуйста, как использовать Moq. Ну типа шпаргалки для быстрого старта на русском.

    ОтветитьУдалить
  36. @Алексей

    На сайте проекта есть подробное описание http://code.google.com/p/moq/

    ОтветитьУдалить
  37. Эх, там не на русском всё... Но всё равно спасибо, хороший блог.

    ОтветитьУдалить
  38. Ну тогда еще если можно один вопрос. Как тестировать модули работающие с файловой системой? Я это делаю вот так:

    public void TestMethod(){
    var fileName=Path.GetRandomFileName();
    try{
    //тестовая логика компонента работающего с файлом
    }finally{
    //на тот случай если файл был оставлен открытым
    //перехватываем исключение
    try{
    File.Delete(fileName);
    }catch{}
    }
    }

    Дело в том, что у меня не бизенс приложения и не всегда можно поставить moq-объект для работы с файлами. Скажите как вы поступаете в таких случаях?

    ОтветитьУдалить
  39. @Алексей

    Модульные тесты не покрывают код, который работает с внешними ресурсами: БД, файловая система, ActiveDirectory и т.п.

    "Вопрос: Как же мне протестировать взаимодействие с базой данных, работу с SMTP-сервером или файловой системой?"

    ОтветитьУдалить
  40. Как понимаю, достаточный уровень автоматизированного тестирования заменяет TDD.

    ОтветитьУдалить
  41. Дмитрий, здесь трудно говорить о замене. TDD - совсем другой подход к разработке приложений. Особенно с помощью него интересно строить дизайн системы или изучать предметную область.

    ОтветитьУдалить
  42. Александр, TDD это инструмент. В каких то условиях от него будет прок, а в каких то нет.

    На хабре много статей про TDD, и считается, что TDD не заменяет проектирование.

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

    ОтветитьУдалить
  43. Речь изначально шла о том, что "достаточный уровень автоматизированного тестирования заменяет TDD"


    Есть ряд моментов, когда я предпочитаю TDD, а не проектирование наперед и составление тестов после кода.

    ОтветитьУдалить

Моя книга «Антихрупкость в IT»

Как достигать результатов в IT-проектах в условиях неопределённости. Подробнее...