Хабрахабр

По дороге к 100% покрытия кода тестами в Go на примере sql-dumper

image

Начну с описания, зачем мне нужна была это программа. В этом посте я расскажу о том, как я писал консольную программу на языке Go для выгрузки данных из БД в файлы, стремясь покрыть весь код тестами на 100%. Дальше немного упомяну сборку на Travis CI, а затем расскажу о том, как я писал тесты, пытаясь покрыть код на 100%. Продолжу описанием первых трудностей, некоторые из которых вызваны особенностями языка Go. А в заключении скажу о том, к чему приводит стремление максимально покрыть код тестами и о чём говорит этот показатель. Немного затрону тестирование работы с БД и файловой системой. Материал я сопровожу ссылками как на документацию, так и на примеры коммитов из своего проекта.

Результатом работы должен быть файл, в котором описаны запросы на создания указанных таблиц с указанными столбами и insert-выражения выбранных данных. Программа должна запускаться из командной строки с указанием списка таблиц и некоторых их столбцов, диапазона данных по первому указанному столбцу, перечислением связей выбираемых таблиц между собой, с возможностью указать файл с настройками подключения к БД. Кроме того, эти sql-файлы выгрузок предполагалось обрабатывать другой программой, которая заменяет часть данных по определенному шаблону. Предполагалось, что использование такой программы упростит сценарий извлечения порции данных из большой БД и разворачивания этой порции локально.

Приложение же должно было упростить этот процесс и максимально автоматизировать. Такого же результата можно добиться, используя любой из популярных клиентов к БД и достаточно большим объёмом ручной работы.

Но ситуация получилась такая, что от этой задумки отказались. Эту программу должны были разработать мои стажёры с целью обучения и последующего использования в их дальнейшем обучении. А я всё же решил попробовать сам написать в свободное время такую программу в целях своей практики разработки на языке Go.

В любом случае, это не боевой проект. Решение неполное, имеет ряд ограничений, которые описаны в README.

Примеры использования и исходный код.

Большинство примеров по работе с БД на Go подразумевало то, что структура БД заранее известна, мы просто создаем struct с указанием типов у каждого столбца. Список таблиц и их столбцов передаётся в программу аргументом в виде строки, то есть он заранее неизвестен. Но в этом случае так не получится.

Дальше вопросом стало, как из этих интерфейсов получить реальный тип данных. Решением для этого стало использование метода MapScan из github.com/jmoiron/sqlx, который создавал слайс интерфейсов в размере, равном количеству столбцов выборки. Такое решение выглядит не очень красивым, потому что нужно будет все типы приводить к строке: целые — как есть, строки — экранировать и оборачивать в кавычки, но при этом описывать все типы, которые могут прийти из БД. Решением является switch-case по типу. Более элегантного способа решения этого вопроса я не нашёл.

Для решения этой проблемы в пакете database/sql есть решение — использовать специальные struсt, которые хранят в себе значение и признак, NULL это или нет. С типами проявилась еще особенность языка Go — переменная типа string не может принимать значение nil, но из БД может прийти как пустая строка, так и NULL.

Файл .travis.yml для сборки довольно простой: Для сборки я использую Travis CI, для получения процента покрытия кода тестами — Coveralls.

language: go go: - 1.9 script: - go get -t -v ./... - go get golang.org/x/tools/cmd/cover - go get github.com/mattn/goveralls - go test -v -covermode=count -coverprofile=coverage.out ./... - $HOME/gopath/bin/goveralls -coverprofile=coverage.out -service=travis-ci -repotoken $COVERALLS_TOKEN

В настройках Travis CI нужно только указать переменную окружения COVERALLS_TOKEN, значение которой нужно взять на сайте.

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

Это самая объёмная работа при написании тестов, да и, в целом, при разработке приложения. Покрытие кода тестами на 100% означает, что написаны тесты, которые, помимо прочего, выполняют код на каждое ветвление в if.

Вычислять покрытие тестами можно и локально, например, той же go test -v -covermode=count -coverprofile=coverage.out ./..., но делать это ещё и в CI солиднее, можно плашку на Github разместить.

Раз уж зашла речь о плашках, то я считаю полезной плашку от https://goreportcard.com, которая проводит анализ по следующим показателям:

  • gofmt – форматирование кода, в том числе упрощение конструкций
  • go_vet – проверяет подозрительные конструкции
  • gocyclo – показывает проблемы в цикломатической сложности
  • golint – для меня это проверка наличия всех необходимых комментариев
  • license – в проекте должна быть лицензия
  • ineffassign – проверяет неэффективные присвоения
  • misspell – проверяет на опечатки

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

Но это сложное решение, далеко от unit-тестирования и накладывает свои требования на окружение, в том числе на CI-сервере. Как вариант, подключаться к настоящему серверу БД, в каждом тесте предзаполнять данными, проводить выборки, очищать.

Open("sqlite3", ":memory:")), но это подразумевает, что код должен быть как можно слабее привязан к движку БД, а это значительно усложняет проект, но для интеграционного теста вполне хорошо. Другим вариантом могло быть использование БД в памяти, например, sqlite (sqlx.

Я нашёл этот. Для unit-тестирования подойдет использование mock для БД. С помощью этого пакета можно тестировать поведение как в случае обычного результата, так и в случае возникновения ошибок, указав, какой запрос какую ошибку должен вернуть.

Написание тестов показало, что функцию, которая осуществляет подключение к реальной БД, нужно вынести в main.go, так можно будет её переопределить в тестах на ту, которая будет возвращать mock-экземпляр.

Это позволит подменять запись реальных файлов на запись в память для удобства тестирования и уменьшит зацепление (coupling). Кроме работы с БД нужно вынести в отдельную зависимость работу с файловой системой. Для тестирования сценариев ошибки были созданы вспомогательные реализации этих интерфейсов и размещены в файле filewriter_test.go, таким образом, они не попадают в общий билд, но могут быть использованы в тестах. Так появился интерфейс FileWriter, а вместе с ним и интерфейс возвращаемого им файла.

На тот момент у меня там было достаточно кода. Через некоторое время у меня возник вопрос, как покрыть тестами main(). Вместо этого, весь код, который можно вынести из main(), нужно вынести. Как показали результаты поиска, таким в Go не занимаются. Но эти строки не позволяют получить ровно 100% покрытия. В своём коде я оставил только разбор опций и аргументов командной строки (пакет flag), подключение к БД, инстанцирование объекта, который будет заниматься записью файлов, и вызов метода, который будет выполнять всю остальную работу.

Это тестовые функции, которые сравнивают вывод с тем, что описан в комментарии внутри такой функции. В тестировании Go есть такое понятие, как "Example functions". Если такие файлы не содержат тестов и бенчмарков, то именуются они с префиксом example_ и оканчиваются на _test.go. Примеры таких тестов можно найти в исходном коде пакетов go. На этом я и написал тест для объекта, который занимается записью sql в файл, заменив реальную запись в файл на мок, из которого можно достать содержимое и вывести. Имя каждой такой тестовой функции должно начинаться с Example. Удобно, не нужно писать руками сравнение, да и несколько строк удобно писать в комментарии. Этот вывод и сравнивается с эталоном. По RFC4180 строки в CSV должны отделяться CRLF, а go fmt заменяет все строки на LF, что приводит к тому, что эталон из комментария не совпадает с актуальным выводом из-за разных разделителей строк. Но когда дело дошло до теста на объект, который записывает данные в csv-файл, возникли трудности. Пришлось для этого объекта писать обычный тест, при этом ещё и файл переименовывать, убрав example_ из него.

Здесь, например, есть только один example_test.go. Остался вопрос, если файл, допустим, query.go тестируется и по Example и по обычным тестам, должно ли быть два файла example_query_test.go и query_test.go? Использовать поиск по "go test example" то ещё развлечение.

Большинство из тех, которые мне попадались (1, 2, 3, 4), предлагают сравнивать полученный результат с ожидаемым конструкцией вида Писать тесты в Go я учился по руководствам, которые выдаёт Google по запросу "go writing tests".

if v != 1.5 { t.Error("Expected 1.5, got ", v)
}

Или ещё пример, когда нужно проверить, что в slice или map есть необходимое значение. Но когда дело доходит до сравнения типов, привычная конструкция эволюционно перерождается в нагромождение из использования "reflect" или type assertation. Так и хочется писать свои вспомогательные функции для теста. Код становится громоздким. Я нашёл https://github.com/stretchr/testify. Хотя хорошим решением здесь является использовать библиотеку для тестирования. Такое решение сокращает объём кода и упрощает чтение и поддержку тестов. Она позволяет делать сравнения одной строкой.

Если ставить себе цель только 100% покрытие, то пропадает мотивация писать unit-тесты на мелкие компоненты системы, потому что это не влияет на значение code coverage. Написание теста на высокоуровневую функцию, которая работает с несколькими объектами, позволяет одним разом существенно поднять значение покрытия кода тестами, потому что в ходе этого теста выполняется много строк кода отдельных объектов.

Можно получить высокое значение покрытия, но при этом не обнаружить серьезные ошибки в работе приложения. Кроме того, если в тест-функции не проверять результат, то это тоже не будет влиять на значение code coverage.

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

Если код имеет сильное зацепление (coupling), то, скорее всего, вы не сможете написать на него тест, а значит, вам придется внести в него изменения, что положительно скажется на качестве кода.

Работоспособное приложение я мог получить за 10 часов разработки, но на достижение 95% покрытия у меня ушло от 20 до 30 часов времени. До этого проекта мне не приходилось ставить себе цель в 100% покрытия кода тестами. На небольшом примере я получил представление о том, как значение покрытия кода влияет на его качество, сколько уходит усилий на его поддержку.

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

Подробнее об этом вы можете почитать в следующих материалах и комментариях к ним:

Спойлер

Простите. Слово «покрытие» использовано около 20 раз.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»