Привет, Хабр!

Недавно я снова услышал тезис, что code coverage не нужен и совсем не обязательно за ним следить, а нужно просто делать black-box тесты и считать количество тест-кейсов. Я не согласен с подобного рода категоричными утверждениями. В этой статье постараюсь изложить свою точку зрения и развеять некоторые мифы, опираясь на свой опыт в разработке и идеи, которые почерпнул из книг и статей других инженеров.

Тесты точно нужны?

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

Говоря тесты, я подразумеваю под этим словом как unit-тесты, так и компонентные\интеграционные тесты — все те вещи, которые могут и должны писать разработчики.

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

Для подсчета покрытия кода тестами в Java/Kotlin проектах традиционно используется JaCoCo. Если хотите узнать про этот инструмент больше, то рекомендую посмотреть доклад Евгения Мандрикова Java, Code Coverage & Their Best Friend: Bytecode.

Единственная объективная характеристика

На мой взгляд, в пренебрежительном ключе о code coverage говорят в основном те, кто не очень разобрался с unit/компонентным тестированием и правильным подсчетом покрытия. Ключевая мысль, которую я хочу донести: code coverage — это единственная объективная характеристика, которую мы можем собрать и на основании которой можем судить о том, насколько хорошо и полно протестирован тот или иной программный продукт\приложение\фрагмент кода.

Дело в том, что подходы с black-box тестированием или с тест-кейсами крайне субъективны. Вы можете придумать сотни тестовых сценариев и проверять их у себя, но это никоим образом не ложится на вашу кодовую базу. Может получиться ситуация, что во всех этих сотнях сценариев вы тестируете в основном happy path и какие-то ближайшие ответвления от него. При этом у вас может быть 20%-30% кода, который вообще не протестирован, и куда поток выполнения никогда не заходит во время ваших тестов.

И тут возникает закономерный вопрос, нужен ли вам этот код вообще? Важен ли он? Вероятно, у вас есть мёртвая кодовая база. Возможно, написан лишний код, который вообще не нужен. К сожалению, используя только black-box тестирование и просто набросав кучу тестовых сценариев, вы не сможете это отследить. Вам нужна визуализация и наглядность, которую как раз даёт JaCoCo.

Не все ветви кода покрыты тестами
Не все ветви кода покрыты тестами
Не все методы покрыты тестами
Не все методы покрыты тестами

Ложная уверенность от тестов

При этом надо понимать, что нет никакого универсального инструмента, серебряной пули или кнопки «сделать все хорошо».  Code coverage не решит все ваши проблемы, и даже 100% покрытие кода тестами не гарантирует того, что в вашем коде нет ошибок. Это просто инструмент. Очень хороший, полезный, объективный инструмент, который должен вам помогать. Важно понимать все его достоинства и недостатки и уметь правильно их использовать. Отсюда вторая ключевая мысль: не попадайте в ловушку ложной уверенности от тестов.

Так ли легко подделать code coverage?

Часто можно услышать, что code coverage легко обойти — просто написать много тестов без assert’ов, которые будут вызывать методы, проходить по строчкам и ничего не проверять. И таким образом получить высокое покрытие без реальных проверок. Да, это правда, но только отчасти.

Можно ли такие тесты сделать? Безусловно можно. Будет ли это легко, и так ли бесполезны они будут? Нет.

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

Не очень хороший тест (мало проверок):

@Test
void getAllShouldWork() {
    webTestClient.get()
           .uri(uriBuilder -> uriBuilder
                   .path("/employee/all")
                   .build())
           .accept(MediaType.APPLICATION_JSON)
           .exchange()
           .expectStatus().isOk();
}

Более качественный тест (больше проверок):

@Test
void getAllShouldReturnEmptyListOnEmptyDatabase() {
   final EmployeeDto[] result = webTestClient.get()
           .uri(uriBuilder -> uriBuilder
                   .path("/employee/all")
                   .build())
           .accept(MediaType.APPLICATION_JSON)
           .exchange()
           .expectStatus().isOk()
           .expectBody(EmployeeDto[].class)
           .returnResult()
           .getResponseBody();
   assertThat(result)
           .isEmpty();
}

Правильно считайте покрытие

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

Пример настройки для Gradle:

jacocoTestCoverageVerification {
   dependsOn(jacocoTestReport)
   violationRules {
       rule {
           limit {
               counter = "CLASS"
               value = "MISSEDCOUNT"
               maximum = "0.0".toBigDecimal()
           }
       }
       rule {
           limit {
               counter = "METHOD"
               value = "MISSEDCOUNT"
               maximum = "0.0".toBigDecimal()
           }
       }
       rule {
           limit {
               counter = "LINE"
               value = "MISSEDCOUNT"
               maximum = "1.0".toBigDecimal()
           }
       }
       rule {
           limit {
               counter = "INSTRUCTION"
               value = "COVEREDRATIO"
               minimum = "0.97".toBigDecimal()
           }
       }
   }
}

Пример настройки для Maven:

<plugin>
   <groupId>org.jacoco</groupId>
   <artifactId>jacoco-maven-plugin</artifactId>
   <version>0.8.12</version>
   <executions>
       <execution>
           <id>jacoco-initialize</id>
           <goals>
               <goal>prepare-agent</goal>
           </goals>
       </execution>
       <execution>
           <id>jacoco-report</id>
           <phase>test</phase>
           <goals>
               <goal>report</goal>
           </goals>
       </execution>
       <execution>
           <id>jacoco-check-minimal-coverage</id>
           <phase>prepare-package</phase>
           <goals>
               <goal>check</goal>
           </goals>
           <configuration>
               <rules>
                   <rule>
                       <element>BUNDLE</element>
                       <limits>
                           <limit>
                               <counter>INSTRUCTION</counter>
                               <value>COVEREDRATIO</value>
                               <minimum>0.66</minimum>
                           </limit>
                           <limit>
                               <counter>BRANCH</counter>
                               <value>COVEREDRATIO</value>
                               <minimum>0.60</minimum>
                           </limit>
                           <limit>
                               <counter>CLASS</counter>
                               <value>MISSEDCOUNT</value>
                               <maximum>1</maximum>
                           </limit>
                           <limit>
                               <counter>METHOD</counter>
                               <value>MISSEDCOUNT</value>
                               <maximum>12</maximum>
                           </limit>
                           <limit>
                               <counter>LINE</counter>
                               <value>MISSEDCOUNT</value>
                               <maximum>58</maximum>
                           </limit>
                       </limits>
                   </rule>
               </rules>
           </configuration>
       </execution>
   </executions>
</plugin>

Как добиться «хороших» тестов?

Если следить за качеством кодовой базы самих тестов, то можно избежать множества проблем, на которые любят ссылаться противники подсчета покрытия. Например, мы можем начать со статического анализа. На Хабре у меня есть отдельная статья про это. Внедряя инструменты вроде Spotbugs, ErrorProne, PMD или Sonar, вы можете добиваться определенной структуры тестов и наличия assert’ов в каждом тесте. Безусловно, это не дает какой-то суперзащиты, но тем не менее побуждает разработчиков лучше оформлять тестовый код.

Следующий очень важный критерий — это мутационное тестирование. Мутационные тесты проверяют код ваших тестов, оценивая их качество и устойчивость. Это та вещь, которая может решить 99% проблем с тестами. Я об этом постараюсь рассказать позднее в отдельной статье, а пока можете просто почитать про pitest.

Последний рубеж защиты — это code review. Вы должны ревьюить в том числе код тестов и их логику. Глазами человек может увидеть то, что не в состоянии отследить инструменты автоматической проверки. Рекомендую посмотреть доклад Cracking the Code Review.

Какие тесты писать не нужно?

Старайтесь избегать unit-тестов на mock’ах. Создавайте их только в крайних случаях, когда без этого действительно не обойтись. Очень часто такие тесты получаются слишком "мелкими": они завязываются на детали реализации, увеличивая трудозатраты на поддержание большого количества моков и затрудняя рефакторинг кода в будущем.

Старайтесь писать более высокоуровневые тесты, которые будут проверять функциональность вашего приложения, видимую в первую очередь внешним наблюдателям. Я крайне рекомендую использовать Testcontainers и живые интеграции (очереди сообщений, базы данных и т. д). При таком подходе вы пишете моки максимум на взаимодействие с соседними сервисами.

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

# lombok.config
lombok.addLombokGeneratedAnnotation = true

Какие тесты стоит добавить в ваше приложение?

Я рекомендую тестировать различные инфраструктурные вещи: метрики, swagger, health-check’и. Например, в Spring Boot приложении метрики могут не отдаваться, если нет зависимости на io.micrometer:micrometer-registry-prometheus или нет нужных параметров в application.yml. Аналогично с пробами. Сваггер может сломаться при обновлении версии и т.п. По сути, всё может сломаться при обновлении мажорной версии фреймворка.

Пока вы это не тестируете, вы не можете гарантировать, что оно вообще работает.

class ActuatorEndpointTest extends TestBase {

    @LocalServerPort
    private int port;
    @LocalManagementPort
    private int actuatorPort;

    private WebTestClient actuatorClient;

    @BeforeEach
    void setUp() {
        this.actuatorClient = WebTestClient.bindToServer()
                .baseUrl("http://localhost:" + actuatorPort + "/actuator/")
                .build();
    }

    @Test
    void actuatorShouldBeRunOnSeparatePort() {
        assertThat(actuatorPort)
                .isNotEqualTo(port);
    }

    @ParameterizedTest
    @CsvSource(value = {
            "prometheus|jvm_threads_live_threads|text/plain",
            "health|{\"status\":\"UP\",\"groups\":[\"liveness\",\"readiness\"]}|application/json",
            "health/liveness|{\"status\":\"UP\"}|application/json",
            "health/readiness|{\"status\":\"UP\"}|application/json",
            "info|\"version\":|application/json"}, delimiter = '|')
    void actuatorEndpointShouldReturnOk(@Nonnull final String endpointName,
                                        @Nonnull final String expectedSubstring,
                                        @Nonnull final String mediaType) {
        final var result = actuatorClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path(endpointName)
                        .build())
                .accept(MediaType.valueOf(mediaType))
                .exchange()
                .expectStatus().isOk()
                .expectBody(String.class)
                .returnResult()
                .getResponseBody();
        assertThat(result)
                .contains(expectedSubstring);
    }

    @Test
    void swaggerUiEndpointShouldReturnFound() {
        final var result = actuatorClient.get()
                .uri(uriBuilder -> uriBuilder
                        .pathSegment("swagger-ui")
                        .build())
                .accept(MediaType.TEXT_HTML)
                .exchange()
                .expectStatus().isFound()
                .expectHeader().location("/actuator/swagger-ui/index.html")
                .expectBody()
                .returnResult()
                .getResponseBody();
        assertThat(result).isNull();
    }

    @Test
    void readinessProbeShouldBeCollectedFromApplicationMainPort() {
        final var result = webTestClient.get()
                .uri(uriBuilder -> uriBuilder
                        .pathSegment("readyz")
                        .build())
                .accept(MediaType.APPLICATION_JSON)
                .exchange()
                .expectStatus().isOk()
                .expectBody(String.class)
                .returnResult()
                .getResponseBody();
        assertThat(result)
                .isEqualTo("{\"status\":\"UP\"}");

        final String metricsResult = actuatorClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("prometheus")
                        .build())
                .accept(MediaType.valueOf("text/plain"))
                .exchange()
                .expectStatus().isOk()
                .expectBody(String.class)
                .returnResult()
                .getResponseBody();
        assertThat(metricsResult)
                .contains("http_server_requests_seconds_bucket");
    }
}

Полный пример тут.

Какое покрытие кода должно быть?

Часто в качестве целевого показателя покрытия кода вы можете встретить цифру в 80%. Кто-то называет допустимым диапазон в 70%-90%. Так сколько же должно быть?

Мне очень импонирует тезис Роберта Мартина — чем больше, тем лучше. В идеале, конечно, стоит стремиться к 100% показателю покрытия, но только для отдельных критически важных компонентов и приложений (общих библиотек, mission critical и business critical сервисов). Здесь я бы отталкивался от того, на какие перцентили по доступности или latency вы смотрите. Хватает ли вам p95 или нужен p99? Почему покрытие кода тестами должно быть меньше?

В то же время чем больше ваша кодовая база, тем сложнее добиться высоких показателей по покрытию. Для библиотеки в 2-3 тысячи строк кода 100% покрытие достаточно легко достижимо. Для микросервиса такого же размера получить заветные 100% уже гораздо сложнее.

В качестве некоторого эмпирического правила можете вычитать из сотни по одному проценту за каждые 1-2 тысячи строк кода. Например, если у вас проект на 10 тысяч строк, то 90% покрытия (100% - 10 * 1%) будет вполне приличным показателем. Опять же это правило не очень применимо к большим и legacy проектам.

Заключение

Научитесь тестировать свой код автоматизированно. На горизонте нескольких лет это сэкономит вам огромное количество времени.

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

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

И тогда тесты станут вашими верными друзьями и помощниками.

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


  1. qeeveex
    16.08.2024 04:07
    +1

    Почему у КДПВ монитор не той стороной?!


    1. IvanVakhrushev Автор
      16.08.2024 04:07

      Нейросетка от Microsoft так генерирует) У меня было несколько попыток, и каждый раз для ноутбука получилось именно так))


      1. qeeveex
        16.08.2024 04:07

        И теперь обязательно ставить кривую картинку?! Разве в гугле не ищется какая-то подходящая фотография или нормальное изображение?!

        Да даже можно самому в фотошопе отредактировать.


        1. IvanVakhrushev Автор
          16.08.2024 04:07
          +1

          ИМХО получилось забавно, поэтому не стал менять.


    1. urvanov
      16.08.2024 04:07

      Тоже в первую очередь это заметил. У мужика на сгенеренной картинке какой-то странный ноут