Всем привет, меня зовут Андрей Федотов, я бэкенд-разработчик в компании «Цифровая Индустриальная Платформа», где мы создаем одноименный продукт – платформу промышленного интернета вещей ZIIoT Oil&Gas. Наша команда разрабатывает набор сервисов, предназначенных для получения данных от различных источников. И моя статья – это своего рода история нашего проекта через призму юнит и интеграционного тестирования.
Как сказал Кент Бек: «Многие силы мешают нам получить чистый код, а иногда не удается даже получить код, который просто работает». В его книге по TDD (сразу оговорюсь, что TDD мы не используем, но книга очень хорошая) используется подход, когда сначала пишется код «который работает», после чего создается «чистый код». И этот подход противоречит модели разработки на основе архитектуры, в которой сначала мы пишем «чистый код», а потом мучаемся, пытаясь интегрировать в проект код, «который работает». Наша действительность была еще хуже: у нас был код, который работает плохо, и путем больших усилий он стал кодом, который работает. Возможно, однажды мы и к чистому придем.
Чтобы понимать, о чем я говорю, предлагаю посмотреть на юнит-тесты, которые были на проекте еще полтора года назад, примерно тогда же, когда во владение нашей команды перешел набор вышеупомянутых сервисов.
На скриншоте ниже показана папка с названиями файлов с тестами. В данной папке их было около двухсот.
Да, они так и назывались: UnitTest001.cs
, UnitTest020.cs
, UnitTest120.cs
…
На следующей картинке показан пример одного из тестов, находящегося в файле UnitTest015.cs
.
Название теста TestMethod02
– типичное название теста в таких файлах.
В этом тесте дважды делаются запросы к внешним источникам: сначала на запись, потом на чтение. Что уже намекает на то, что это не похоже на юнит-тест. А результаты запросов проверяются беспорядочно.
Вот еще пример теста. TestMethod16
. Содержит одну строчку и вызывает грозный метод Run_142_04
.
Что б это могло значить? Провалимся и увидим, что это за метод.
Это метод расширения, и его код не вмещается примерно в никуда, поэтому тут только его части. И таких методов сотни. Предлагаю даже не пытаться вдаваться в задумку авторов.
Можно подумать, что это какие-то автоматически сгенерированные тесты. Но нет, они были написаны вручную.
Как промежуточный итог, перечислю проблемы, которые были на проекте на тот момент:
много заявок от ТП с багами, требующими вовлечения команды разработки;
сложно поддерживаемый код (не только в тестах, но и вообще);
цель существующих тестов не ясна (нет защиты от багов);
цена исправлений высокая.
Как сказал Владимир Хориков в своей книге «Принципы Unit-тестирования»: «Лучше вообще не писать тест, чем написать плохой тест». Поэтому мы решили написать свои тесты, а от существующих избавиться.
Кстати, если вы не читали эту книгу, то очень советую почитать ее. Это кладезь опыта и рекомендаций. Автор настоящий профессионал и книга отличная, хотя, конечно, там могут быть моменты, которые вашей команде не подойдут (так, например, у нас не прижились рекомендации по именованию тестов, которые были описаны в книге, но это мелочи).
Прежде чем идти дальше, затронем немного теории, а потом посмотрим, какие тесты у нас получились и какие плоды это дало. Тут я опираюсь на определения из книги Владимира Хорикова.
Цель юнит и интеграционного тестирования
Юнит и интеграционное тестирование не сводится к простому написанию тестов. Их цель – обеспечение стабильного роста программного проекта. И ключевым словом здесь является слово «стабильный». В начале жизни проекта развивать его довольно просто. Намного сложнее поддерживать это развитие с течением времени. На графике ниже представлена зависимость времени от прогресса для проектов с тестами и без.
Такое снижение скорости разработки называется программная энтропия.
В нашем проекте мы не ставили написание тестов как самоцель. В первую очередь, мы хотели решить существующие проблемы, о которых я упомянул выше, а также иметь возможность наращивать кодовую базу, не снижая надежности продукта в целом.
Тесты и стресс
Более того, есть связь между тестами и уровнем стресса. Об этом написано в книге по TDD Кента Бека (книга хоть и про TDD, но на деле это увлекательное чтиво с набором интересных историй из практики и жизни автора, поэтому её я также очень рекомендую почитать за чашечкой чая).
Чем больший стресс мы ощущаем, тем меньше мы тестируем разрабатываемый код. Чем меньше мы тестируем разрабатываемый код, тем больше ошибок мы допускаем. Чем больше ошибок мы допускаем, тем выше уровень стресса, который мы ощущаем. Получается замкнутый круг с положительной обратной связью: рост стресса приводит к росту стресса.
Тесты, в свою очередь, превращают стресс в скуку. «Нет, я ничего не сломал. Тесты по-прежнему проходят». Поэтому, наличие тестов также снижает стресс.
Что такое юнит-тест
Он же модульный тест. Общее определение звучит следующим образом: это автоматизированный тест, который:
проверяет правильность работы небольшого фрагмента кода (также называемого юнитом);
делает это быстро;
поддерживает изоляцию от другого кода.
Может возникнуть вопрос, что такое юнит и что такое изоляция?
Надо сказать, что существует две школы юнит-тестирования: классическая и лондонская.
Классической она называется потому, что изначально именно так подходили к юнит-тестированию. Лондонскую школу сформировало сообщество программистов из Лондона (внезапно). Корень различий между классической и лондонской школами – как раз вопрос изоляции. Лондонская школа описывает изоляцию на уровне классов, и юнитом обычно является сам класс. Классическая школа под юнитом подразумевает единицу поведения. В наших сервисах мы придерживаемся классической школы. Обычно она приводит к тестам более высокого качества и лучше подходит для достижения цели – стабильного роста проекта.
Виды зависимостей
Также стоит упомянуть про виды зависимостей, которые есть:
совместные (shared) – доступ имеют более одного теста. Позволяет им влиять на результаты друг друга (например, БД);
приватные (private) – не являющиеся совместными;
внепроцессорные (out-of-process) – работают вне процесса приложения.
Интеграционный тест
Интеграционным называется тест, который не удовлетворяет хотя бы одному критерию из определения юнит-тестов. Он может проверять (и часто проверяет) сразу несколько единиц поведения. Также интеграционный тест проверяет, что код работает в интеграции с совместными зависимостями, внепроцессорными зависимостями или кодом, разработанным другими командами в организации.
Сквозные тесты
Они же API-тесты. Они же end-to-end тесты. Составляют подмножество интеграционных тестов. Тоже проверяют, как код работает с внепроцессорными зависимостями. Отличаются от интеграционных прежде всего тем, что сквозные тесты обычно включают большее число таких зависимостей и обычно проверяют полный путь пользователя.
Что должны делать тесты
В идеале тесты должны проверять не единицы кода, а единицы поведения – нечто, имеющее смысл для предметной области и полезность которого будет понятна бизнесу.
Пирамида тестирования
Концепция классической пирамиды тестирования предписывает определенное соотношение разных типов тестов в проекте. Разные типы тестов в пирамиде выбирают разные компромиссы между быстротой обратной связи и защитой от багов. Тесты более высоких уровней пирамиды отдают предпочтение защите от багов, тогда как тесты нижних уровней выводят на первый план скорость выполнения. И наоборот: чем ниже уровень – тем меньше защита от багов, и чем выше – тем меньше скорость.
А теперь от теории вернемся к тестам в наших сервисах.
В наших сервисах пирамида тестирования на данный момент выглядит вот так:
Наш проект является своего рода прокси и содержит немного бизнес-логики, но много взаимодействий с другими сервисами. Поэтому у нас больше интеграционных тестов. Но не только это является причиной.
WebApplicationFactory
Да, выполнение юнит-тестов всегда быстрее, чем интеграционных. Но не всё так страшно.
Использование WebApplicationFactory
позволяет создавать множество параллельно работающих экземпляров приложения в памяти, изолированных друг от друга, и сделать это «дешево». Благодаря WebApplicationFactory
всё происходит быстро (пока ещё не по цене юнитов, но тем не менее). Подробнее про WebApplicationFactory
можно почитать тут.
И для нас интеграционные тесты это такой своего рода догфудинг. Пока делали, сами поняли слабые и неудобные места своего API и приняли меры.
Для чего мы используем тесты
Мы используем интеграционные и сквозные тесты для проверки поведения пользователя. Эти тесты у нас:
запускаются локально на машинах разработчиков,
взаимодействуют с реальными сервисами на стендах,
есть возможность отладки,
есть возможность запуска сервисов локально.
Юнит-тесты мы используем для проверки логики валидации запросов и проверки любой другой внутренней логики.
Примеры:
Вот так выглядит типичный юнит-тест теперь. Здесь проверяется режим запроса данных.
Вот так выглядит простейший интеграционный тест.
Мы используем нейминг, рекомендуемый Microsoft. Также тут используется паттерн AAA.
Характеристики хороших тестов
Хочу отметить, что наши тесты сейчас это не просто улучшенный код и использование каких-то практик и паттернов, это с нуля написанные тесты, которые соответствуют критериям хороших тестов:
защита от багов,
устойчивость к рефакторингу,
быстрая обратная связь,
простота поддержки.
Первые три являются взаимоисключающими, максимально тест может использовать только два из них. Мы выбрали защиту от багов и устойчивость к рефакторингу как главные. Что касается быстрой обратной связи, то, как упомянул ранее, это не сильно критично и WebApplicationFactory
– это та самая таблетка. Мы также выпустили типизированный клиент для наших пользователей и в тестах сами им пользуемся. Еще один важный критерий – наши тесты являются частью DoD (Definition of Done).
Также есть свойства успешного набора тестов:
интеграция в цикл разработки. Пока что у нас он сейчас условный и на ответственности разработчиков, наш CI сейчас не готов к запуску интеграционных тестов, но это планы на самое ближайшее будущее,
проверка самых важных частей кода,
максимальная защита от багов при минимальных затратах на сопровождение.
Немного статистики
Два года назад у нас было 125 заявок из техподдержки и большинство из них требовали исправлений. Год назад ситуация стала получше: 75 заявок, но всё еще многие из них требовали вовлеченности разработчиков.
На данный момент в текущем году заявок сильно меньше: всего 24. И что самое важное –большинство из них являются заявками на консультации или те, которые до разработчиков не доходили.
По периоду стабилизации во время релизов также наблюдается снижение количества багов.
Конечно, тут много факторов, это заслуга не только наших тестов, ведь мы также переписали большую часть кода сервисов, но тем не менее большое количество недочетов теперь отлавливается на этапе разработки.
Всё вышесказанное указывает на увеличение надежности нашего ПО, о которой и было заявлено в названии статьи.
Краткие выводы и рекомендации
Тесты должны проверять единицы поведения, а не единицы кода,
не пренебрегать практиками написания тестов: нейминг, паттерн AAA и т.д.,
самая показательная метрика – это количество багов,
в современном мире интеграционные тесты не сильно «дороже» юнит-тестов.
Оставляйте свои вопросы, замечания и советы в комментариях – буду рад. Еще я писал про использование HttpClient в нашей работе. Почитать об этом можно тут.
Комментарии (11)
kanadeiar
02.10.2024 09:21+2Спасибо за статью! Вопрос у меня к Вам. Что вы делаете с толстыми и повторяющимися блоками Assert и Arrange внутри тестовых методов?
Показанный в данной статье пример - идеальный. Мне интересно было бы узнать как Вы обычные, "бытовые" тесты пишете.
KoscheyScrag Автор
02.10.2024 09:21+1Если Вы имеете в виду что в нескольких методах будут одинаковые большие блоки Assert/Arrange, то я считаю, что отталкиваться нужно от поведения, которое Вы тестируете.
Для интеграционного теста вполне обыденно тестировать несколько единиц поведения за раз.
Если же в одном тесте это будет не уместно, то не вижу ничего страшного, если в разных методах блоки будут повторяться (по необходимости можно такую логику вынести отдельно - в хелперы или в некий test init).
Если же речь идет про повторяющиеся блоки в рамках одного теста, то такого быть не должно.
Вот пример одного из наших "бытовых" тестов (всё переименовано на всякий случай):
[Fact] public async Task Subscription_SomeScenario_ReceivesDataFromOneAndThenOnlyFromAnother() { // Arrange const int valuesCount = 10; const int oneValuesCount = valuesCount / 2; const int anotherValuesCount = valuesCount - valuesCount; var (id, name) = await _apiTestHelper.CreateSomethingAsync(); var firstValueTs = DateTimeOffset.UtcNow.AddHours(-1); var firstValue = Random.Shared.NextDouble(); var expectedValues = new List<(DateTimeOffset Ts, double Value)> { (firstValueTs, firstValue) }; await _apiClient.WriteValueAsync(id, firstValueTs, firstValue); var linkedId = await _apiTestHelper.CreateLinkedSomethingAsync(id); var subscriberId = Guid.NewGuid(); await _anotherApiClient.PrepareAsync(subscriberId, [linkedId]); var receivedValues = new List<Info>(); var authToken = await _securityService.GetAccessToken(); var testServerWebSocketClient = _webAppFixture.Server.CreateWebSocketClient(); testServerWebSocketClient.ConfigureRequest = request => { request.Headers.Authorization = $"{authToken!.TokenType} {authToken.AccessToken}"; }; // Act var firstValueReceivedTcs = new TaskCompletionSource(); var expectedAmountOfValuesFromOneIsReceivedTcs = new TaskCompletionSource(); var expectedAmountOfValuesFromAnotherIsReceivedTcs = new TaskCompletionSource(); using var websocketClient = await _anotherApiClient.SubscribeAsync( subscriberId: subscriberId, onReceived: info => { receivedValues.Add(info); firstValueReceivedTcs.TrySetResult(); if (receivedValues.Count == valuesCount) expectedAmountOfValuesFromOneIsReceivedTcs.TrySetResult(); if (receivedValues.Count == valuesCount) expectedAmountOfValuesFromAnotherIsReceivedTcs.TrySetResult(); }, connectionFactory: (uri, token) => testServerWebSocketClient.ConnectAsync(uri, token)); await Task.WhenAny(firstValueReceivedTcs.Task, DelayHelper.WaitForSomethingAsync()); for (var i = 1; i < valuesCount; i++) { var valueTs = DateTimeOffset.UtcNow.AddMilliseconds(new Random().NextInt64(-100, 100)); var value = Random.Shared.NextDouble(); expectedValues.Add((valueTs, value)); await _apiClient.WriteValueAsync(id, valueTs, value); } await Task.WhenAny(expectedAmountOfValuesFromOneIsReceivedTcs.Task, DelayHelper.WaitForSomethingAgainAsync()); await _apiTestHelper.UpdateSomethingAsync(id, Random.Shared.NextDouble()); var something = await _apiTestHelper.CreateSomethingAsync(); await DelayHelper.WaitForSomethingChangeEventAsync(); for (var i = 0; i < constValuesCount; i++) { var value = Random.Shared.NextDouble(); expectedValues.Add((DateTimeOffset.UtcNow, value)); await _apiTestHelper.UpdateToSomethingAsync(id, value); await _apiClient.WriteValueAsync( something, DateTimeOffset.UtcNow, Random.Shared.NextDouble()); await DelayHelper.WaitForSomethingChangeEventAsync(); } await Task.WhenAny(expectedAmountOfValuesFromAnotherIsReceivedTcs.Task, DelayHelper.WaitForSomethingAgainAsync()); // Assert receivedValues.Count.Should().Be(expectedValues.Count); expectedValues = expectedValues.OrderBy(tuple => tuple.Ts).ToList(); for (var i = 0; i < receivedValues.Count; i++) { receivedValues[i].Value.Should().Be(expectedValues[i].Value); receivedValues[i].ValueStatusId.Should() .Be(i == 0 ? ValueStatus.SomeStatus : ValueStatus.AnotherStatus); } }
kanadeiar
02.10.2024 09:21Спасибо большое за приведенный пример! Я так понимаю в Ваших тестах в качестве вспомогательного инструмента используете FluentAssertion?
Valdemariusinter
02.10.2024 09:21Правильно ли я понимаю, что случае с попаданием в замкнутый цикл "стресса и тестов" - нужно переходить от сквозных в сторону юнит тестов? И тогда, проработав код отдельными кусочками появится возможность наконец увидеть положительный результат и в сквозных показателях?
KoscheyScrag Автор
02.10.2024 09:21+2Ну, если положительного результата совсем не видно, то возможно код действительно стоит проработать отдельными кусочками. Может уделить время рефакторингу и понять какой код тестировать, а какой нет.
Вообще, весь код можно разделить на 4 категории: тривиальный, переусложненный, контроллеры и бизнес логика. Точно стоит тестировать бизнес логику и контроллеры. Тривиальный менее важен для тестирования. Переусложненный в идеале должен быть отрефакторен и перейти в категорию бизнес логики или контроллеров.
Соответственно, для бизнес логики больше подойдут юниты, а для контроллеров интеграционные/сквозные (конечно, тут зависит он контекста каждого отдельного случая еще)
summerwind
02.10.2024 09:21Хоть мне не нравится лондонская школа в её категоричном чистом виде, но вот у этого правила классической школы тестирования есть один огромный минус:
Тесты должны проверять единицы поведения, а не единицы кода
Минус этот заключается в отсутствии определенности, что же именно считать единицей поведения. У каждого разработчика зачастую может быть какое-то свое видение, что в том или ином случае считать "атомарной единицей поведения". Возникают случаи, когда прилетает PR, где в таком "юнит-тесте" принимает участие с десяток классов, с последующими времязатратными дискуссиями и доказыванием своей точки зрения.
Одновременно это же и огромный плюс лондонской школы - определенность. Все в команде четко знают, что юнит это класс, и не возникает никаких разночтений и споров по этому поводу. Хочешь проверить взаимодействие классов между собой - напиши 1-2 интеграционных теста, тестирующих именно взаимодействие, а детальное тестирование поведения уже в юнит тестах.
kanadeiar
02.10.2024 09:21А что такое эти самые "единицы поведения" и как их считать - это самое интересное!
MAXH0
Большое спасибо за статью. Не смотря на то, что я не большой поклонник С#, но примеры достаточно универсальны.
KoscheyScrag Автор
Спасибо большое!
А если не секрет, то почему C# не очень нравится?)
MAXH0
MS масдай и C# порок его.
С тех давних времен, когда майки тормозили Джаву под Винду, чтобы продвинуть Си шарп. Потом ситуация стала получше, но от шарпа по прежнему сильный запах менеджмента майков. Т.е. не заметишь, как погрузишься в экосистему и будешь использовать "патентованные решения". Причем не только майки в этой экосистеме так страдают, но и, например, Юнити.
KoscheyScrag Автор
Понятно, спасибо за ответ.
Ну, кстати, кажется, что в последние годы они очень много вкладываются в развитие экосистемы .NET и самого C#.