На Хабре множество раз обсуждалась тема TDD. Сталкивался с плюсами и минусами но всё же использую как обязательную часть. Я пришёл к метрике - если тестов больше чем рабочего кода - что-то идёт не так! И хочу поделиться одним приёмом, который в моей практике уже начинает напоминать паттерн.
Я не буду приводить и цитировать всё статьи и холивары, их множество, достаточно загуглить "TDD habr". Надо сказать что с большинством выводов и мнений я согласен:
TDD точно не имеет смысл внедрять покрывая легаси (ибо это уже точно не TDD, поздно...)
написав кучу тестов можно не только замедлить разработку, но и "зацементировать" код, в случае если тестов больше чем тестируемого кода цена измененний рабочего кода возрастает неимоверно
написание unit test'ов после реализации (это уже не TDD, идёт обычно по лозунгом "сейчас сдадим, а позже напишем, чтоб покрытие не просело") приводит к двойной работе: сначала дорогостоящая отладка, а потом, зачем-то, "покрытие".
TDD не заменяет все виды тестирования*. Собрав из "проверенных" модулей логику системы на интеграционном тестировании вылезут проблемы проективания системы, поторые не видны на уровне модулей.
С другой стороны подход TDD лично мне помог в рамках Scrum перейти от практики тестирования и фикса багов перед спринт ревью (с последующим покрытием unit тестами в начале следующего спринта - по сути дела работа в долг) к выходу на уровень практически выкладки без тестирования (только основные смоки) и реализации последнего функционала за несколько часов до ревью. При этом качество продукта на ревью значительно повысилось и вместо отвлечения на баги участники ревью стали концентрироваться на удобстве и полезности функционала. Получается TDD помог не только код сделать более качественным, но и сам процесс разработки продукта вывел на новый уровень. Конечно не TDD единым. После разрабртки фичи (по TDD с модульными тестами), дописывались сценарии интеграционных тестов и в сумме это гарантировало высокую степень готовности и практически отсутствие ручного тестирования.
По ходу применения TDD при написании и тестировании бизнес логики (реализованной c помощью библиотеки MediatR) стал вырисовываться шаблон тестирования Handler'а. Одной из целей данного подхода была минимизация тестового кода. Итак тестовый код состоит из:
Инициализации для "положительного" прогона
[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" достаточно правильно инициализировать команду.
Прогон положительного сценария
[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);
}
Проверка отработки разнообразных негативных сценариев
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);
}
}
}
После реализации тестов начинается заполнение этого заглушки уже внятным кодом, постепенно закрывая красные тесты.
В заключение скажу, что такой минималистический набор тестов помогает мне, не цементируя код, проверять позитивные и негативные сценарии. При этом рефакторинг достаточно доступен:
изменения команды повлияет только на инициализацию и возможно на какие-то из негативных сценариев
изменение зависимостей уходит в базовые классы
В проекте использовались библиотеки тестирования:
Готов обсудить в комментариях алюсы и минусы подхода. Надеюсь статья не оставит вас равнодушным, а возможно даже приведёт к каким-то инсайтам. С вами был Евгений Смольский. Связаться со мной напрямую можно в ТГ.