Писать тесты не всегда самое интересное занятие. Если вы не работаете по TDD, то такие проблемы как отсутствие тестов, их малое количество и их устаревшая версия вам знакомы. Но почему так происходит? Давайте разбираться.

На бэкенде для тестов мы в Tourmaline Core создаем проект xUnit, если пишем на C#. Наши тесты пишутся на все слои приложения: Api, Application, DataAccess и Core (он же Domain), если есть логика, описанная в модели. Чтобы как-то всё структурировать, мы воссоздаем эти слои в проекте с тестами, кидая всё в папки с соответствующими названиями:

Первая проблема, которая появляется при таком подходе — дублирование. Нам нужно полностью повторить структуру папок проекта. Это неудобно, и делать так в каждом проекте слишком долго и трудозатратно.

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

И как тогда улучшить процесс добавления новых тестов и обновления старых?

На фронтенде эти проблемы уже решены:

  • структура не дублируется (повторяется только название тестируемого компонента);

  • файлы с тестами всегда на виду — они лежат прямо рядом с компонентом, который тестируется.

Выше показан пример, где тесты для ProductsLogPage.tsx лежат в той же папке и названы также, но с другим тестовым расширением, в данном случае — cy.tsx.
Мы подумали — может такой подход можно реализовать и на бэкенде? Давайте попробуем.

Первое, что мы заметили: отдельный проект с тестами на xUnit необязателен для запуска тестов. Вполне достаточно установить библиотеки для тестирования в проект с функционалом и запускать тесты командой dotnet test. Эта команда запустит все тесты, найденные в солюшене (а значит, во всех слоях приложения). Это нам и сыграет на руку.

Мы скопировали тесты из отдельного проекта и положили их рядом с тестируемыми классами, прямо как на фронтенде:

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

Docker Ignore

Самое очевидное решение — использовать .dockerignore файл, чтобы исключить файлы с тестами из сборки:

# другие игнорируемые папки и файлы
*Tests.cs
# другие игнорируемые папки и файлы

Но и тут ждет подводный камень — библиотеки для тестирования останутся и будут занимать какое-то место.

Мы посчитали, насколько тяжелой станет сборка с дополнительными пакетами:

  • вес библиотеки xUnit - 30.91 KB;

  • вес библиотеки Moq - 815.29 KB;

  • вес библиотеки Microsoft.NET.Test.Sdk - 33.5 KB.

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

ItemGroup и Condition

Но мы решили пойти дальше и обнаружили интересную фичу, которая скрывается в файле проекта .csproj. И это параметр Condition, который нужно указать в блоке ItemGroup. Так мы создадим некое правило, по которому в проект будут или не будут включаться указанные внутри ItemGroup библиотеки. Кстати, таким образом можно исключить из билда ещё и файлы с тестами, не используя .dockerignore.

Чтобы реализовать такое решение, мы создали переменную EXCLUDE_UNIT_TESTS_FROM_BUILD, и указали её как ARG в Dockerfile.

<!-- Условие для исключения пакетов для тестирования -->
<ItemGroup Condition="'$(EXCLUDE_UNIT_TESTS_FROM_BUILD)' != 'false'">
	<PackageReference Include="xunit" Version="2.4.1" />
	<PackageReference Include="Moq" Version="4.16.1" />
	<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
</ItemGroup>
<!-- Условие для исключения тестовых файлов -->
<ItemGroup Condition="'$(EXCLUDE_UNIT_TESTS_FROM_BUILD)' == 'true'">
	<Compile Remove="**/*Tests.cs" />
</ItemGroup>

Почему мы используем именно ARG, а не ENV? Мы исключаем пакеты и/или файлы из сборки, следовательно, переменная должна работать именно как аргумент для сборки (docker build), а её значение мы можем брать из переменных среды, установленных на хост-машине. ENV применимо только тогда, когда нам нужно значение в момент выполнения приложения (docker run).

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG EXCLUDE_UNIT_TESTS_FROM_BUILD=${EXCLUDE_UNIT_TESTS_FROM_BUILD}
# other Dockerfile lines…
RUN dotnet build "./example.csproj" \
-o /app/build /p:EXCLUDE_UNIT_TESTS_FROM_BUILD=$EXCLUDE_UNIT_TESTS_FROM_BUILD

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
ARG ASPNETCORE_ENVIRONMENT=Production
RUN dotnet publish "./example.csproj" \
-o /app/publish /p:UseAppHost=false \
/p:EXCLUDE_UNIT_TESTS_FROM_BUILD=$EXCLUDE_UNIT_TESTS_FROM_BUILD
# other Dockerfile lines…

Удаляем файлы с тестами внутри Dockerfile

Исключить пакеты для тестирования из билда получится только через Condition в .csproj файле, а вот файлы с тестами не включать в продовский образ можно ещё и третьим способом — мы таким же образом будем использовать переменную EXCLUDE_UNIT_TESTS_FROM_BUILD для сборки. Но все файлы с тестами изначально скопируем в контейнер. После этого мы сможем найти файлы с тестами в папке /src и удалить их. Это можно сделать в Dockerfile, используя блок RUN, который умеет обрабатывать условный оператор:

# Удаление файлов тестов, если аргумент установлен в true
RUN if [ "$EXCLUDE_UNIT_TESTS_FROM_BUILD" = "true" ]; then \
        find /src -type f -name '*Tests.cs' -exec rm -f {} +; \
    else \
        echo "Skipping unit test removal"; \
    fi

Итоги

В итоге мы нашли три способа удалить лишние пакеты и файлы из продовской сборки (кстати, эта фича может быть полезна не только для описанного нами кейса). А еще мы изменили архитектуру папок и теперь файлы с тестами будут лежать рядом с тестируемыми классами. Такой подход поможет не забыть обновить тест после обновления функционала или напомнит о том, что на какой-то класс тестов всё ещё нет. А ещё так удобнее!

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

А как вы тестируете свой продукт? Какие методы используете для того, чтобы не терять тесты и держать их всегда в актуальном состоянии? Давайте обсудим это в комментариях :)

Авторы: Колесникова Анна, Шинкарев Александр
Вычитка и фидбек: Ядрышникова Мария, Шинкарев Александр
Оформление: Шур Маргарита

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


  1. AgentFire
    20.11.2024 06:06

    Так и как же всё-таки сделать Unit-тестирование в .NET проще и интереснее?


    1. TourmalineCore Автор
      20.11.2024 06:06

      Для нас так стало проще и интереснее, и веселее :)

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

      Здесь хотели поделиться идеей, её плюсами и техническими деталями реализации.


  1. simplepersonru
    20.11.2024 06:06

    Если слегка запарится с именем и сделать имя файлов тестов типо такого:

    Для Func.cs

    Тест - Func.cs.test.cs

    То в среде разработки он будет отображаться как вложенный в Func.cs, более явная принадлежность

    Это также работает например с razor

    File.razor

    File.razor.cs (отображается как вложенный)


    1. TourmalineCore Автор
      20.11.2024 06:06

      Классный совет! Спасибо, мы попробуем :) 12 лет назад пользовались Razor и Web.config со всеми их скрытыми вложенностями, что-то не подумали, что такая фича всё ещё должна быть и прикольно работать.

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

      Будем пробовать и смотреть, как пошло.


    1. TonyWerner
      20.11.2024 06:06

      Можно сделать проще, просто добавить в csproj условие для сопоставления файлов

      <ItemGroup>
      	<Compile Update="**\*Tests.cs">
      		<DependentUpon>$([System.String]::Copy(%(Filename)).Replace('Tests', '.cs'))</DependentUpon>
      	</Compile>
      </ItemGroup>
      


      1. TourmalineCore Автор
        20.11.2024 06:06

        Проверили, работает, спасибо, будем знать :)


  1. nronnie
    20.11.2024 06:06

    Но мы решили пойти дальше и обнаружили интересную фичу, которая скрывается в файле проекта .csproj. И это параметр Condition

    Если у вас такие глубокие познания в MSBuild, что этот "параметр" для вас стал открытием, то, может вам сначала немного подучиться, там, типа, опыта поднабраться, перед тем какие-то свои уникальные подходы к project layout изобретать?


    1. Gromilo
      20.11.2024 06:06

      Вот странная претензия. Типа пока MSBuild не знаешь, то project layout не трогай?

      А когда тогда можно трогать?


      1. nronnie
        20.11.2024 06:06

        А когда тогда можно трогать?

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


        1. TourmalineCore Автор
          20.11.2024 06:06

          А у нас достаточно опыта в .NET и насмотренности в других стеках, таких как Python, NodeJS, C/C++. Один из авторов, который вам сейчас пишет - Александр, начинал профессиональную карьеру на C# 13 лет назад, читал Рихтера, Фаулера, Мартина и т.д., делал проекты, ездил учиться на конференции DotNext, а сейчас и сам делится знаниями и видением.

          Ещё ребята в Go так делают: https://go.dev/doc/tutorial/add-a-test и вот ещё интересное обсуждение такого подхода, https://www.reddit.com/r/golang/comments/157tsdj/where_do_you_place_your_tests_in_your_project/?rdt=51749.

          Если что, мы ещё и не верим в автомаппер и медиатор в .NET проектах =ъ

          Пыс: не очень понятно откуда и зачем такой негатив


          1. nronnie
            20.11.2024 06:06

            читал Рихтера, Фаулера, Мартина и т.д., делал проекты

            Я этим так впечатлён, что аж дыхание спёрло :)))

            не очень понятно откуда и зачем такой негатив

            А, вы знаете, а, ведь, на самом деле, абсолютно похер. Я работу работаю исключительно из-за денег, платят мне, фактически, за жопочасы, и потрачу я свой рабочий день на что-то полезное или же на то чтобы разбираться в чьих-то красноглазых галлюцинациях типа самодельного супералгоритма сортировки с использованием эрмитовых матриц и групп Ли - мне от этого ни
            холодно, ни жарко, за свои Х часов я ровно те же деньги все равно получу. Поэтому, просто, удачи вам - ступайте с Богом в мир, делитесь дальше своими знаниями и видЕниями. :)


            1. Gromilo
              20.11.2024 06:06

              Настолько похер, что невозможно молчать :)


  1. nin-jin
    20.11.2024 06:06

    Тут есть ещё несколько идей из фронтенда:

    https://page.hyoo.ru/#!=2jggfw_at1ily

    https://page.hyoo.ru/#!=z90h0r_m6qkvl


  1. gdt
    20.11.2024 06:06

    Интересный подход, но как по мне выглядит некрасиво. Мы у себя начали писать больше интеграционных тестов - они на самом деле покрывают бизнес логику так, как её видит бизнес, и дают хороший coverage. Unit тесты оставили только для каких-то часто используемых примитивов, типа своих observable collections или чего-то многопоточного, где на самом деле есть неочевидные граничные условия, которые необходимо покрывать. Вот после этого стало проще и интереснее.


    1. TourmalineCore Автор
      20.11.2024 06:06

      Согласны про больше интеграционных. Мы тут под Unit подразумеваем не всегда самую маленькую и ни от чего независящую единицу. Мы сейчас тоже перешли к большему количеству интеграционных тестов + минимум E2E-тестов самого API, которые заменяют каноничные Unit-тесты..


      1. AgentFire
        20.11.2024 06:06

        Но ведь интеграционные тесты, пусть и покрывающие все кейсы, не гарантируют детерминированности, в отличии от юнитовых.


        1. gdt
          20.11.2024 06:06

          Тут есть нюансы. Во-первых, детерминированность чего? Во-вторых, ну замокаю я сервисы вью модели, проверю, что по нажатию кнопки что-то где-то вызвалось. Что на самом деле мне даёт такой тест? У нас в команде не идиоты работают, + qa, так что, вероятность того, что кто-то уберёт этот вызов по нажатию кнопки, крайне мала. Опять же, интеграционные тесты не пройдут после такого, так что qa даже билд не получит с такой проблемой. Зато, после рефакторинга, придётся ещё и этот бесполезный тест менять.


          1. AgentFire
            20.11.2024 06:06

            Во-первых, детерминированность чего?

            Детерминированность тестов. Ты один раз endpoint дернул - будет хороший результат, второй раз дёрнул - случилась внутренния ошибка чтения из серверного кеша. Но второй-пятый-десятый раз мало кто дергает.

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


            1. gdt
              20.11.2024 06:06

              Если есть какой-то generic переиспользуемый кэш, у которого может быть внутренняя ошибка чтения, наверное имеет смысл отдельно его покрыть юнит-тестами. Чтобы не было такого, что интеграционные тесты из-за него периодически падают. Одно другому в целом не мешает.


              1. AgentFire
                20.11.2024 06:06

                Ну вот. А если всё хорошенько покрыть юнитами, то интеграционные уже особо ничего не порешают. Уже нужен будут более верхние по пирамиде. ^^


  1. colotiline
    20.11.2024 06:06

    Также пишу тесты. Два года назад попробовал в го и сделал в дотнете github.com/colotiline/go-like-tests-in-csharp .