Использование TDD для 100% тестирования консольных приложений Golang
Мы создадим консольное приложение-калькулятор с использованием TDD, сосредоточившись на внутрипроцессных тестах, чтобы достичь идеального тестового покрытия и чистой архитектуры.
Практикуя TDD, вы неизбежно столкнетесь с некоторыми сценариями, в которых может быть немного сложно получить хорошее покрытие, особенно когда вы пытаетесь не прибегать к внепроцессным тестам. Как правило, main.go
- один из таких случаев.
При разработке консольных приложений я бы дал две рекомендации:
- Сохраняйте
main.go
минимально возможное количество кода. В конечном итоге это должна быть просто точка входа в ваше приложение. - На самом деле постарайтесь придерживаться процесса TDD: сначала создайте тест, сделайте его успешным и только потом проведите рефакторинг. Я также рекомендовал бы следовать предпосылке приоритета трансформации (TPP), так как это поможет сохранить чистую архитектуру и принудительно управлять изменениями кода с помощью тестов.
Первоначально TDD может показаться медленным, особенно при применении TPP, но как только вы освоите его, преимущества должны начать проявляться.
Все дело в первом тесте!
Мы начнем с непрерывного тестирования, которое поможет нам управлять архитектурой всего приложения. Тест будет сосредоточен на утверждении операции сложения для нашего калькулятора и будет помещен в новый файл с именем main_test.go
.
При написании тестов я обычно начинаю с утверждения. Это прояснит то, что я собираюсь тестировать, поэтому не возникнет путаницы, когда я приступлю к реализации настоящего теста. В этом случае я хочу, чтобы мой первый тест подтвердил, что калькулятор сможет суммировать 1 + 1 и вернуть два.
Этот код пока не может быть скомпилирован, и это абсолютно нормально.
Обратите внимание, что assertThat
будет переменной func. Я лично подхожу к тестам таким образом, чтобы они читались как по-английски: «assert that should sum 1 + 1 and return 2». Это также помогает, когда вам нужно создать разные варианты одного и того же теста, которые нам понадобятся при создании новых операций (например, умножения).
Для проверки main.go
в процессе важны две вещи:
- Перезаписать ввод: в нашем калькуляторе это командная строка, которую пользователи будут использовать для выполнения приложения:
calc 1 + 1
. - Перезаписать вывод: приложение будет использовать стандартный вывод для вывода результатов для пользователей, поэтому его нужно будет перезаписать, чтобы мы могли сделать утверждения против него.
Перезапись ввода
На этом этапе все начинает становиться интересным: как нам преобразовать calc 1 + 1
в то, что приложение фактически получит, не прибегая к внепроцессному вызову? Что ж, оказывается, что golang позволяет изменять os.Args
, поэтому мы можем просто изменить это перед вызовом main()
с чем-то вроде:
os.Args = strings.Split("calc 1 + 1", " ")
Точно так же любой источник ввода, от которого может зависеть ваше консольное приложение, также требует соответствующей обработки. Например, если вы создаете приложение, которое поддерживает os.Stdin
или требует доступа к переменным среды, их нужно будет обрабатывать в main.go
.
Сосредоточьтесь на том, чтобы main.go
оставался единственным местом во всем приложении, имеющим доступ к os.*
ресурсам. Это улучшит тестируемость приложения и позволит каждой части приложения соответствовать соответствующему уровню абстракции.
Перезапись os.Stdout
Реализация os.Stdout
основана на *os.File
. Это не лучший тип для тестирования, поэтому на других уровнях абстракции мы заменим его на io.Writer
. Однако на данный момент у нас нет другого выбора, кроме как использовать временные файлы для перезаписи os.Stdout
.
После перезаписи ввода и вывода мой тест выглядит так:
Для простоты я не обрабатываю ошибки ни при создании временного файла, ни при чтении из него. Если какая-либо из этих операций завершится неудачно, они запаниковали, поэтому не стесняйтесь реализовывать эти утверждения.
Давайте следовать этому зеленому свету!
После того, как тест действительно создан, мы должны создать минимальный объем производственного кода, чтобы он прошел.
Сначала я бы создал main.go
файл только с пустым func main()
, чтобы код компилировался. Затем запустите тест и получите ошибку:
Следуя TPP, пройдите тест, жестко запрограммировав желаемый результат:
Запуск нашего единственного теста сейчас должен пройти успешно. Это ставит нас в очень выгодное положение, с этого момента, что бы ни случилось, мы можем гарантировать, что не нарушим синтаксис командной строки для этой команды, иначе тест не состоится.
Рефакторинг…
Теперь мы можем провести рефакторинг этого кода, заменив зависимости на os.Args
и os.Stdout
. Как упоминалось ранее, os.File
теперь можно заменить на io.Writer
, os.Args
можно заменить простым строковым массивом, и то, и другое значительно упростит наши будущие тесты.
Имеет смысл оставить в main.go
только main
функцию, а в main_test.go
только сквозные тесты для приложения. Мы проведем рефакторинг Run
и поместим его в новый cli.go
файл.
С новыми изменениями мы также должны изменить код в main.go
:
С этого момента мы будем касаться main.go
и main_test.go
только в том случае, если нас вынуждают наши требования. В противном случае мы сосредоточимся на создании тестов в cli.go
и оттуда развернем наши тесты.
CLI, интерфейс командной строки
Теперь, когда у нас есть cli.go
, мы должны создать его копию тестового файла, и при этом создать новый тест, который будет управлять большей частью нашей реализации. Чтобы помочь с этим, просто помните, что cli будет отвечать за все, что связано с обработкой аргументов, выбором операций для выполнения и обработкой ошибок.
Мы можем начать с проверки того, что мы запускаем команду добавления только в том случае, если ошибок не было (например, недопустимый синтаксис, недопустимая операция и т. Д.). Здесь я также добавлю оба теста за один раз, чтобы избежать избыточности, но при разработке делайте это по одному время. :)
Вот как выглядит cli_test.go
:
Заметьте, я прыгнул на несколько ступеней и получил там фабрику, иначе этот пост был бы вдвое длиннее. :) Фабрика будет отвечать за обработку переданных аргументов и принятие решения о том, какая команда является наиболее подходящей на основе args
, на данный момент она будет жестко запрограммирована так, чтобы всегда возвращать additionCommand
:
Мы могли бы заставить начать выполнение некоторых реальных вычислений, создав тесты для additionCommand
:
И при минимальном количестве изменений он позволит пройти все тесты:
Это будет продолжаться некоторое время, так как вы, вероятно, протестируете NewConsole
, чтобы убедиться, что он паникует, когда он получает nil
в качестве входных данных. То, что GetCommand
фактически возвращает правильный тип на основе оператора, переданного в args. Как только все это будет реализовано и все тесты станут зелеными, вы можете также реорганизовать a.value1 +a.value2
из run
func в собственный calc
пакет, в конце концов, это наша логика предметной области и совершенно другой уровень абстракции.
В том, что все? Ждать! А как насчет кодов выхода?
Важнейшей частью консольных приложений являются коды выхода. Однако тестирование кодов выхода может быть довольно сложным, поскольку при выполнении os.Exit(2)
(или log.Fatal
) за кулисами вызывается системный вызов exit
, который немедленно останавливает ваше приложение. Когда это происходит в рамках теста, тест просто терпит неудачу - даже до того, как ваше утверждение будет выполнено.
Ответ здесь - прибегнуть к внепроцессному тестированию. Однако мы хотим убедиться, что тестируем ту же скомпилированную версию, с которой работают другие тесты, поэтому мы не должны делать это с go run
или go test
. Вместо этого мы будем использовать тестовый двоичный файл, который был скомпилирован за кулисами, когда вы выполнили go test
, это позволит синхронизировать все процессы.
Здесь следует отметить несколько моментов:
- Текущее имя исполняемого файла - это то, что мы используем для запуска новой команды. Таким образом мы проверяем тот же двоичный файл, что и другие тесты.
- Будет выполнен новый внепроцессный тест
TestMain_ErrorCodes_Inception
, поэтому мы передаем его в качестве аргумента в файл. TestMain_ErrorCodes_Inception
Тест будет выполняться толькоmain()
, когда получит переменную средыErrorCodes_Args
. Это также будет использоваться для перезаписиos.Args
в этом тесте. Это отлично подходит для тестирования нескольких тестовых сценариев, каждый из которых имеет свою командную строку, что приводит к разным кодам выхода.- Строка
if !ok {
в основном предназначена для того, чтобы гарантировать, что если тест не завершится с кодом ошибки, это не затруднит чтение вашей тестовой ошибки. - Я проверяю только
CombinedOutput
, который содержит как стандартные, так и стандартные ошибки. Я мог бы разделить их и убедиться, что я получаю ошибки только через StdErr. Но я решил проверить это на других тестах наcli_test.go
.
Этот тест приведет к довольно большим изменениям в коде приложения, прежде чем он пройдет. Обратите внимание, что main.go
теперь должен передать и os.StdErr
, и os.Exit
. Причина последнего состоит в том, чтобы гарантировать, что мы можем перезаписать это во время тестирования cli.go
, иначе это потребовало бы еще одного внепроцессного теста и также нарушило бы наш уровень абстракции.
Как тогда это выглядит в конце?
Ниже только код приложения, тесты вместе с полным исходным кодом можно найти в этом репо.
main.go
По-прежнему одиночный лайнер, но теперь он также переходит к cli
стандартной ошибке и методу выхода.
cli.go
В приведенной ниже реализации я бы, вероятно, изменил одну вещь: избегал нарушения Говори, не спрашивай и переносил ответственность за синтаксический анализ аргументов на сами команды, однако я бы сказал, что это будет этап рефакторинга, когда будут выполнены дополнительные операции. реализовано, и необходимость в этом действительно возникла.
calc.go
Наша доменная логика предельно проста, поэтому здесь нет ничего удивительного:
После реализации результат должен быть 100% тестовым покрытием:
Примечание об организации проекта и чистой архитектуре
В конце приложение будет разбито на три раздела:
Каждый раздел имеет свои обязанности и, что наиболее важно, зависимость между ними идет только в одном направлении, а наша логика предметной области (calc) не зависит от зависимостей.
Что теперь?
Как только команда сложения будет полностью реализована, для реализации следующей операции, скажем, умножения, вы должны создать новый сквозной тест в main_test.go
:
assertThat("should multiply 5 * 5 and return 25", "calc 5 * 5", "multiplication total: 25\n")
Он будет помещен под первой строкой кода, который мы написали в начале. Как только тест завершится неудачно, вы должны реализовать некоторый код, чтобы он прошел, а затем создать другие тесты для дальнейшего углубления реализации.
Мне очень понравилась гифка от Рене Френча, которую я использовал в 4 фрагментах кода golang, которые обманут разработчиков C #, поэтому я добавлю ее сюда еще раз. :)