В этом посте я расскажу о том, как я писал консольную программу на языке Go для выгрузки данных из БД в файлы, стремясь покрыть весь код тестами на 100%. Начну с описания, зачем мне нужна была это программа. Продолжу описанием первых трудностей, некоторые из которых вызваны особенностями языка Go. Дальше немного упомяну сборку на Travis CI, а затем расскажу о том, как я писал тесты, пытаясь покрыть код на 100%. Немного затрону тестирование работы с БД и файловой системой. А в заключении скажу о том, к чему приводит стремление максимально покрыть код тестами и о чём говорит этот показатель. Материал я сопровожу ссылками как на документацию, так и на примеры коммитов из своего проекта.
Назначение программы
Программа должна запускаться из командной строки с указанием списка таблиц и некоторых их столбцов, диапазона данных по первому указанному столбцу, перечислением связей выбираемых таблиц между собой, с возможностью указать файл с настройками подключения к БД. Результатом работы должен быть файл, в котором описаны запросы на создания указанных таблиц с указанными столбами и insert-выражения выбранных данных. Предполагалось, что использование такой программы упростит сценарий извлечения порции данных из большой БД и разворачивания этой порции локально. Кроме того, эти sql-файлы выгрузок предполагалось обрабатывать другой программой, которая заменяет часть данных по определенному шаблону.
Такого же результата можно добиться, используя любой из популярных клиентов к БД и достаточно большим объёмом ручной работы. Приложение же должно было упростить этот процесс и максимально автоматизировать.
Эту программу должны были разработать мои стажёры с целью обучения и последующего использования в их дальнейшем обучении. Но ситуация получилась такая, что от этой задумки отказались. А я всё же решил попробовать сам написать в свободное время такую программу в целях своей практики разработки на языке Go.
Решение неполное, имеет ряд ограничений, которые описаны в README. В любом случае, это не боевой проект.
Примеры использования и исходный код.
Первые трудности
Список таблиц и их столбцов передаётся в программу аргументом в виде строки, то есть он заранее неизвестен. Большинство примеров по работе с БД на Go подразумевало то, что структура БД заранее известна, мы просто создаем struct
с указанием типов у каждого столбца. Но в этом случае так не получится.
Решением для этого стало использование метода MapScan
из github.com/jmoiron/sqlx
, который создавал слайс интерфейсов в размере, равном количеству столбцов выборки. Дальше вопросом стало, как из этих интерфейсов получить реальный тип данных. Решением является switch-case по типу. Такое решение выглядит не очень красивым, потому что нужно будет все типы приводить к строке: целые — как есть, строки — экранировать и оборачивать в кавычки, но при этом описывать все типы, которые могут прийти из БД. Более элегантного способа решения этого вопроса я не нашёл.
С типами проявилась еще особенность языка Go — переменная типа string не может принимать значение nil
, но из БД может прийти как пустая строка, так и NULL
. Для решения этой проблемы в пакете database/sql
есть решение — использовать специальные struсt
, которые хранят в себе значение и признак, NULL
это или нет.
Сборка и вычисление процента покрытия кода тестами
Для сборки я использую Travis CI, для получения процента покрытия кода тестами — Coveralls. Файл .travis.yml
для сборки довольно простой:
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 – проверяет на опечатки
Трудности покрытия кода тестами на 100%
Если разбор небольшого пользовательского запроса на составные части в основном работает с преобразованием строк в некоторые структуры из строк и довольно легко покрывается тестами, то для тестирования кода, который работает с БД решение не столь очевидное.
Как вариант, подключаться к настоящему серверу БД, в каждом тесте предзаполнять данными, проводить выборки, очищать. Но это сложное решение, далеко от unit-тестирования и накладывает свои требования на окружение, в том числе на CI-сервере.
Другим вариантом могло быть использование БД в памяти, например, sqlite (sqlx.Open("sqlite3", ":memory:")
), но это подразумевает, что код должен быть как можно слабее привязан к движку БД, а это значительно усложняет проект, но для интеграционного теста вполне хорошо.
Для unit-тестирования подойдет использование mock для БД. Я нашёл этот. С помощью этого пакета можно тестировать поведение как в случае обычного результата, так и в случае возникновения ошибок, указав, какой запрос какую ошибку должен вернуть.
Написание тестов показало, что функцию, которая осуществляет подключение к реальной БД, нужно вынести в main.go, так можно будет её переопределить в тестах на ту, которая будет возвращать mock-экземпляр.
Кроме работы с БД нужно вынести в отдельную зависимость работу с файловой системой. Это позволит подменять запись реальных файлов на запись в память для удобства тестирования и уменьшит зацепление (coupling). Так появился интерфейс FileWriter
, а вместе с ним и интерфейс возвращаемого им файла. Для тестирования сценариев ошибки были созданы вспомогательные реализации этих интерфейсов и размещены в файле filewriter_test.go
, таким образом, они не попадают в общий билд, но могут быть использованы в тестах.
Через некоторое время у меня возник вопрос, как покрыть тестами main()
. На тот момент у меня там было достаточно кода. Как показали результаты поиска, таким в Go не занимаются. Вместо этого, весь код, который можно вынести из main()
, нужно вынести. В своём коде я оставил только разбор опций и аргументов командной строки (пакет flag
), подключение к БД, инстанцирование объекта, который будет заниматься записью файлов, и вызов метода, который будет выполнять всю остальную работу. Но эти строки не позволяют получить ровно 100% покрытия.
В тестировании Go есть такое понятие, как "Example functions". Это тестовые функции, которые сравнивают вывод с тем, что описан в комментарии внутри такой функции. Примеры таких тестов можно найти в исходном коде пакетов go. Если такие файлы не содержат тестов и бенчмарков, то именуются они с префиксом example_
и оканчиваются на _test.go
. Имя каждой такой тестовой функции должно начинаться с Example
. На этом я и написал тест для объекта, который занимается записью sql в файл, заменив реальную запись в файл на мок, из которого можно достать содержимое и вывести. Этот вывод и сравнивается с эталоном. Удобно, не нужно писать руками сравнение, да и несколько строк удобно писать в комментарии. Но когда дело дошло до теста на объект, который записывает данные в csv-файл, возникли трудности. По RFC4180 строки в CSV должны отделяться CRLF, а go fmt
заменяет все строки на LF, что приводит к тому, что эталон из комментария не совпадает с актуальным выводом из-за разных разделителей строк. Пришлось для этого объекта писать обычный тест, при этом ещё и файл переименовывать, убрав example_
из него.
Остался вопрос, если файл, допустим, query.go
тестируется и по Example и по обычным тестам, должно ли быть два файла example_query_test.go
и query_test.go
? Здесь, например, есть только один example_test.go
. Использовать поиск по "go test example" то ещё развлечение.
Писать тесты в Go я учился по руководствам, которые выдаёт Google по запросу "go writing tests". Большинство из тех, которые мне попадались (1, 2, 3, 4), предлагают сравнивать полученный результат с ожидаемым конструкцией вида
if v != 1.5 {
t.Error("Expected 1.5, got ", v)
}
Но когда дело доходит до сравнения типов, привычная конструкция эволюционно перерождается в нагромождение из использования "reflect" или type assertation. Или ещё пример, когда нужно проверить, что в slice или map есть необходимое значение. Код становится громоздким. Так и хочется писать свои вспомогательные функции для теста. Хотя хорошим решением здесь является использовать библиотеку для тестирования. Я нашёл https://github.com/stretchr/testify. Она позволяет делать сравнения одной строкой. Такое решение сокращает объём кода и упрощает чтение и поддержку тестов.
Дробление кода и тестирование
Написание теста на высокоуровневую функцию, которая работает с несколькими объектами, позволяет одним разом существенно поднять значение покрытия кода тестами, потому что в ходе этого теста выполняется много строк кода отдельных объектов. Если ставить себе цель только 100% покрытие, то пропадает мотивация писать unit-тесты на мелкие компоненты системы, потому что это не влияет на значение code coverage.
Кроме того, если в тест-функции не проверять результат, то это тоже не будет влиять на значение code coverage. Можно получить высокое значение покрытия, но при этом не обнаружить серьезные ошибки в работе приложения.
С другой стороны, если у вас есть код с множеством ветвлений, после которых вызывается объемная функция, то покрыть его тестами будет сложно. И здесь у вас появляется стимул этот код улучшить, например, вынести все ветвления в отдельную функцию и написать на нее отдельный тест. Это положительно повлияет на читаемость кода.
Если код имеет сильное зацепление (coupling), то, скорее всего, вы не сможете написать на него тест, а значит, вам придется внести в него изменения, что положительно скажется на качестве кода.
Заключение
До этого проекта мне не приходилось ставить себе цель в 100% покрытия кода тестами. Работоспособное приложение я мог получить за 10 часов разработки, но на достижение 95% покрытия у меня ушло от 20 до 30 часов времени. На небольшом примере я получил представление о том, как значение покрытия кода влияет на его качество, сколько уходит усилий на его поддержку.
Мой вывод заключается в том, что если вы видите у кого-то плашку с высоким значением покрытия кода, то это почти ничего не говорит о том, как хорошо протестировано это приложение. Всё равно нужно смотреть сами тесты. Но если вы сами взяли курс на честные 100%, то это поможет вам написать приложение качественнее.
Подробнее об этом вы можете почитать в следующих материалах и комментариях к ним:
- The tragedy of 100% code coverage, это же на Хабре
- О том, как можно иметь 100% покрытие, но при этом ничего не проверять
Слово «покрытие» использовано около 20 раз. Простите.
Комментарии (7)
liderman
29.07.2018 20:45+1Что бы код был еще и качественным рекомендую использовать gometalinter. На 100% покрытом тестами проекте мне удалось найти пару ошибок с помощью gometalinter
Hixon10
29.07.2018 21:24Привет.
Как вариант, подключаться к настоящему серверу БД, в каждом тесте предзаполнять данными, проводить выборки, очищать. Но это сложное решение, далеко от unit-тестирования и накладывает свои требования на окружение, в том числе на CI-сервере.
Можно посмотреть на www.testcontainers.org
2) После получения 100% тестового покрытия можно смотреть в сторону мутиационного тестирования, улучшая качество тестов.
zitryss
30.07.2018 04:53+1С типами проявилась еще особенность языка Go — переменная типа string не может принимать значение nil, но из БД может прийти как пустая строка, так и NULL. Для решения этой проблемы в пакете database/sql есть решение — использовать специальные struсt, которые хранят в себе значение и признак, NULL это или нет.
Можно также использовать указатель на строчку.
m0nym
Зачем нужно 100%?
Имхо, достаточно покрывать тестами самые неоднозначные места или места, от которых много чего по цепочке может зависеть.
getId Автор
Здесь есть противоположное мнение: habr.com/post/345774/#comment_10608046