image


В этом посте я расскажу о том, как я писал консольную программу на языке 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%, то это поможет вам написать приложение качественнее.


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



Спойлер

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

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


  1. m0nym
    29.07.2018 16:44
    -1

    Зачем нужно 100%?

    Имхо, достаточно покрывать тестами самые неоднозначные места или места, от которых много чего по цепочке может зависеть.


    1. getId Автор
      29.07.2018 16:52

      Здесь есть противоположное мнение: habr.com/post/345774/#comment_10608046


  1. lucius
    29.07.2018 17:38
    +3

    А как насчёт 146%?


  1. liderman
    29.07.2018 20:45
    +1

    Что бы код был еще и качественным рекомендую использовать gometalinter. На 100% покрытом тестами проекте мне удалось найти пару ошибок с помощью gometalinter


  1. Hixon10
    29.07.2018 21:24

    Привет.

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


    Можно посмотреть на www.testcontainers.org

    2) После получения 100% тестового покрытия можно смотреть в сторону мутиационного тестирования, улучшая качество тестов.


  1. zitryss
    30.07.2018 04:53
    +1

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

    Можно также использовать указатель на строчку.


    1. ufm
      30.07.2018 14:24

      И не только на строку, а на любой тип, который может оказаться NULL в базе.