Тестирование вашего приложения Go: начните с правильного пути

Опубликовано: 2022-03-11

Изучая что-то новое, важно иметь свежий взгляд.

Если вы новичок в Go и пришли с таких языков, как JavaScript или Ruby, вы, вероятно, привыкли использовать существующие фреймворки, которые помогают вам имитировать, утверждать и выполнять другие волшебства тестирования.

Теперь искорените идею зависимости от внешних зависимостей или фреймворков! Тестирование было первым препятствием, с которым я столкнулся при изучении этого замечательного языка программирования пару лет назад, когда было гораздо меньше доступных ресурсов.

Теперь я знаю, что успешное тестирование в Go означает путешествие налегке с зависимостями (как и во всем, что связано с Go), минимальное использование внешних библиотек и написание хорошего повторно используемого кода. Эта презентация опыта Блейка Мизерани, связанного со сторонними библиотеками тестирования, — отличное начало для изменения вашего мышления. Вы увидите несколько веских аргументов в пользу использования внешних библиотек и фреймворков вместо того, чтобы делать это «по пути».

Хотите научиться Го? Ознакомьтесь с нашим вводным руководством по Golang.

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

Делайте вещи «на ходу», искореняйте зависимости от внешних фреймворков.
Твитнуть

Тестирование таблиц в Go

Базовая единица тестирования — известная как «модульное тестирование» — может быть любым компонентом программы в ее простейшей форме, которая принимает входные данные и возвращает выходные данные. Давайте взглянем на простую функцию, для которой мы хотели бы написать тесты. Он далеко не идеален или завершен, но достаточно хорош для демонстрационных целей:

avg.go

 func Avg(nos ...int) int { sum := 0 for _, n := range nos { sum += n } if sum == 0 { return 0 } return sum / len(nos) }

Вышеупомянутая функция func Avg(nos ...int) возвращает либо ноль, либо целое среднее из ряда чисел, которые ей заданы. Теперь давайте напишем для него тест.

В Go считается лучшей практикой называть тестовый файл тем же именем, что и файл, содержащий тестируемый код, с добавленным суффиксом _test . Например, приведенный выше код находится в файле с именем avg.go , поэтому наш тестовый файл будет называться avg_test.go .

Обратите внимание, что эти примеры являются только выдержками из реальных файлов, так как определение пакета и импорт были опущены для простоты.

Вот тест для функции Avg :

avg__test.go

 func TestAvg(t *testing.T) { for _, tt := range []struct { Nos []int Result int }{ {Nos: []int{2, 4}, Result: 3}, {Nos: []int{1, 2, 5}, Result: 2}, {Nos: []int{1}, Result: 1}, {Nos: []int{}, Result: 0}, {Nos: []int{2, -2}, Result: 0}, } { if avg := Average(tt.Nos...); avg != tt.Result { t.Fatalf("expected average of %v to be %d, got %d\n", tt.Nos, tt.Result, avg) } } }

Есть несколько моментов, на которые стоит обратить внимание при определении функции:

  • Во-первых, префикс «Тест» в имени тестовой функции. Это необходимо для того, чтобы инструмент воспринял его как действительный тест.
  • Последняя часть имени функции обычно является именем тестируемой функции или метода, в данном случае Avg .
  • Нам также необходимо передать структуру тестирования под названием testing.T , которая позволяет контролировать ход выполнения теста. Для получения более подробной информации об этом API посетите страницу документации.

Теперь поговорим о форме, в которой написан пример. Набор тестов (серия тестов) запускается через функцию Avg() , и каждый тест содержит определенные входные данные и ожидаемые выходные данные. В нашем случае каждый тест отправляет часть целых чисел ( Nos ) и ожидает определенное возвращаемое значение ( Result ).

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

Насмешка над интерфейсом Golang

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

Интерфейс — это именованный набор методов, а также переменный тип.

Давайте возьмем воображаемый сценарий, в котором нам нужно прочитать первые N байтов из io.Reader и вернуть их в виде строки. Это будет выглядеть примерно так:

readn.go

 // readN reads at most n bytes from r and returns them as a string. func readN(r io.Reader, n int) (string, error) { buf := make([]byte, n) m, err := r.Read(buf) if err != nil { return "", err } return string(buf[:m]), nil }

Очевидно, главное проверить, что функция readN при различных входных данных возвращает правильный результат. Это можно сделать с помощью табличного тестирования. Но есть два других нетривиальных аспекта, которые мы должны рассмотреть, а именно:

  • r.Read вызывается с буфером размера n.
  • r.Read возвращает ошибку, если она выдается.

Чтобы узнать размер буфера, который передается в r.Read , а также взять под контроль ошибку, которую он возвращает, нам нужно имитировать r , передаваемый в readN . Если мы посмотрим документацию Go по типу Reader, то увидим, как выглядит io.Reader :

 type Reader interface { Read(p []byte) (n int, err error) }

Это кажется довольно легким. Все, что нам нужно сделать, чтобы удовлетворить io.Reader , это иметь собственный метод Read для нашего макета. Итак, наш ReaderMock может быть следующим:

 type ReaderMock struct { ReadMock func([]byte) (int, error) } func (m ReaderMock) Read(p []byte) (int, error) { return m.ReadMock(p) }

Давайте немного проанализируем приведенный выше код. Любой экземпляр ReaderMock явно удовлетворяет интерфейсу io.Reader , поскольку реализует необходимый метод Read . Наш мокап также содержит поле ReadMock , позволяющее нам установить точное поведение имитируемого метода, что позволяет нам очень легко динамически создавать экземпляры всего, что нам нужно.

Отличный трюк с освобождением памяти для обеспечения того, чтобы интерфейс был удовлетворен во время выполнения, — это вставить в наш код следующее:

 var _ io.Reader = (*MockReader)(nil)

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

Двигаясь дальше, давайте напишем наш первый тест, в котором r.Read вызывается с буфером размера n . Для этого мы используем наш ReaderMock следующим образом:

 func TestReadN_bufSize(t *testing.T) { total := 0 mr := &MockReader{func(b []byte) (int, error) { total = len(b) return 0, nil }} readN(mr, 5) if total != 5 { t.Fatalf("expected 5, got %d", total) } }

Как вы можете видеть выше, мы определили поведение функции Read нашего «поддельного» io.Reader с помощью переменной области видимости, которую впоследствии можно использовать для подтверждения достоверности нашего теста. Достаточно легко.

Давайте посмотрим на второй сценарий, который нам нужно протестировать, который требует от нас имитировать Read , чтобы вернуть ошибку:

 func TestReadN_error(t *testing.T) { expect := errors.New("some non-nil error") mr := &MockReader{func(b []byte) (int, error) { return 0, expect }} _, err := readN(mr, 5) if err != expect { t.Fatal("expected error") } }

В приведенном выше тестировании любой вызов mr.Read (наш фиктивный Reader) вернет определенную ошибку, поэтому можно с уверенностью предположить, что правильное функционирование readN будет делать то же самое.

Насмешка над функциями с помощью Go

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

Рассмотрим этот простой оператор if ниже, который регистрирует выходные данные в зависимости от значения n :

 func printSize(n int) { if n < 10 { log.Println("SMALL") } else { log.Println("LARGE") } }

В приведенном выше примере мы предполагаем нелепый сценарий, в котором мы специально проверяем, что log.Println вызывается с правильными значениями. Чтобы мы могли смоделировать эту функцию, мы должны сначала обернуть ее внутри нашей собственной:

 var show = func(v ...interface{}) { log.Println(v...) }

Объявление функции таким образом — как переменной — позволяет нам перезаписывать ее в наших тестах и ​​назначать ей любое поведение, которое мы хотим. Неявно строки, относящиеся к log.Println , заменяются на show , поэтому наша программа становится такой:

 func printSize(n int) { if n < 10 { show("SMALL") } else { show("LARGE") } }

Теперь мы можем протестировать:

 func TestPrintSize(t *testing.T) { var got string oldShow := show show = func(v ...interface{}) { if len(v) != 1 { t.Fatalf("expected show to be called with 1 param, got %d", len(v)) } var ok bool got, ok = v[0].(string) if !ok { t.Fatal("expected show to be called with a string") } } for _, tt := range []struct{ N int Out string }{ {2, "SMALL"}, {3, "SMALL"}, {9, "SMALL"}, {10, "LARGE"}, {11, "LARGE"}, {100, "LARGE"}, } { got = "" printSize(tt.N) if got != tt.Out { t.Fatalf("on %d, expected '%s', got '%s'\n", tt.N, tt.Out, got) } } // careful though, we must not forget to restore it to its original value // before finishing the test, or it might interfere with other tests in our // suite, giving us unexpected and hard to trace behavior. show = oldShow }

Наш вывод не должен быть «фиктивным log.Println », но в тех очень редких сценариях, когда нам действительно нужно имитировать функцию уровня пакета по законным причинам, единственный способ сделать это (насколько мне известно) это объявив его как переменную уровня пакета, чтобы мы могли контролировать его значение.

Однако, если нам когда-нибудь понадобится имитировать такие вещи, как log.Println , можно написать гораздо более элегантное решение, если мы будем использовать собственный регистратор.

Тесты рендеринга шаблонов Go

Другим довольно распространенным сценарием является проверка того, что выходные данные отображаемого шаблона соответствуют ожиданиям. Давайте рассмотрим запрос GET к http://localhost:3999/welcome?name=Frank , который возвращает следующее тело:

 <html> <head><title>Welcome page</title></head> <body> <h1 class="header-name"> Welcome <span class="name">Frank</span>! </h1> </body> </html>

В случае, если это еще не было достаточно очевидно, это не совпадение, что name параметра запроса совпадает с содержимым span классифицированного как «имя». В этом случае очевидным тестом будет проверка того, что это происходит правильно каждый раз для нескольких выходных данных. Я обнаружил, что библиотека GoQuery очень полезна здесь.

GoQuery использует API-интерфейс, подобный jQuery, для запроса структуры HTML, что необходимо для проверки достоверности разметки, выводимой вашими программами.

Теперь мы можем написать наш тест следующим образом:

welcome__test.go

 func TestWelcome_name(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { t.Fatal(err) } if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" { t.Fatalf("expected markup to contain 'Frank', got '%s'", v) } }

Прежде чем продолжить, мы проверяем, что код ответа был 200/OK.

Я считаю, что не слишком надуманно предположить, что остальная часть приведенного выше фрагмента кода не требует пояснений: мы извлекаем URL-адрес с помощью пакета http и создаем новый документ, совместимый с goquery, из ответа, который мы затем используем для запроса DOM, который был возвращен. Мы проверяем, что span.name внутри h1.header-name инкапсулирует текст «Фрэнк».

Тестирование JSON API

Go часто используется для написания каких-либо API-интерфейсов, поэтому, что не менее важно, давайте рассмотрим некоторые высокоуровневые способы тестирования API-интерфейсов JSON.

Учтите, что конечная точка ранее возвращала JSON вместо HTML, поэтому из http://localhost:3999/welcome.json?name=Frank мы ожидаем, что тело ответа будет выглядеть примерно так:

 {"Salutation": "Hello Frank!"}

Утверждение ответов JSON, как можно было уже догадаться, мало чем отличается от утверждения ответов шаблона, за исключением того, что нам не нужны никакие внешние библиотеки или зависимости. Стандартных библиотек Go достаточно. Вот наш тест, подтверждающий, что для заданных параметров возвращается правильный JSON:

welcome__test.go

 func TestWelcome_name_JSON(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome.json?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } var dst struct{ Salutation string } if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil { t.Fatal(err) } if dst.Salutation != "Hello Frank!" { t.Fatalf("expected 'Hello Frank!', got '%s'", dst.Salutation) } }

Если будет возвращено что-либо, кроме структуры, которую мы декодируем, json.NewDecoder вместо этого вернет ошибку, и тест завершится неудачей. Считая, что ответ успешно декодируется относительно структуры, мы проверяем, что содержимое поля соответствует ожидаемому — в нашем случае «Привет, Фрэнк!».

Установка и демонтаж

Тестировать с Go легко, но есть одна проблема как с тестом JSON выше, так и с тестом рендеринга шаблона до него. Оба они предполагают, что сервер работает, и это создает ненадежную зависимость. Кроме того, не стоит идти против «живого» сервера.

Никогда не рекомендуется тестировать «живые» данные на «живом» рабочем сервере; запускайте локальные или разрабатываемые копии, чтобы не нанести ущерб, если что-то пойдет не так.

К счастью, Go предлагает пакет httptest для создания тестовых серверов. Тесты запускают свой собственный отдельный сервер, независимый от нашего основного, поэтому тестирование не будет мешать работе.

В этих случаях идеально создать общие функции setup и teardown , которые будут вызываться всеми тестами, требующими работающего сервера. Следуя этому новому, более безопасному шаблону, наши тесты в конечном итоге будут выглядеть примерно так:

 func setup() *httptest.Server { return httptest.NewServer(app.Handler()) } func teardown(s *httptest.Server) { s.Close() } func TestWelcome_name(t *testing.T) { srv := setup() url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL) resp, err := http.Get(url) // verify errors & run assertions as usual teardown(srv) }

Обратите внимание на app.Handler() . Это рекомендуемая функция, которая возвращает http.Handler приложения, который может создавать экземпляр рабочего сервера или тестового сервера.

Заключение

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

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

Я надеюсь, что эта статья была вам полезна, и вы можете оставить комментарий, если знаете какие-либо другие приемы тестирования.