Визуализация покрытия автотестами для gRPC сервисов
- вторник, 24 сентября 2024 г. в 00:00:04
В данной статье хочу рассказать про инструмент измерения покрытия gRPC сервисов tests-coverage-tool, написанный на языке Golang. Основная задача инструмента — автоматическое измерение покрытия требований на основе proto контрактов. Поговорим про использование в автотестах, концепцию, отчет и кратко про архитектуру самого инструмента tests-coverage-tool
Выделяют два вида покрытия: по коду, по требованиям. Данная статья рассказывает про инструмент, который ориентирован на измерение покрытия по требованиям. Требования в данном случае это proto контракты. Соответственно все метрики и аналитика будет строиться вокруг proto контрактов, измерить покрытие требований со стороны глубокой бизнес логики сервиса данным инструментом не получится.
При разработке и тестировании gRPC сервисов критически важно обеспечить покрытие автотестами всех ключевых сценариев взаимодействия между клиентом и сервером. Как минимум необходимо убедиться, что все методы gRPC сервиса покрыты автотестами, как максимум, что все поля ответа/запроса полностью проверяются автотестами. Однако эта задача усложняется с ростом количества сервисов: десятки, а иногда и сотни методов, которые могут изменяться с каждым новым релизом. Постоянные изменения контрактов требуют своевременной адаптации автотестов. Инструмент tests-coverage-tool помогает объективно оценивать текущее состояние покрытия и на основе этого делать выводы о необходимости написания/изменения автотестов.
Важное преимущество инструмента tests-coverage-tool в том, что покрытие требований собирается автоматически: все, что необходимо, — это правильная настройка.
Важно. Возможно для некоторых читателей статья покажется сложной — делаю такое предположение исходя из популярности REST сервисов и использования HTTP/1.1 протокола, gRPC все же встречается реже. Поэтому сразу рекомендую ознакомиться со ссылками ниже:
Также отмечу, что даже если инструмент tests-coverage-tool предназначен для gRPC сервисов и написан на golang, суть и принцип от этого не меняется, точно такой же инструмент может быть написан на любом другом языке с поддержкой gRPC. Сама концепция измерения покрытия «по контрактами» может применяться и к HTTP/1.1 протоколу в купе с swagger документацией.
Все описанное ниже имеет практическое применение на реальном проекте. В разделе «Практическое применение» поделюсь своим опытом и расскажу про потенциальные области применения.
Рассмотрим основную концепцию tests-coverage-tool. Задача инструмента сводится к тому, чтобы сравнить ожидаемые контракты с фактическими. Ожидаемые контракты берутся из reflection тестируемого сервиса. Фактические контракты будут собираться при запуске автотестов. Все достаточно просто и понятно, есть определенные сложности с реализацией, но все эти сложности решаются на уровне кода.
Задача данного раздела также состоит в том, чтобы задать правильные вопросы про покрытие proto контрактов. В процессе статьи покажу, как инструмент tests-coverage-tool помогает ответить на эти вопросы.
Для того, чтобы легче понять принцип работы инструмента, рассмотрим простой контракт сервиса управления статьями ArticlesService:
service ArticlesService {
rpc GetArticle (GetArticleRequest) returns (GetArticleResponse);
rpc CreateArticle (CreateArticleRequest) returns (CreateArticleResponse);
rpc UpdateArticle (UpdateArticleRequest) returns (UpdateArticleResponse);
rpc DeleteArticle (DeleteArticleRequest) returns (DeleteArticleResponse);
}
В данном контракте описан логический сервис ArticlesService
и несколько методов GetArticle
, CreateArticle
, UpdateArticle
, DeleteArticle
. С точки зрения автоматизации тестирования нам нужно отвечать на вопросы:
Каков общий процент покрытия логического сервиса?
Какие из методов логического сервиса покрыты автотестами? На сколько процентов покрыт весь логический сервис?
Какие методы устарели (deprecated) и нам не стоит писать на них автотесты?
Сразу определимся с терминологией логический сервис. На практике одно приложение может содержать несколько логических сервисов, например, на одном хосте localhost:3000
может находиться сразу несколько логических сервисов: ArticlesService
, UserService
, AccountService
и т.д. Соответственно с точки зрения измерения покрытия, есть смысл работать индивидуально с каждым логическим сервисом в рамках одного приложения.
Теперь детальнее рассмотрим метод GetArticle
, который принимает запрос GetArticleRequest
:
// Обычно в запросе передаётся не так много полей: идентификаторы, фильтры, либо пустой запрос
// В таком случае, наверняка, все поля будут покрыты автотестами, и покрытие достаточно легко оценить
// К сожалению, не всегда всё проходит так гладко, как хотелось бы
// В моей практике встречаются методы, которые принимают десятки полей в запросе, содержат enum-ы и вложенные структуры
// В таких случаях оценить покрытие "на глаз" становится крайне сложной задачей
// Именно здесь на помощь приходит инструмент измерения покрытия
message GetArticleRequest {
string article_id = 1;
}
И возвращает ответ GetArticleResponse
:
message GetArticleResponse {
// Важно проверять все варианты, то есть у нас должны быть автотесты на ошибку и на успешный ответ
oneof result {
Error error = 1; // Error это отдельная модель. Необходимо проверять каждое поле внутри данной модели
Article article = 2; // Article это также отдельная модель
}
}
В моделях запроса и ответа есть определенные параметры/поля, например, в GetArticleRequest
это article_id
, а в GetArticleResponse
может вернуться одно из полей error
, article
. При этом Error
, Article
это тоже модели со своей структурой и вложенностью. Рассмотрим подробнее модель Error
:
// Для нас очень важно понимать, какие именно значения enum-ма протестированы
// На этом может быть завязана критичная бизнес логика
enum ErrorType {
NOT_FOUND = 0;
ALREADY_EXISTS = 1;
UNSPECIFIED = 2;
}
message Error {
string message = 1; // Примитивный тип строки
ErrorType type = 2; // А это уже enum с определенными значениями
}
Видим, что модель Error
содержит поля: message
— строка, type
— enum, нам также необходимо понимать покрыты ли данные поля автотестами. Обратите внимание, что поле type внутри модели Error
имеет тип ErrorType
— это enum/перечисление, который тоже необходимо покрывать автотестами и хотелось бы видеть, какие конкретно значения enum-мов покрыты. По итогу с точки зрения автоматизации тестирования нам нужно отвечать на вопросы:
Сколько всего полей в ответе/запросе и сколько из них покрыто? Какой процент покрытия полей?
Какие конкретно поля покрыты автотестами? Хочется видеть всю вложенность полей, включая enum-ы. В примере выше есть поля error
, article
, хочется понимать, есть ли у нас автотесты, которые проверяют ошибку и успешный запрос?
Какие поля устарели (deprecated) и нам не стоит писать на них автотесты?
На все вопросы выше ответим в разделе «Отчет».
Архитектурно инструмент tests‑coverage‑tool состоит из двух проектов:
tests‑coverage‑tool — сам инструмент, проект содержит в себе все нужные функции, команды, пакеты, перехватчик для встраивания в автотесты;
tests‑coverage‑report — мета проект, который используется только в качестве submodule‑я и предоставляет платформу для репортинга, что такое submodule в git можно почитать тут. Сам отчет написан на react + typescript и собирается в единый HTML файл готовый к использованию. При генерации отчета в HTML файл подставляется нужный state и получается готовый HTML отчет.
Подробно вдаваться в реализацию инструмента не будем, считаю, что это тема для отдельной статьи. Детальнее хочется остановиться только на реализации перехватчика CoverageInterceptor:
package coverageinupt
import (
// Стандартные go-шные библиотеки
"context"
"fmt"
"log"
// Настройки и утилиты из библиотеки tests-coverage-tool
"github.com/Nikita-Filonov/tests-coverage-tool/tool/config"
"github.com/Nikita-Filonov/tests-coverage-tool/tool/utils"
// Библиотека uuid для генерации названия файла с результатами покрытия
"github.com/google/uuid"
// Библиотека для работы с gRPC протоколов в golang. В данном случае нам нужны только типы из этой библиотеки
"google.golang.org/grpc"
)
func CoverageInterceptor() grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
invokerErr := invoker(ctx, method, req, reply, cc, opts...)
// Собираем результат покрытия из данных gRPC перехватичка: метод, запрос, ответ
result, err := buildCoverageResult(method, req, reply)
if err != nil {
// Если результат собрать не получилось, пишем лог об ошибке, но ничего не фейлим
// Так сделано специально, чтобы никак не блокировать выполнение автотеста
log.Printf("Error building coverage result: %v", err)
return invokerErr
}
// Получаем настройки, чтобы понимать, куда нужно сохранить результат покрытия
toolConfig, err := config.NewConfig()
if err != nil {
// Также в случае ошибки ничего не фейлим, просто пишем лог
log.Printf("Error building config: %v", err)
return invokerErr
}
// Сохраняем результат покрытия в JSON файл
filename := fmt.Sprintf("%s.json", uuid.New().String())
resultsDir := toolConfig.GetResultsDir()
if err = utils.SaveJSONFile(result, resultsDir, filename); err != nil {
// Также в случае ошибки ничего не фейлим, просто пишем лог
log.Printf("Error saving coverage result: %v", err)
}
return invokerErr
}
}
Для корректной работы инструмента tests-coverage-tool необходимо указать путь к YAML файлу с настройками, для этого нужно установить переменную окружения TESTS_COVERAGE_CONFIG_FILE:
export TESTS_COVERAGE_CONFIG_FILE="./examples/config-example.yaml"
Теперь рассмотрим, как должен выглядеть YAML файл с настройками:
# Список сервисов, для которых необходимо измерять покрытие
services:
- name: "first service" # Название сервиса — мета информация для корректного отображение в отчете
host: "localhost:1000" # Адрес gRPC сервиса
repository: "https://repo.com/first-service" # Ссылка на репозиторий — мета информация для корректного отображение в отчете
- name: "second service"
host: "localhost:2000"
repository: "https://repo.com/second-service"
- name: "third service"
host: "localhost:3000"
repository: "https://repo.com/third-service"
# Настройки ниже опциональны
# Все значения указанные ниже являются стандартными, то есть будут использоваться по умолчанию
# При необходимости настройки можно изменить через yaml файл или через переменные окружения
# Переменная окружения "TESTS_COVERAGE_RESULTS_DIR"
resultsDir: "." # Директория, в которую будут сохраняться файлы с результатами покрытия
# Переменная окружения "TESTS_COVERAGE_HTML_REPORT_DIR"
htmlReportDir: "." # Директория, в которую будет сохранен HTML отчет
# Переменная окружения "TESTS_COVERAGE_JSON_REPORT_DIR"
jsonReportDir: "." # Директория, в которую будет сохранен JSON отчет
# Переменная окружения "TESTS_COVERAGE_HTML_REPORT_FILE"
htmlReportFile: "index.html" # Имя HTML файла отчета
# Переменная окружения "TESTS_COVERAGE_JSON_REPORT_FILE"
jsonReportFile: "coverage-report.json" # Имя JSON файла отчета
Важно. Настройки в YAML файле имеют приоритет над настройками переданными через переменные окружения. Например, если в YAML файле указано htmlReportFile: "index.html"
, а в переменных окружения TESTS_COVERAGE_HTML_REPORT_FILE="report.html"
, то конечным значением будет index.html.
Подробнее со всеми настройками можно ознакомиться тут. Пример yaml файла с настройками.
Интеграция в автотесты максимально проста и осуществляется одной строчкой кода — использованием перехватчика CoverageInterceptor. Давайте рассмотрим пример автотеста, который будет использовать описанный выше контракт сервиса ArticlesService:
package test
import (
// Стандартные go-шные библиотеки
"context"
"crypto/tls"
"fmt"
"log"
"testing"
// Библиотека, которая поможет интегрировать перехватчик CoverageInterceptor в клиентское соедениение
grpcmiddleware "github.com/grpc-ecosystem/go-grpc-middleware"
// Библиотека для работы с gRPC протоколов в golang
// С помощью данной библиотеки мы создаим клиентское соедение с gRPC сервером
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
// Сама библиотека tests-coverage-tool, из нее нам нужен перехватчик CoverageInterceptor
"github.com/Nikita-Filonov/tests-coverage-tool/tool/coverageinupt"
// Я не укзавал импорты для прото контрактов ArticleService, считаю это бессмыслено
// На практике у вас, как и у меня будут другие контракты реальных проектов
// В данном контексте важно лишь показать интеграцию перехватика CoverageInterceptor
)
func TestGetArticle(t *testing.T) {
// Настройка клиентского соединения gRPC
conn, err := grpc.Dial(
"localhost:1000", // Указываем адрес тестируемого приложения
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{InsecureSkipVerify: true})),
grpc.WithUnaryInterceptor(
grpcmiddleware.ChainUnaryClient(
// Указываем CoverageInterceptor для сбора покрытия
// Это единственная строчка кода, которую нужно добавить для сбора покрытия
coverageinupt.CoverageInterceptor(),
),
),
)
if err != nil {
log.Fatalf("Failed to connect to gRPC server: %v", err)
}
defer conn.Close()
// Инициализируем ArticleService из proto контрактов
client := articleservice.NewArticleServiceClient(conn)
// Вызываем gRPC метод; данные о покрытии будут автоматически сохранены в директорию указанную в настройках
resp, err := client.GetArticle(context.Background(), &articleservice.GetArticleRequest{})
fmt.Println(resp, err)
}
После запуска автотеста в директорию coverage-results будут сохранены результаты покрытия, в таком формате:
{
"method": "company.team.v1.ArticleService.GetArticle",
"request": [
{
"covered": false,
"parameter": "article_id",
"deprecated": false
}
],
"response": [
{
"covered": true,
"parameter": "article",
"parameters": [
{
"covered": true,
"parameter": "id",
"deprecated": false
},
{
"covered": true,
"parameter": "title",
"deprecated": false
},
{
"covered": true,
"parameter": "author",
"deprecated": false
},
{
"covered": true,
"parameter": "description",
"deprecated": false
}
],
"deprecated": false
},
{
"covered": false,
"parameter": "error",
"parameters": [
{
"covered": false,
"parameter": "message",
"deprecated": false
},
{
"covered": false,
"parameter": "type",
"deprecated": false,
"parameters": [
{
"covered": false,
"parameter": "NOT_FOUND",
"deprecated": false
},
{
"covered": false,
"parameter": "ALREADY_EXISTS",
"deprecated": false
},
{
"covered": false,
"parameter": "UNSPECIFIED",
"deprecated": false
}
]
}
],
"deprecated": false
}
]
}
Подробно останавливаться на структуре результата не будем, она достаточно проста и понятна. Сам go-шный код структуры находится тут.
Интересно. При подходе с сохранением результатов в файлы может возникнуть закономерный вопрос: сказывается ли это на времени выполнения автотестов? Логично, что да, сказывается, но, как показывает практика использования инструмента, — не сильно. Это добавит несколько миллисекунд к выполнению каждого запроса, все конечно будет сильно зависеть от размера контракта и окружения, на котором запускаются автотесты. При интеграции инструмента в наши автотесты мы не заметили увеличения по времени выполнения, при том, что контракты у нас достаточно большие, в среднем 10 — 50 полей в запросеи 300–1200 полей в ответе. Поэтому можно сделать вывод, что даже если время выполнения каждого запроса увеличивается, то незначительно — на несколько миллисекунд.
Для генерации отчета необходимо:
Результаты покрытия из папки coverage‑results, которые были получены при запуске автотестов. В нашем примере данные результаты были получены в разделе «Интеграция в автотесты»;
Указать путь к YAML файлу с настройками через переменную окружения «TESTS_COVERAGE_CONFIG_FILE». Соответственно сам YAML файл с настройками;
Last but not least. Для корректной работы инструмента tests‑coverage‑tool необходимо чтобы проверяемый gRPC сервис поддерживал reflection.
Теперь, когда все необходимые компоненты в сборе, можно сгенерировать отчет. Устанавливаем tests-coverage-tool, после установки генерируем отчет:
go install github.com/Nikita-Filonov/tests-coverage-tool/...@latest
tests-coverage-tool save-report
Команда save‑report подгрузит ожидаемые proto контракты используя reflection и сравнит их с фактическими результатами в папке coverage‑results. В указанные в настройках папки будут сохранены HTML и JSON отчеты. Большой интерес представляет HTML отчет, так как именно он визуализирует всю картину покрытия. JSON отчет содержит в себе информацию, которая может использоваться для сбора аналитики, отправки результатов в рабочий чат и т. д. Также стоит отметить, что HTML отчет генерируется в единый HTML файл, что очень удобно и открывает большие возможности для дальнейшей работы с ним. Например, HTML отчет можно скачать из артефактов джобы/пайплайна и открыть локально в браузере, либо же опубликовать на GitLab/GitHub pages или просто отправить в виде файла коллеге в рабочий чат.
Теперь детальнее посмотрим из чего состоит HTML отчет. Актуальный пример отчета можно найти тут.
Важно. Перед тем, как я начну рассказ про HTML отчет, хочу предупредить, что данные на скриншотах это тестовые данные, которые могут быть несвязными и противоречивыми, главная задача это показать отображение.
Первое, про что хочется рассказать, это аппбар в шапке отчета. В аппбаре содержится минимальный набор функционала, самое интересное это возможность выбора сервиса, по которому мы хотим посмотреть покрытие:
Далее идет два виджета, один показывает информацию о сервисе, дату создания отчета. Второй виджет показывает общее покрытие сервиса в процентах. Обратите внимание, что в данном контексте имеется ввиду общее покрытие сервиса/приложения, внутри которого может находиться несколько логических сервисов:
Ниже находится большой виджет, который показывает общее покрытие по каждому логическому сервису:
Далее идет самый интересный виджет — таблица с покрытием методов логического сервиса. Выше в разделе «Концепция» мы сформировали вопросы, ответы на которые хотим видеть в отчете:
На скриншоте ниже в колонке «Method» отображается название метода. В колонке «Covered?» виден статус покрытия;
Колонка «Total cases» показывает из скольких случаев была собрана статистика. Например, на один метод у нас может быть несколько автотестов, соответственно будет несколько вызовов метода;
Колонки «Total request parameters/Covered» и «Total response parameters/Covered» показывают общее количество полей, количество покрытых полей и процент покрытия для запроса и ответа соответственно;
В правом верхнем углу находится шкала, которая показывает общий процент покрытия логического сервиса.
По каждому методу можно посмотреть более детальную информацию. В дереве полей запроса/ответа можно видеть конкретно какие из полей были покрыты. В случае если поле устарело (deprecated), то рядом будет отображаться (deprecated). Также для enum‑мов будет отображаться список из всех возможных значений и статус покрытия каждого из значений. Дерево полей дает очень полезную информацию для детального анализа покрытия, можно видеть, что не покрыто, какие маппинги/поля/enum‑ы не покрыты, какие автотесты нужно дописать. Отсутствие проверок на определенные поля это потенциальные баги:
Важно. Хочу немного детальнее рассказать о том, когда поле считается покрытым. Все дело в том, что инструмент tests‑coverage‑tool написан на golang, в golang все примитивные типы имеют стандартные значения/zero value, например, для типа string стандартным значением будет пустая строка ««, для типа bool стандартным значением будет false, для int стандартным значением будет 0, подробнее можно ознакомиться тут. Соответственно поле считается покрытым, когда его значение было отличным от стандартного. Например, у нас есть поле is_user_active и если есть автотест, который проверяет, что поле пришло в значение true, значит поле будет считаться покрытым.
Вариантов применения и анализа покрытия очень много и эта задача полностью ложится на пользователя инструмента tests-coverage-tool — то есть на QA-инженера. Приведу примеры, как пользуется инструментом моя команда, а также приведу потенциальные возможности для использования:
Личный пример использования. В моей команде инструмент используется следующим образом: после написания автотестов на новый функционал QA-инженер, сверяется с отчетом покрытия и проверяет, не забыл ли он проверить все поля запроса и ответа. Если какие-то проверки были пропущены, то дописываются автотесты. Также это сильно сокращает время на ревью тестируемой бизнес логики, по отчету покрытия можно объективно понять, что проверяется в автотестах. Все это дает большую осознанность при написании автотестов, а также объективную оценку покрытия для всей команды;
Наращивание покрытия. Потенциальной возможностью для применения является анализ уже существующих автотестов и увеличение покрытия, через написание новых или корректировке старых автотестов. С помощью HTML отчета, можно увидеть, какие методы не были покрыты автотестами, какие поля запроса/ответа не покрыты, какие значения enum-мов не покрыты. На основе этих данных можно заводить задачи на автоматизацию. В моей команде такого процесса нет, мы пишем автотесты сразу в процессе тестирования задачи в одной ветке с разработчиком, поэтому для нас важнее анализировать покрытие здесь и сейчас. После того, как задача попадает в main, она уже покрыта автотестами;
Анализ устаревших методов и полей: В инструменте tests-coverage-tool есть возможность отслеживать методы и поля, которые помечены как устаревшие (deprecated). Это помогает своевременно удалять или обновлять автотесты, которые проверяют устаревший функционал;
Оценка и планирование покрытия для нового функционала. При работе с proto контрактами, как правило, сами контракты разрабатываются системными аналитиками до передачи задачи в разработку. Соответственно, до написания автотестов уже по имеющимся контрактам можно оценивать объем работы по написанию автотестов.
Скорее всего есть еще много способов применить инструмент на практике, тут уже нужно отталкиваться от конкретных команд, процессов, требований и ресурсов.
Инструмент tests-coverage-tool оказался очень полезен в моей команде, помогая не только осознанно подходить к написанию автотестов, но и объективно оценивать покрытие сервисов. Он позволяет выявлять пробелы в покрытии еще на стадии написания автотестов на новый функционал, соответственно, снижать вероятность возникновения багов, что в конечном итоге улучшает качество продукта. Стоит отметить, что непосредственно сам инструмент не снижает вероятность возникновения багов, но он открывает для нас потенциал/опцию для того, чтобы вовремя обнаружить пробелы и докинуть нужных автотестов.
Одним из ключевых преимуществ инструмента, лично для меня, является его полная автоматизация, что было одним из основных требований при его разработке. С другой стороны инструмент не позволяет капнуть глубже контрактов, именно в саму бизнес логику сервиса. Для оценки бизнес логики, как правило, создаются таблицы, где описывается конкретная функциональность и процент покрытия, делается соотношение с тест кейсами. Данный подход с таблицами сложно автоматизировать, следовательно на поддержку в актуальном состоянии будет уходить не оправдано много времени.
Рекомендую попробовать tests‑coverage‑tool в вашем проекте, если вы работаете с протоколом gRPC. Инструмент полностью готов к работе «из коробки», и я уверен, что вы не будете разочарованы, как минимум это очень интересный и полезный опыт.
Весь исходный код инструмента вы можете найти на моем GitHub: