На Хабре множество раз обсуждалась тема TDD. Сталкивался с плюсами и минусами но всё же использую как обязательную часть. Я пришёл к метрике - если тестов больше чем рабочего кода - что-то идёт не так! И хочу поделиться одним приёмом, который в моей практике уже начинает напоминать паттерн.

Я не буду приводить и цитировать всё статьи и холивары, их множество, достаточно загуглить "TDD habr". Надо сказать что с большинством выводов и мнений я согласен:

  • TDD точно не имеет смысл внедрять покрывая легаси (ибо это уже точно не TDD, поздно...)

  • написав кучу тестов можно не только замедлить разработку, но и "зацементировать" код, в случае если тестов больше чем тестируемого кода цена измененний рабочего кода возрастает неимоверно

  • написание unit test'ов после реализации (это уже не TDD, идёт обычно по лозунгом "сейчас сдадим, а позже напишем, чтоб покрытие не просело") приводит к двойной работе: сначала дорогостоящая отладка, а потом, зачем-то, "покрытие".

  • TDD не заменяет все виды тестирования*. Собрав из "проверенных" модулей логику системы на интеграционном тестировании вылезут проблемы проективания системы, поторые не видны на уровне модулей.

С другой стороны подход TDD лично мне помог в рамках Scrum перейти от практики тестирования и фикса багов перед спринт ревью (с последующим покрытием unit тестами в начале следующего спринта - по сути дела работа в долг) к выходу на уровень практически выкладки без тестирования (только основные смоки) и реализации последнего функционала за несколько часов до ревью. При этом качество продукта на ревью значительно повысилось и вместо отвлечения на баги участники ревью стали концентрироваться на удобстве и полезности функционала. Получается TDD помог не только код сделать более качественным, но и сам процесс разработки продукта вывел на новый уровень. Конечно не TDD единым. После разрабртки фичи (по TDD с модульными тестами), дописывались сценарии интеграционных тестов и в сумме это гарантировало высокую степень готовности и практически отсутствие ручного тестирования.

По ходу применения TDD при написании и тестировании бизнес логики (реализованной c помощью библиотеки MediatR) стал вырисовываться шаблон тестирования Handler'а. Одной из целей данного подхода была минимизация тестового кода. Итак тестовый код состоит из:

  1. Инициализации для "положительного" прогона

    [TestFixture]
    public class GetSurveyHandlerTests : SurveysTestsBase
    {
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.        
        GetSurveyCmd cmd;

        private GetSurveyHandler sut;
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.

        [SetUp]
        public void Setup()
        {
            SetupBase();

            cmd = fixture.Build<GetSurveyCmd>()
                .With(t => t.OrderedById, mate1_1.Id)
                .With(t => t.TeamId, team1.Id)
                .With(t => t.SurveyId, survey.Id)
                .Create();

            sut = new(scheduler, teamRepository, teammateRepository, userRepository, bot, surveyRepository, new NullLoggerFactory());
        }

Код инициализации большей части зависимостей вынесен в общий класс. В рамках тестирования класса "GetSurveyHandler" достаточно правильно инициализировать команду.

  1. Прогон положительного сценария

        [Test]
        public async Task Handle_ShouldReturnSurvey()
        {
            var res = await sut.Handle(cmd, CancellationToken.None);

            res.IsSuccess.Should().BeTrue();
            res.Value.Id.Should().Be(survey.Id);
        }
  1. Проверка отработки разнообразных негативных сценариев

        public enum WrongCase
        {
            None,
            TeamNotFound,
            MateNotFound,
            SurveyNotInActive,
            SurveyNotFound,
        }

        [Test]
        [InlineAutoData(WrongCase.None, Errors.None)]
        [InlineAutoData(WrongCase.TeamNotFound, Errors.TeamNotFound)]
        [InlineAutoData(WrongCase.MateNotFound, Errors.MateNotFound)]
        [InlineAutoData(WrongCase.SurveyNotInActive, Errors.SurveyNotFound)]
        [InlineAutoData(WrongCase.SurveyNotFound, Errors.SurveyNotFound)]
        public async Task Handle_WhenSomethingWrong_ShouldWorkAsAwaited(WrongCase wrongCase, Errors awaitedCode)
        {
            cmd = cmd with
            {
                OrderedById = wrongCase == WrongCase.MateNotFound ? Guid.NewGuid() : cmd.OrderedById,
            };

            if (wrongCase == WrongCase.TeamNotFound)
            {
                teamsById.Remove(team1.Id);
            }
            if (wrongCase == WrongCase.MateNotFound)
            {
                matesById.Remove(mate1_1.Id);
            }
            if (wrongCase == WrongCase.SurveyNotFound)
            {
                surveyRepository.GetByIdAsync(survey.Id, Arg.Any<CancellationToken>()).Returns<Survey>(x => throw new KeyNotFoundException());
            }
            if (wrongCase == WrongCase.SurveyNotInActive)
            {
                teamsById[team1.Id] = team1 = team1 with
                {
                    ActiveSurveys = Array.Empty<Guid>(),
                };
            }

            var res = await sut.Handle(cmd, CancellationToken.None);

            res.ErrorCode.Should().Be(awaitedCode);
            res.IsSuccess.Should().Be(awaitedCode == Errors.None);
        }

Собственно этот отработчик негативных сценариев в купе с изначальной позитивной инициализацией и состовляет суть выработанного мной подхода. В соответствии с DRY мы не пишем множество инициализаций - только одна положительная и по одной модификации на каждый отрицательный кейс. Мы не пишем множество проверок - по сути одна положительная проверка и одна проверка на ошибку. Кейс с отсутствием ошибки "WrongCase.None" проверяет адекватность теста, особенно хорошо помогает когда нет чёткой связи между WrongCase и Errors, когда тест просто проверяет что результат !IsSuccess.

Такой подход удобен для тестирования бизнес логики реализованой с помощью MediatR, но не покрывает всех вохможностей/необходимостей тестирования. Мне подходит как базавый уровень. Иногда приходится добавлять дополнительные тесты. Но это уже говорит о, возможно, черезмерном усложнении логики.

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

И традиционный вопрос к TDD о курице или яйце.

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

public class GetSurveyHandler : SurveyBaseHandler<GetSurveyHandler, GetSurveyCmd, FullSurveyDto>
    {
        public GetSurveyHandler(IScheduler scheduler, ITeamRepository teamRepository,
            ITeammateRepository teammateRepository, IUserRepository userRepository,
            IBot bot, ISurveyRepository surveyRepository, ILoggerFactory loggerFactory)
            : base(scheduler, teamRepository, teammateRepository, userRepository, bot, surveyRepository, loggerFactory)
        {
        }

        public override async Task<Result<FullSurveyDto>> Handle(GetSurveyCmd cmd, CancellationToken cancellationToken)
        {
            try
            {
                throw new NotImplementedException();
            }
            catch (Exception ex)
            {
                return Failure(Errors.SomethinGoingWrong, ex.Message, ex);
            }
        }
    }

После реализации тестов начинается заполнение этого заглушки уже внятным кодом, постепенно закрывая красные тесты.

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

  • изменения команды повлияет только на инициализацию и возможно на какие-то из негативных сценариев

  • изменение зависимостей уходит в базовые классы

В проекте использовались библиотеки тестирования:

Готов обсудить в комментариях алюсы и минусы подхода. Надеюсь статья не оставит вас равнодушным, а возможно даже приведёт к каким-то инсайтам. С вами был Евгений Смольский. Связаться со мной напрямую можно в ТГ.

Комментарии (13)


  1. nronnie
    29.06.2023 07:05
    +2

    Код инициализации большей части зависимостей вынесен в общий класс.

    Вот, по опыту, не стоит подобные вещи делать. Код тестов должен быть максимально "тупой", без всяких паттернов, повторного использования, самодельных библиотек и фреймворков и т.п. Чтобы в случае потери актуальности какого-то отдельного теста или набора тестов проще было его вообще полностью удалить и написать заново, чем рефакторить сами тесты.


    1. smallsky Автор
      29.06.2023 07:05
      +2

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

      Согласен. С другой стороны палка о двух концах. Если один и тот же код используется в разных тестах - легче вынести, поскольку в случае рефакторинга структурированный код отрефакторить значительно проще, чем в каждом тесте.

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


    1. AlfShumway
      29.06.2023 07:05

      Код тестов должен быть максимально "тупой", без всяких паттернов, повторного использования

      Где-то горько заплакал Джерард Месарош, автор фолианта под названием "Шаблоны тестирования xUnit: рефакторинг кода тестов" на 832 страницы (с предисловием от Мартина Фаулера).


      1. nronnie
        29.06.2023 07:05

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


        1. AlfShumway
          29.06.2023 07:05

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


  1. mv28jam
    29.06.2023 07:05

    Есть смысл расширять функцинальность и использовать новые сущности, которые конечно могут быть "подставлены", как нам велит измученный SOLID, тогда и тесты переписывать не нужно. В заголовке же Unit тесты.

    В целом проблема известная.


    1. nronnie
      29.06.2023 07:05

      Тесты приходится переписывать хотя бы при рефакторинге, когда, например, выносишь код в отдельный класс с зависимостью исходного класса от него.


  1. AlfShumway
    29.06.2023 07:05

    TDD не заменяет все виды тестирования*.

    TDD не заменяет ни один вид тестирования, поскольку это разработка по определению термина. Равно как никакое тестирование не может заменить разработку, это разные вещи.

    TDD точно не имеет смысл внедрять покрывая легаси

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

    написав кучу тестов можно не только замедлить разработку, но и "зацементировать" код

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


  1. nronnie
    29.06.2023 07:05

    Хорошо спроектированные тесты (не знаю, попадает ли "куча" под это определение) должны проверять код на соответствие требованиям.

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


    1. AlfShumway
      29.06.2023 07:05

      Для юнит-тестов это не так.

      Это для всех видов тестов так. Каждый уровень тестов проверяет свой набор требований: приемосдаточные тесты валидируют бизнес-требования, системные верифицируют требования к системе, и так далее вниз по V-модели. Модульные тесты должны проверять требования, налагаемые контрактом интерфейса модуля. Иначе набор модульных тестов превратится в какое-то бессистемное нагромождение типа "а давайте еще такой случай проверим", а это уже не инженерный подход. Да и какая может быть архитектура у системы, где не определены контракты модулей?


      1. nronnie
        29.06.2023 07:05

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


        1. AlfShumway
          29.06.2023 07:05

          Как частный случай может быть и класс, конечно (хотя есть парадигмы и помимо ООП, да и в объектно-ориентированных языках понятия модуля и класса могут не совпадать, взять хотя бы C++20 или тот же Ruby). Дело в другом: если требования к модулю еще не детализированы настолько, что я могу написать для него набор юнит-тестов, из этого неизбежно следует, что и для написания его кода у меня недостаточно информации (если я не знаю, как проверить что-то, то откуда я знаю, как это корректно написать?). Для меня выглядит странно, не писал код в такой ситуации.


          1. nronnie
            29.06.2023 07:05

            Я немного поясню, потому что, возможно, я немного смутно выразился. "Полноценный" юнит-тест тестирует только код одного отдельного класса (в идеале - отдельного метода класса), подменяя всё что для этого класса/метода внешнее специальным образом сконфигурированными мок-объектами. А это означает, что сам этот юнит-тест будет уже зависеть не только от требований к этому классу/методу, но и от его реализации (т.е., как я уже говорил, "белый ящик"). Вот, например, у нас есть класс, который должен проверять регистрационные данные пользователя и записывать их в БД. В изначальной реализации мы работаем напрямую с ORM и для юнит-тестов используем in-memory database. А позже мы решили над ORM встроить еще один слой абстракции типа какого-нибудь IRepository - и тогда юнит-тесты уже надо будет модифицировать, хотя требования-то остались те же.