Alex Edwards Let's Go часть 4
Categories:
Тестирование
И вот мы наконец-то подошли к теме тестирования.
Как и структурирование и организация кода приложения, нет единственного “правильного” способа структурировать и организовать тесты в Go. Но есть некоторые соглашения, шаблоны и лучшие практики, которые вы можете соблюдать.
В этом разделе мы добавим тесты для части кода нашего приложения с целью продемонстрировать общий синтаксис для создания тестов и проиллюстрировать некоторые шаблоны, которые можно повторно использовать в широком спектре приложений.
Вы узнаете:
- Как создавать и запускать табличные юнит-тесты и подтесты в Go.
- Как тестировать HTTP-обработчики и middleware.
- Как проводить сквозное тестирование маршрутов веб-приложения, middleware и обработчиков.
- Как создавать мок-объекты моделей базы данных и использовать их в юнит-тестах.
- Шаблон для тестирования отправки HTML-форм с CSRF-защитой.
- Как использовать тестовый экземпляр MySQL для интеграционного тестирования.
- Как легко рассчитывать и профилировать покрытие кода тестами.
Юнит-тестирование и подтесты
В этой главе мы создадим юнит-тест, чтобы убедиться, что наша функция humanDate()
(которую мы создали в главе о пользовательских функциях шаблонизации) выводит значения типа time.Time
в точно таком формате, как нам нужно.
Если вы не помните, функция humanDate()
выглядит так:
Файл: cmd/web/templates.go
package main
...
func humanDate(t time.Time) string {
return t.UTC().Format("02 Jan 2006 at 15:04")
}
...
Создание юнит-теста
Давайте сразу же создадим юнит-тест для этой функции.
В Go общепринятой практикой является создание тестов в файлах *_test.go, которые находятся непосредственно рядом с кодом, который вы тестируете. Итак, в этом случае первым делом мы создадим новый файл cmd/web/templates_test.go для хранения теста:
touch cmd/web/templates_test.go
Файл: cmd/web/templates_test.go
package main
import (
"testing"
"time"
)
func TestHumanDate(t *testing.T) {
// Initialize a new time.Time object and pass it to the humanDate function.
tm := time.Date(2022, 3, 17, 10, 15, 0, 0, time.UTC)
hd := humanDate(tm)
// Check that the output from the humanDate function is in the format we
// expect. If it isn't what we expect, use the t.Errorf() function to
// indicate that the test has failed and log the expected and actual
// values.
if hd != "17 Mar 2022 at 10:15" {
t.Errorf("got %q; want %q", hd, "17 Mar 2022 at 10:15")
}
}
Этот шаблон является базовым, который вы будете использовать почти для всех тестов, которые пишете на Go.
Важно запомнить:
-
Тест является обычным кодом на Go, который вызывает функцию humanDate() и проверяет, что результат соответствует ожидаемому.
-
Ваши юнит-тесты содержатся в обычной функции Go с сигнатурой
func(*testing.T)
. -
Чтобы быть действительным юнит-тестом, имя этой функции должно начинаться с слова Test. Обычно за ним следует имя функции, метода или типа, который вы тестируете, чтобы сделать очевидным, что тестируется.
-
Вы можете использовать функцию
t.Errorf()
, чтобы пометить тест как не прошедший и зарегистрировать описательное сообщение о сбое. Важно отметить, что вызовt.Errorf()
не останавливает выполнение вашего теста — после вызова Go продолжит выполнять любой оставшийся тестовый код как обычно.
Давайте попробуем это. Сохраните файл, затем используйте команду go test
, чтобы запустить все тесты в нашем пакете cmd/web:
$ go test ./cmd/web
ok snippetbox.igorra.net/cmd/web 0.002s
Итак, это хорошо. ok в этом выводе указывает, что все тесты в пакете (пока только наш тест TestHumanDate()) прошли без проблем.
Если вы хотите получить больше информации, вы можете увидеть, какие тесты выполняются, используя флаг -v
, чтобы получить подробный вывод:
$ go test -v ./cmd/web
=== RUN TestHumanDate
--- PASS: TestHumanDate (0.00s)
PASS
ok snippetbox.igorra.net/cmd/web 0.002s
Табличные тесты
Теперь давайте расширим нашу функцию TestHumanDate()
, чтобы охватить некоторые дополнительные тестовые случаи. В частности, мы обновим ее, чтобы также проверить, что:
-
Если входные данные для
humanDate()
являются нулевым временем, то она возвращает пустую строку “”. -
Вывод функции
humanDate()
всегда использует часовой пояс UTC.
В Go идиоматическим способом запуска нескольких тестовых случаев является использование табличных тестов.
По сути, идея табличных тестов заключается в создании «таблицы» тестовых случаев, содержащих входные данные и ожидаемые выходные данные, а затем в итерации по ним, запуская каждый тестовый случай в подтесте. Есть несколько способов настроить это, но распространенным подходом является определение тестовых случаев в срезе анонимных структур.
Я продемонстрирую: Файл: cmd/web/templates_test.go
package main
import (
"testing"
"time"
)
func TestHumanDate(t *testing.T) {
// Создаем срез анонимных структур, содержащих имя тестового случая,
// входные данные для нашей функции humanDate() (поле tm) и ожидаемый вывод
// (поле want).
tests := []struct {
name string
tm time.Time
want string
}{
{
name: "UTC",
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.UTC),
want: "17 Mar 2022 at 10:15",
},
{
name: "Empty",
tm: time.Time{},
want: "",
},
{
name: "CET",
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
want: "17 Mar 2022 at 09:15",
},
}
// Итерируем по тестовым случаям.
for _, tt := range tests {
// Используем функцию t.Run(), чтобы запустить подтест для каждого тестового случая.
// Первый параметр — имя теста (которое используется для идентификации подтеста в любом выводе журнала),
// а второй параметр — анонимная функция, содержащая фактический тест для каждого случая.
t.Run(tt.name, func(t *testing.T) {
hd := humanDate(tt.tm)
if hd != tt.want {
t.Errorf("got %q; want %q", hd, tt.want)
}
})
}
}
Примечание: В третьем тестовом случае мы используем CET (Центральноевропейское время) в качестве часового пояса, который на один час опережает UTC. Итак, мы хотим, чтобы вывод humanDate()
(в UTC) был “17 Mar 2022 at 09:15”, а не “17 Mar 2022 at 10:15”.
Хорошо, давайте запустим это и посмотрим, что произойдет:
$ go test -v ./cmd/web
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
=== RUN TestHumanDate/Empty
templates_test.go:44: got "01 Jan 0001 at 00:00"; want ""
=== RUN TestHumanDate/CET
templates_test.go:44: got "17 Mar 2022 at 10:15"; want "17 Mar 2022 at 09:15"
--- FAIL: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
--- FAIL: TestHumanDate/Empty (0.00s)
--- FAIL: TestHumanDate/CET (0.00s)
FAIL
FAIL snippetbox.alexedwards.net/cmd/web 0.003s
FAIL
Итак, здесь мы можем видеть индивидуальный вывод для каждого из наших подтестов. Как вы, возможно, догадались, наш первый тестовый случай прошел, но Empty и CET-тесты оба не прошли. Заметим, как для неудавшихся тестовых случаев мы получаем соответствующее сообщение об ошибке и имя файла и номер строки в выводе?
Давайте вернемся к нашей функции humanDate()
и обновим ее, чтобы исправить эти две проблемы:
Файл: cmd/web/templates.go
package main
...
func humanDate(t time.Time) string {
// Возвращаем пустую строку, если время имеет нулевое значение.
if t.IsZero() {
return ""
}
// Преобразуем время в UTC, прежде чем форматировать его.
return t.UTC().Format("02 Jan 2006 at 15:04")
}
...
И когда вы повторно запустите тесты,
$ go test -v ./cmd/web
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
=== RUN TestHumanDate/Empty
=== RUN TestHumanDate/CET
--- PASS: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
--- PASS: TestHumanDate/Empty (0.00s)
--- PASS: TestHumanDate/CET (0.00s)
PASS
ok snippetbox.alexedwards.net/cmd/web 0.003s
Вспомогательные функции для тестовых утверждений
Как я упоминал ранее в книге, в течение следующих нескольких глав мы будем писать много тестовых утверждений, которые являются вариацией этого шаблона:
if actualValue != expectedValue {
t.Errorf("got %v; want %v", actualValue, expectedValue)
}
Давайте быстро абстрагируем этот код в вспомогательную функцию.
Если вы следите за ходом событий, создайте новый пакет internal/assert
:
$ mkdir internal/assert
$ touch internal/assert/assert.go
И затем добавьте следующий код: Файл: internal/assert/assert.go
package assert
import (
"testing"
)
func Equal[T comparable](t *testing.T, actual, expected T) {
t.Helper()
if actual != expected {
t.Errorf("got: %v; want: %v", actual, expected)
}
}
Заметим, как мы настроили это так, что Equal()
является универсальной функцией? Это означает, что мы сможем использовать ее независимо от типа фактических и ожидаемых значений. Пока фактические и ожидаемые значения имеют один и тот же тип (например, они оба являются строковыми значениями или оба являются целыми числами), наш тестовый код должен компилироваться и работать нормально, когда мы вызываем Equal()
.
Примечание
Функция t.Helper()
, которую мы используем в приведенном выше коде, указывает Go-тест, что наша функция Equal()
является тестовым помощником. Это означает, что когда t.Errorf()
вызывается из нашей функции Equal()
, Go-тест будет сообщать имя файла и номер строки кода, который вызвал нашу функцию Equal()
, в выводе.
Без t.Helper()
вывод теста будет указывать на строку внутри функции Equal()
, где была вызвана t.Errorf()
, а не на строку в фактическом тестовом коде, где была вызвана Equal()
. Это может затруднить отладку и понимание, что именно пошло не так.
Используя t.Helper()
, мы можем получить более точную и полезную информацию об ошибках в наших тестах.
package main
import (
"testing"
"time"
"snippetbox.alexedwards.net/internal/assert" // New import
)
func TestHumanDate(t *testing.T) {
tests := []struct {
name string
tm time.Time
want string
}{
{
name: "UTC",
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.UTC),
want: "17 Mar 2022 at 10:15",
},
{
name: "Empty",
tm: time.Time{},
want: "",
},
{
name: "CET",
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
want: "17 Mar 2022 at 09:15",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hd := humanDate(tt.tm)
// Use the new assert.Equal() helper to compare the expected and
// actual values.
assert.Equal(t, hd, tt.want)
})
}
}
Дополнительная информация
Субтесты без таблицы тестовых случаев
Важно отметить, что вам не нужно использовать субтесты в сочетании с табличными тестами (как мы делали до сих пор в этой главе). Полностью допустимо выполнять субтесты, вызывая t.Run()
последовательно в ваших тестовых функциях, подобно этому:
func TestExample(t *testing.T) {
t.Run("Example sub-test 1", func(t *testing.T) {
// Выполняем один тест.
})
t.Run("Example sub-test 2", func(t *testing.T) {
// Выполняем другой тест.
})
t.Run("Example sub-test 3", func(t *testing.T) {
// И еще один...
})
}
Этот подход может быть полезен, когда у вас есть несколько независимых тестов, которые не связаны между собой таблицей тестовых случаев. Однако табличные тесты часто более удобны и кратки, когда у вас есть много тестов с похожими входными и выходными данными.
Тестирование HTTP-обработчиков и middleware
Давайте перейдем к конкретным техникам юнит-тестирования HTTP-обработчиков.
Все обработчики, которые мы написали для этого проекта, пока что довольно сложны для тестирования. Чтобы проще разобраться в теме, начнем с чего-то более простого.
Простой пример: обработчик ping
Откройте файл handlers.go и создайте новый обработчик ping, который:
- Возвращает статус-код 200 OK
- Отправляет тело ответа “OK”
Такой обработчик может использоваться для проверки работоспособности сервера (например, в мониторинге uptime).
package main
...
func ping(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
}
В этой главе мы создадим юнит-тест TestPing, который проверит:
- Что обработчик ping возвращает HTTP-статус 200.
- Что тело ответа содержит строго “OK”.
Запись ответов
Для помощи в тестировании ваших обработчиков HTTP пакет net/http/httptest
в Go предоставляет набор полезных инструментов.
Одним из этих инструментов является тип httptest.ResponseRecorder
. По сути, это реализация http.ResponseWriter
, которая записывает код состояния ответа, заголовки и тело вместо того, чтобы фактически записывать их в соединение HTTP.
Таким образом, простым способом тестирования ваших обработчиков является создание нового объекта httptest.ResponseRecorder
, передача его в функцию обработчика, а затем проверка его после возврата обработчика.
Давайте попробуем сделать именно это, чтобы протестировать функцию обработчика ping
.
Сначала, следуя соглашениям Go, создайте новый файл handlers_test.go
,
$ touch cmd/web/handlers_test.go
Затем добавьте следующий код:
Файл: cmd/web/handlers_test.go
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"snippetbox.igorra.net/internal/assert"
)
func TestPing(t *testing.T) {
// Инициализируйте новый `httptest.ResponseRecorder`.
rr := httptest.NewRecorder()
// Инициализируйте новый фиктивный `http.Request`.
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
// Вызовите функцию обработчика `ping`, передав `httptest.ResponseRecorder` и `http.Request`.
ping(rr, r)
// Вызовите метод `Result()` для `http.ResponseRecorder`, чтобы получить `http.Response`, сгенерированный обработчиком `ping`.
rs := rr.Result()
// Проверьте, что код состояния, записанный обработчиком `ping`, был 200.
assert.Equal(t, rs.StatusCode, http.StatusOK)
// И мы можем проверить, что тело ответа, записанное обработчиком `ping`, равно "OK".
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
bytes.TrimSpace(body)
assert.Equal(t, string(body), "OK")
}
Примечание
В приведенном выше коде мы используем функцию t.Fatal()
в нескольких местах для обработки ситуаций, когда в нашем тестовом коде возникает неожиданная ошибка. При вызове t.Fatal()
пометит тест как неудачный, запишет ошибку и затем полностью остановит выполнение текущего теста (или подтеста).
Обычно следует вызывать t.Fatal()
в ситуациях, когда не имеет смысла продолжать текущий тест — например, при ошибке во время шага настройки или когда неожиданная ошибка от функции стандартной библиотеки Go означает, что вы не можете продолжить тест.
Сохраните файл, затем попробуйте запустить go test
снова с установленным флагом verbose. Вот так:
go test -v ./cmd/web
=== RUN TestPing
--- PASS: TestPing (0.00s)
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
=== RUN TestHumanDate/Empty
=== RUN TestHumanDate/CET
--- PASS: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
--- PASS: TestHumanDate/Empty (0.00s)
--- PASS: TestHumanDate/CET (0.00s)
PASS
ok snippetbox.igorra.net/cmd/web 0.002s
Тестирование middleware
Также возможно использовать тот же общий шаблон для модульного тестирования вашего middleware.
Мы продемонстрируем это, создав новый тест TestSecureHeaders
для middleware secureHeaders()
, который мы создали ранее в книге. В рамках этого теста мы хотим проверить, что:
Middleware `secureHeaders()` устанавливает все ожидаемые заголовки в ответе HTTP.
Middleware `secureHeaders()` корректно вызывает следующий обработчик в цепочке.
Сначала вам нужно создать файл cmd/web/middleware_test.go
, чтобы содержать тест:
$ touch cmd/web/middleware_test.go
Затем добавьте следующий код:
Файл: cmd/web/middleware_test.go
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"snippetbox.alexedwards.net/internal/assert"
)
func TestSecureHeaders(t *testing.T) {
// Инициализируйте новый `httptest.ResponseRecorder` и фиктивный `http.Request`.
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
// Создайте фиктивный обработчик HTTP, который можно передать нашему middleware `secureHeaders`,
// который пишет код состояния 200 и тело ответа "OK".
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
// Передайте фиктивный обработчик HTTP нашему middleware `secureHeaders`. Поскольку
// `secureHeaders` *возвращает* `http.Handler`, мы можем вызвать его метод `ServeHTTP()`,
// передав `http.ResponseRecorder` и фиктивный `http.Request`, чтобы выполнить его.
secureHeaders(next).ServeHTTP(rr, r)
// Вызовите метод `Result()` для `http.ResponseRecorder`, чтобы получить результаты теста.
rs := rr.Result()
// Проверьте, что middleware корректно установил заголовок `Content-Security-Policy` в ответе.
expectedValue := "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com"
assert.Equal(t, rs.Header.Get("Content-Security-Policy"), expectedValue)
// Проверьте, что middleware корректно установил заголовок `Referrer-Policy` в ответе.
expectedValue = "origin-when-cross-origin"
assert.Equal(t, rs.Header.Get("Referrer-Policy"), expectedValue)
// Проверьте, что middleware корректно установил заголовок `X-Content-Type-Options` в ответе.
expectedValue = "nosniff"
assert.Equal(t, rs.Header.Get("X-Content-Type-Options"), expectedValue)
// Проверьте, что middleware корректно установил заголовок `X-Frame-Options` в ответе.
expectedValue = "deny"
assert.Equal(t, rs.Header.Get("X-Frame-Options"), expectedValue)
// Проверьте, что middleware корректно установил заголовок `X-XSS-Protection` в ответе.
expectedValue = "0"
assert.Equal(t, rs.Header.Get("X-XSS-Protection"), expectedValue)
// Проверьте, что middleware корректно вызвал следующий обработчик в цепочке,
// и код состояния ответа и тело ответа соответствуют ожиданиям.
assert.Equal(t, rs.StatusCode, http.StatusOK)
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
bytes.TrimSpace(body)
assert.Equal(t, string(body), "OK")
}
Если вы запустите тесты сейчас, вы должны увидеть, что тест TestSecureHeaders
проходит без каких-либо проблем.
go test -v ./cmd/web
=== RUN TestPing
--- PASS: TestPing (0.00s)
=== RUN TestSecureHeaders
--- PASS: TestSecureHeaders (0.00s)
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
=== RUN TestHumanDate/Empty
=== RUN TestHumanDate/CET
--- PASS: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
--- PASS: TestHumanDate/Empty (0.00s)
--- PASS: TestHumanDate/CET (0.00s)
PASS
ok snippetbox.igorra.net/cmd/web 0.002s
End-to-end testing (Сквозное тестирование)
В предыдущей главе мы обсудили общий шаблон для модульного тестирования ваших обработчиков HTTP в изоляции.
Но — в большинстве случаев — ваши обработчики HTTP не используются в изоляции. Итак, в этой главе мы объясним, как запустить сквозные тесты на вашем веб-приложении, которые охватывают маршрутизацию, middleware и обработчики.
Чтобы проиллюстрировать это, мы адаптируем нашу функцию TestPing
так, чтобы она запускала сквозной тест на нашем коде. В частности, мы хотим, чтобы тест гарантировал, что GET-запрос к /ping
нашему приложению вызывает функцию обработчика ping
и приводит к статусу 200 OK и телу ответа “OK”.
По сути, мы хотим проверить, что наше приложение имеет маршрут, подобный этому:
Метод | Шаблон | Обработчик | Действие |
---|---|---|---|
GET | /ping | ping | Вернуть ответ 200 OK |
Использование httptest.Server
Ключом к сквозному тестированию нашего приложения является функция httptest.NewTLSServer()
, которая создает экземпляр httptest.Server
, которому можно отправлять HTTPS-запросы.
С учетом этого, вернитесь к вашему файлу handlers_test.go
и обновите тест TestPing
так, чтобы он выглядел следующим образом:
Файл: cmd/web/handlers_test.go
package main
import (
"bytes"
"io"
"log" // Новый импорт
"net/http"
"net/http/httptest"
"testing"
"snippetbox.alexedwards.net/internal/assert"
)
func TestPing(t *testing.T) {
// Создайте новый экземпляр нашей структуры приложения. На данный момент это просто
// содержит пару фиктивных логгеров (которые игнорируют все, что пишется в них).
app := &application{
errorLog: log.New(io.Discard, "", 0),
infoLog: log.New(io.Discard, "", 0),
}
// Затем мы используем функцию `httptest.NewTLSServer()`, чтобы создать новый тестовый
// сервер, передав значение, возвращенное нашим методом `app.routes()`, в качестве
// обработчика для сервера. Это запускает HTTPS-сервер, который слушает на
// случайно выбранном порту вашей локальной машины в течение теста.
// Обратите внимание, что мы откладываем вызов `ts.Close()`, чтобы сервер был закрыт
// после завершения теста.
ts := httptest.NewTLSServer(app.routes())
defer ts.Close()
// Адрес сети, на котором тестовый сервер слушает, содержится в поле `ts.URL`.
// Мы можем использовать это вместе с методом `ts.Client().Get()`, чтобы отправить
// GET-запрос к `/ping` тестовому серверу. Это возвращает структуру `http.Response`,
// содержащую ответ.
rs, err := ts.Client().Get(ts.URL + "/ping")
if err != nil {
t.Fatal(err)
}
// Затем мы можем проверить значение кода состояния ответа и тела, используя
// тот же шаблон, что и раньше.
assert.Equal(t, rs.StatusCode, http.StatusOK)
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
bytes.TrimSpace(body)
assert.Equal(t, string(body), "OK")
}
Есть несколько вещей в этом коде, на которые стоит обратить внимание и обсудить.
- Когда мы вызываем
httptest.NewTLSServer()
, чтобы инициализировать тестовый сервер, нам нужно передатьhttp.Handler
в качестве параметра — и этот обработчик вызывается каждый раз, когда тестовый сервер получает HTTPS-запрос. В нашем случае мы передали возвращаемое значение из нашего методаapp.routes()
, что означает, что запрос к тестовому серверу будет использовать все наши реальные маршруты приложения, middleware и обработчики. - Это большое преимущество работы, которую мы проделали ранее в книге, чтобы изолировать всю маршрутизацию нашего приложения в методе
app.routes()
. - Если вы тестируете HTTP-сервер (а не HTTPS), вы должны использовать функцию
httptest.NewServer()
, чтобы создать тестовый сервер. - Метод
ts.Client()
возвращает клиент тестового сервера — который имеет типhttp.Client
— и мы всегда должны использовать этого клиента, чтобы отправлять запросы к тестовому серверу. Возможно настроить клиента, чтобы изменить его поведение, и мы объясним, как это сделать, в конце этой главы. - Вы можете задаться вопросом, почему мы установили поля
errorLog
иinfoLog
нашей структуры приложения, но не все остальные поля. Причина этого в том, что логгеры необходимы middlewarelogRequest
иrecoverPanic
, которые используются нашим приложением на каждом маршруте. Попытка запустить этот тест без установки этих двух зависимостей приведет к панике.
В любом случае, давайте попробуем новый тест:
go test -v ./cmd/web
=== RUN TestPing
handlers_test.go:42: got: 404; want: 200
handlers_test.go:51: got: Not Found
; want: OK
--- FAIL: TestPing (0.00s)
Из тестового вывода мы видим, что ответ на наш запрос GET /ping имеет код состояния 404, а не 200, как мы ожидали. И это потому, что мы еще не зарегистрировали маршрут GET /ping с помощью нашего маршрутизатора. Внесем правку в файл File: cmd/web/routes.go
router.HandlerFunc(http.MethodGet, "/ping", ping)
Итог:
go test -v ./cmd/web
=== RUN TestPing
--- PASS: TestPing (0.00s)
ok snippetbox.igorra.net/cmd/web 0.005s
Использование тестовых помощников
Наш тест TestPing
теперь работает хорошо. Но есть хорошая возможность выделить некоторые части этого кода в функции-помощники, которые мы можем повторно использовать, добавляя больше сквозных тестов к нашему проекту.
Нет жестких и быстрых правил о том, где размещать методы-помощники для тестов. Если помощник используется только в определенном файле *_test.go, то, вероятно, имеет смысл включить его встроенным в этот файл рядом с вашими тестами. С другой стороны, если вы собираетесь использовать помощник в тестах в нескольких пакетах, то вы можете поместить его в повторно используемый пакет, называемый internal/testutils
(или аналогичный), который может быть импортирован вашими тестовыми файлами.
В нашем случае помощники будут использоваться только в нашем пакете cmd/web
, и мы поместим их в новый файл cmd/web/testutils_test.go
.
Если вы следите за ходом, пожалуйста, создайте этот файл сейчас…
$ touch cmd/web/testutils_test.go
Затем добавьте следующий код:
Файл: cmd/web/testutils_test.go
package main
import (
"bytes"
"io"
"log"
"net/http"
"net/http/httptest"
"testing"
)
// Создайте помощник `newTestApplication`, который возвращает экземпляр нашей
// структуры приложения, содержащий фиктивные зависимости.
func newTestApplication(t *testing.T) *application {
return &application{
errorLog: log.New(io.Discard, "", 0),
infoLog: log.New(io.Discard, "", 0),
}
}
// Определите пользовательский тип `testServer`, который включает экземпляр `httptest.Server`.
type testServer struct {
*httptest.Server
}
// Создайте помощник `newTestServer`, который инициализирует и возвращает новый экземпляр
// нашего пользовательского типа `testServer`.
func newTestServer(t *testing.T, h http.Handler) *testServer {
ts := httptest.NewTLSServer(h)
return &testServer{ts}
}
// Реализуйте метод `get()` на нашем пользовательском типе `testServer`. Это делает GET-запрос
// к заданному пути URL, используя клиент тестового сервера, и возвращает код состояния,
// заголовки и тело ответа.
func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, string) {
rs, err := ts.Client().Get(ts.URL + urlPath)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
bytes.TrimSpace(body)
return rs.StatusCode, rs.Header, string(body)
}
По сути, это просто обобщение кода, который мы уже написали в этой главе, чтобы запустить тестовый сервер и отправить GET-запрос к нему.
Давайте вернемся к нашему тесту TestPing
и используем эти новые помощники:
package main
import (
"net/http"
"testing"
"snippetbox.alexedwards.net/internal/assert"
)
func TestPing(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
code, _, body := ts.get(t, "/ping")
assert.Equal(t, code, http.StatusOK)
assert.Equal(t, body, "OK")
}
все сработало
go test -v ./cmd/web
ok snippetbox.igorra.net/cmd/web 0.005s
Cookies and redirections
До сих пор в этой главе мы использовали настройки клиента тестового сервера по умолчанию. Но есть несколько изменений, которые я хотел бы внести, чтобы сделать его более подходящим для тестирования нашего веб-приложения. В частности:
- Мы хотим, чтобы клиент автоматически хранил любые куки, отправленные в ответе HTTPS, чтобы мы могли включить их (если это необходимо) в любые последующие запросы обратно к тестовому серверу. Это пригодится позже в книге, когда нам нужно будет поддерживать куки на нескольких запросах, чтобы протестировать наши меры противодействия CSRF.
- Мы не хотим, чтобы клиент автоматически следовал перенаправлениям. Вместо этого мы хотим, чтобы он возвращал первый ответ HTTPS, отправленный нашим сервером, чтобы мы могли протестировать ответ на этот конкретный запрос.
Изменим testutils_test.go
и обновим функцию newTestServer()
:
Файл: cmd/web/testutils_test.go
package main
import (
"bytes"
"io"
"log"
"net/http"
"net/http/cookiejar" // Новый импорт
"net/http/httptest"
"testing"
)
...
func newTestServer(t *testing.T, h http.Handler) *testServer {
// Инициализируйте тестовый сервер как обычно.
ts := httptest.NewTLSServer(h)
// Инициализируйте новый контейнер для куки.
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
// Добавьте контейнер для куки к клиенту тестового сервера. Любые куки ответа теперь
// будут храниться и отправляться с последующими запросами при использовании этого клиента.
ts.Client().Jar = jar
// Отключите следование за перенаправлениями для клиента тестового сервера, установив
// пользовательскую функцию `CheckRedirect`. Эта функция будет вызвана всякий раз, когда
// клиент получит ответ 3xx, и, всегда возвращая ошибку `http.ErrUseLastResponse`,
// она заставляет клиента немедленно вернуть полученный ответ.
ts.Client().CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
return &testServer{ts}
}
...
Настройка тестирования
Управление запуском тестов
До сих пор в этой книге мы запускали тесты в определенном пакете (пакете cmd/web
) следующим образом:
$ go test ./cmd/web
Но также возможно запустить все тесты в вашем текущем проекте, используя шаблон ./...
. В нашем случае мы можем использовать его для запуска всех тестов в нашем проекте следующим образом:
$ go test ./...
ok snippetbox.alexedwards.net/cmd/web 0.007s
? snippetbox.alexedwards.net/internal/models [no test files]
? snippetbox.alexedwards.net/internal/validator [no test files]
? snippetbox.alexedwards.net/ui [no test files]
Или, идя в другом направлении, можно запустить только определенные тесты, используя флаг -run
. Это позволяет вам передать регулярное выражение — и только тесты с именем, совпадающим с регулярным выражением, будут запущены.
Например, мы могли бы запустить только тест TestPing
следующим образом:
$ go test -v -run="^TestPing$" ./cmd/web/
=== RUN TestPing
--- PASS: TestPing (0.00s)
PASS
ok snippetbox.alexedwards.net/cmd/web 0.008s
Вы также можете использовать флаг -run
, чтобы ограничить тестирование до определенных подтестов, используя формат {regexp}/{sub-test regexp}
. Например, чтобы запустить подтест UTC
нашего теста TestHumanDate
, мы можем сделать это:
$ go test -v -run="^TestHumanDate$/^UTC$" ./cmd/web
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
--- PASS: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
PASS
ok snippetbox.alexedwards.net/cmd/web 0.003s
Кэширование тестов
Если вы запустите один и тот же тест дважды — без внесения каких-либо изменений в пакет, который вы тестируете — то отображается кэшированная версия результата теста (указанная аннотацией (cached)
рядом с именем пакета).
$ go test ./cmd/web
ok snippetbox.alexedwards.net/cmd/web (cached)
В большинстве случаев кэширование результатов тестов действительно полезно (особенно для больших кодовых баз), поскольку оно помогает сократить общее время выполнения тестов. Но если вы хотите принудительно запустить свои тесты в полном объеме (и избежать кэша), вы можете использовать флаг -count=1
:
$ go test -count=1 ./cmd/web
Примечание
Флагcount
используется для указания go test
, сколько раз вы хотите выполнить каждый тест. Это не кэшируемый флаг, что означает, что каждый раз, когда вы его используете, go test
не будет ни читать, ни записывать результаты тестов в кэш. Таким образом, использование count=1
— это своего рода трюк, чтобы избежать кэша без влияния на выполнение тестов.
Кроме того, вы можете очистить кэшированные результаты для всех тестов с помощью команды go clean
:
$ go clean -testcache
Быстрое завершение
Когда мы используем функцию t.Errorf()
, чтобы пометить тест как неудачный, она не вызывает немедленное завершение go test
. Все остальные тесты (и подтесты) будут продолжать выполняться после сбоя.
Если вы предпочитаете завершить тесты сразу после первого сбоя, вы можете использовать флаг -failfast
:
$ go test -failfast ./cmd/web
Важно отметить, что флаг -failfast
останавливает тесты только в пакете, где произошел сбой. Если вы запускаете тесты в нескольких пакетах (например, используя go test ./...
), то тесты в других пакетах будут продолжать выполняться.
Параллельное тестирование
По умолчанию команда go test
выполняет все тесты последовательно, один за другим. Когда у вас небольшое количество тестов (как у нас) и время выполнения очень быстрое, это абсолютно нормально.
Но если у вас сотни или тысячи тестов, общее время выполнения может начать накапливаться до чего-то более существенного. И в этом сценарии вы можете сэкономить себе некоторое время, запустив тесты параллельно.
Вы можете указать, что тест можно запускать одновременно с другими тестами, вызвав функцию t.Parallel()
в начале теста. Например:
func TestPing(t *testing.T) {
t.Parallel()
...
}
Важно отметить, что:
-
Тесты, отмеченные с помощью
t.Parallel()
, будут запускаться параллельно с другими параллельными тестами и только с ними. -
По умолчанию максимальное количество тестов, которые будут запускаться одновременно, равно текущему значению
GOMAXPROCS
. Вы можете переопределить это, установив конкретное значение с помощью флага-parallel
. Например:$ go test -parallel 4 ./...
-
Не все тесты подходят для параллельного выполнения. Например, если у вас есть интеграционный тест, который требует, чтобы таблица базы данных находилась в определенном известном состоянии, то вы не хотели бы запускать его параллельно с другими тестами, которые изменяют ту же таблицу базы данных.
Включение детектора гонок
Команда go test
включает флаг -race
, который активирует детектор гонок Go при запуске тестов.
Если код, который вы тестируете, использует конкурентность, или вы запускаете тесты параллельно, включение этого может быть хорошей идеей, чтобы помочь выявить гонки, существующие в вашем приложении. Вы можете использовать его следующим образом:
$ go test -race ./cmd/web/
Важно отметить, что детектор гонок — это просто инструмент, который выявляет гонки данных, если и когда они обнаруживаются во время выполнения тестов. Он не проводит статический анализ вашего кода, и чистый запуск не гарантирует, что ваш код свободен от гонок.
Включение детектора гонок также увеличит общее время выполнения ваших тестов. Поэтому, если вы запускаете тесты очень часто в рамках рабочего процесса TDD, вы можете предпочесть использовать флаг -race
только во время выполнения тестов перед коммитом.
Мокирование зависимостей
Мокирование зависимостей
Мокирование зависимостей (или “mocking dependencies”) — это техника, используемая в тестировании программного обеспечения, которая позволяет создавать поддельные (мок) версии зависимостей, с которыми взаимодействует тестируемый компонент. Это делается для того, чтобы изолировать тестируемый код от реальных зависимостей, что позволяет:
-
Избежать побочных эффектов: Использование реальных зависимостей может привести к нежелательным изменениям состояния (например, запись в базу данных или отправка сетевых запросов). Моки позволяют избежать этих побочных эффектов.
-
Упростить тестирование: Моки могут быть настроены для возврата предопределенных значений, что упрощает тестирование различных сценариев, включая ошибки и исключительные ситуации.
-
Ускорить тесты: Тесты с моками обычно выполняются быстрее, так как они не зависят от внешних систем, таких как базы данных или API.
-
Улучшить контроль: Моки позволяют контролировать и проверять, как тестируемый код взаимодействует с зависимостями, что помогает выявить ошибки в логике.
В Go для мокирования зависимостей часто используются библиотеки, такие как gomock
или testify
, которые позволяют создавать моки и определять их поведение в тестах.
Новые тесты для нашего обработчика snippetView
и маршрута GET /snippet/view/:id
.
На протяжении всего этого проекта мы внедряли зависимости в наши обработчики через структуру application
, которая сейчас выглядит следующим образом:
type application struct {
errorLog *log.Logger
infoLog *log.Logger
snippets *models.SnippetModel
users *models.UserModel
templateCache map[string]*template.Template
formDecoder *form.Decoder
sessionManager *scs.SessionManager
}
При тестировании иногда имеет смысл мокировать эти зависимости вместо использования точно таких же, как в вашем производственном приложении.
Например, в предыдущей главе мы мокировали зависимости errorLog
и infoLog
логгерами, которые пишут сообщения в io.Discard
, вместо потоков os.Stdout
и os.Stderr
, как мы делаем в нашем производственном приложении:
func newTestApplication(t *testing.T) *application {
return &application{
errorLog: log.New(io.Discard, "", 0),
infoLog: log.New(io.Discard, "", 0),
}
}
Причина мокирования этих и записи в io.Discard
заключается в том, чтобы избежать засорения вывода наших тестов ненужными сообщениями журнала при запуске go test -v
(с включенным режимом verbose).
Примечание: В зависимости от вашего происхождения и опыта программирования, вы можете не считать эти логгеры моками. Вы можете назвать их фейками, заглушками или чем-то совсем другим. Но название не имеет значения — и разные люди называют их по-разному. Главное, что мы используем что-то, что exposes тот же интерфейс, что и производственный объект, для целей тестирования.
Две другие зависимости, которые имеет смысл мокировать, — это модели базы данных models.SnippetModel
и models.UserModel
. Создавая моки этих моделей, можно тестировать поведение наших обработчиков без необходимости настраивать весь тестовый экземпляр базы данных MySQL.
Мокирование моделей базы данных
Создайте новый пакет internal/models/mocks
, содержащий файлы snippets.go
и users.go
для хранения моков моделей базы данных, следующим образом:
$ mkdir internal/models/mocks
$ touch internal/models/mocks/snippets.go
$ touch internal/models/mocks/users.go
Давайте начнем с создания мока для нашей модели models.SnippetModel
. Для этого мы создадим простую структуру, которая реализует те же методы, что и наша производственная модель models.SnippetModel
, но методы будут возвращать фиксированные тестовые данные.
Файл: internal/models/mocks/snippets.go
package mocks
import (
"time"
"snippetbox.alexedwards.net/internal/models"
)
var mockSnippet = &models.Snippet{
ID: 1,
Title: "Старый тихий пруд",
Content: "Старый тихий пруд...",
Created: time.Now(),
Expires: time.Now(),
}
type SnippetModel struct{}
func (m *SnippetModel) Insert(title string, content string, expires int) (int, error) {
return 2, nil
}
func (m *SnippetModel) Get(id int) (*models.Snippet, error) {
switch id {
case 1:
return mockSnippet, nil
default:
return nil, models.ErrNoRecord
}
}
func (m *SnippetModel) Latest() ([]*models.Snippet, error) {
return []*models.Snippet{mockSnippet}, nil
}
Теперь сделаем то же самое для нашей модели models.UserModel
:
Файл: internal/models/mocks/users.go
package mocks
import (
"snippetbox.alexedwards.net/internal/models"
)
type UserModel struct{}
func (m *UserModel) Insert(name, email, password string) error {
switch email {
case "dupe@example.com":
return models.ErrDuplicateEmail
default:
return nil
}
}
func (m *UserModel) Authenticate(email, password string) (int, error) {
if email == "alice@example.com" && password == "pa$$word" {
return 1, nil
}
return 0, models.ErrInvalidCredentials
}
func (m *UserModel) Exists(id int) (bool, error) {
switch id {
case 1:
return true, nil
default:
return false, nil
}
}
Инициализация моков
На следующем этапе нашего проекта давайте вернемся к файлу testutils_test.go
и обновим функцию newTestApplication()
, чтобы она создавала структуру приложения со всеми необходимыми зависимостями для тестирования.
Файл: cmd/web/testutils_test.go
package main
import (
"bytes"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"testing"
"time" // Новый импорт
"snippetbox.alexedwards.net/internal/models/mocks" // Новый импорт
"github.com/alexedwards/scs/v2" // Новый импорт
"github.com/go-playground/form/v4" // Новый импорт
)
func newTestApplication(t *testing.T) *application {
// Создаем экземпляр кеша шаблонов.
templateCache, err := newTemplateCache()
if err != nil {
t.Fatal(err)
}
// И декодер форм.
formDecoder := form.NewDecoder()
// И экземпляр менеджера сессий. Обратите внимание, что мы используем те же настройки, что и
// в производственной среде, за исключением того, что мы *не* устанавливаем Store для менеджера сессий.
// Если Store не установлен, пакет SCS по умолчанию будет использовать временное
// хранилище в памяти, что идеально подходит для тестирования.
sessionManager := scs.New()
sessionManager.Lifetime = 12 * time.Hour
sessionManager.Cookie.Secure = true
return &application{
errorLog: log.New(io.Discard, "", 0),
infoLog: log.New(io.Discard, "", 0),
snippets: &mocks.SnippetModel{}, // Используем мок.
users: &mocks.UserModel{}, // Используем мок.
templateCache: templateCache,
formDecoder: formDecoder,
sessionManager: sessionManager,
}
}
…
Если вы попробуете запустить тесты сейчас, они не скомпилируются и выдадут следующие ошибки:
$ go test ./cmd/web
# snippetbox.alexedwards.net/cmd/web [snippetbox.alexedwards.net/cmd/web.test]
cmd/web/testutils_test.go:40:19: cannot use &mocks.SnippetModel{} (value of type *mocks.SnippetModel) as type *models.SnippetModel in struct literal
cmd/web/testutils_test.go:41:19: cannot use &mocks.UserModel{} (value of type *mocks.UserModel) as type *models.UserModel in struct literal
FAIL snippetbox.alexedwards.net/cmd/web [build failed]
FAIL
Это происходит потому, что наша структура приложения ожидает указатели на экземпляры models.SnippetModel
и models.UserModel
, но мы пытаемся использовать указатели на экземпляры mocks.SnippetModel
и mocks.UserModel
.
Идиоматическое решение этой проблемы — изменить нашу структуру приложения так, чтобы она использовала интерфейсы, которые удовлетворяются как нашими моками, так и производственными моделями базы данных.
Для этого давайте вернемся к файлу internal/models/snippets.go
и создадим новый тип интерфейса SnippetModelInterface
, который описывает методы, которые есть у нашей фактической структуры SnippetModel
.
Файл: internal/models/snippets.go
package models
import (
"database/sql"
"errors"
"time"
)
type SnippetModelInterface interface {
Insert(title string, content string, expires int) (int, error)
Get(id int) (*Snippet, error)
Latest() ([]*Snippet, error)
}
…
Теперь сделаем то же самое для нашей структуры UserModel
:
Файл: internal/models/users.go
package models
import (
"database/sql"
"errors"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
)
type UserModelInterface interface {
Insert(name, email, password string) error
Authenticate(email, password string) (int, error)
Exists(id int) (bool, error)
}
…
Теперь, когда мы определили эти интерфейсы, давайте обновим нашу структуру приложения, чтобы она использовала их вместо конкретных типов SnippetModel
и UserModel
. Вот так:
Файл: cmd/web/main.go
package main
import (
"crypto/tls"
"database/sql"
"flag"
"html/template"
"log"
"net/http"
"os"
"time"
"snippetbox.alexedwards.net/internal/models"
"github.com/alexedwards/scs/mysqlstore"
"github.com/alexedwards/scs/v2"
"github.com/go-playground/form/v4"
_ "github.com/go-sql-driver/mysql"
)
type application struct {
errorLog *log.Logger
infoLog *log.Logger
snippets models.SnippetModelInterface // Используем наш новый интерфейс.
users models.UserModelInterface // Используем наш новый интерфейс.
templateCache map[string]*template.Template
formDecoder *form.Decoder
sessionManager *scs.SessionManager
}
…
Если вы снова попробуете запустить тесты, все должно работать корректно.
go test -v ./cmd/web
ok snippetbox.igorra.net/cmd/web 0.005s
Мы обновили структуру приложения так, что вместо того, чтобы поля snippets
и users
имели конкретные типы *models.SnippetModel
и *models.UserModel
, теперь они являются интерфейсами.
Пока объект имеет необходимые методы для удовлетворения интерфейса, мы можем использовать его в нашей структуре приложения. Как наши «реальные» модели базы данных (например, models.SnippetModel
), так и моковые модели базы данных (например, mocks.SnippetModel
) удовлетворяют этим интерфейсам, поэтому мы теперь можем использовать их взаимозаменяемо.
Тестирование обработчика snippetView
Перейдем к написанию сквозного теста для нашего обработчика snippetView
, который использует эти мокированные зависимости.
В рамках этого теста код в нашем обработчике snippetView
будет вызывать метод mock.SnippetModel.Get()
. Напоминаем, что этот мокированный метод модели возвращает models.ErrNoRecord
, если ID сниппета не равен 1 — в противном случае он вернет следующий мок-сниппет:
var mockSnippet = &models.Snippet{
ID: 1,
Title: "Старый тихий пруд",
Content: "Старый тихий пруд...",
Created: time.Now(),
Expires: time.Now(),
}
Таким образом, мы хотим протестировать следующее:
- Для запроса
GET /snippet/view/1
мы получаем ответ 200 OK с соответствующим мок-сниппетом, содержащимся в теле HTML-ответа. - Для всех остальных запросов к
GET /snippet/view/*
мы должны получить ответ 404 Not Found.
Для первой части теста мы хотим проверить, что тело ответа содержит определенное содержимое, а не является точно равным ему. Давайте быстро добавим новую функцию StringContains()
в наш пакет assert
, чтобы помочь с этим:
Файл: assert/assert.go
package assert
import (
"strings" // Новый импорт
"testing"
)
...
func StringContains(t *testing.T, actual, expectedSubstring string) {
t.Helper()
if !strings.Contains(actual, expectedSubstring) {
t.Errorf("got: %q; expected to contain: %q", actual, expectedSubstring)
}
}
Затем откройте файл cmd/web/handlers_test.go
и создайте новый тест TestSnippetView
, как показано ниже:
Файл: cmd/web/handlers_test.go
package main
...
func TestSnippetView(t *testing.T) {
// Создаем новый экземпляр нашей структуры приложения, который использует мокированные зависимости.
app := newTestApplication(t)
// Устанавливаем новый тестовый сервер для выполнения сквозных тестов.
ts := newTestServer(t, app.routes())
defer ts.Close()
// Настраиваем тесты с использованием таблицы для проверки ответов, отправляемых нашим приложением для различных URL.
tests := []struct {
name string
urlPath string
wantCode int
wantBody string
}{
{
name: "Valid ID",
urlPath: "/snippet/view/1",
wantCode: http.StatusOK,
wantBody: "Старый тихий пруд...",
},
{
name: "Non-existent ID",
urlPath: "/snippet/view/2",
wantCode: http.StatusNotFound,
},
{
name: "Negative ID",
urlPath: "/snippet/view/-1",
wantCode: http.StatusNotFound,
},
{
name: "Decimal ID",
urlPath: "/snippet/view/1.23",
wantCode: http.StatusNotFound,
},
{
name: "String ID",
urlPath: "/snippet/view/foo",
wantCode: http.StatusNotFound,
},
{
name: "Empty ID",
urlPath: "/snippet/view/",
wantCode: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, _, body := ts.get(t, tt.urlPath)
assert.Equal(t, code, tt.wantCode)
if tt.wantBody != "" {
assert.StringContains(t, body, tt.wantBody)
}
})
}
}
Когда вы снова запустите тесты, все должно пройти успешно, и вы увидите вывод, включая новые тесты TestSnippetView
, который будет выглядеть примерно так:
--- PASS: TestSnippetView (0.01s)
--- PASS: TestSnippetView/Valid_ID (0.00s)
--- PASS: TestSnippetView/Non-existent_ID (0.00s)
--- PASS: TestSnippetView/Negative_ID (0.00s)
--- PASS: TestSnippetView/Decimal_ID (0.00s)
--- PASS: TestSnippetView/String_ID (0.00s)
--- PASS: TestSnippetView/Empty_ID (0.00s)
Обратите внимание, как названия субтестов были канонизированы? Все пробелы в названии подтеста были заменены символом подчеркивания (и все непечатаемые символы также будут экранированы) в выходных данных теста.
Тестирование HTML-форм
В этой главе мы добавим сквозной тест для маршрута POST /user/signup
, который обрабатывается нашим обработчиком userSignupPost
.
Тестирование этого маршрута немного усложняется из-за проверки на наличие CSRF-токена, которую выполняет наше приложение. Любой запрос к POST /user/signup
всегда будет получать ответ 400 Bad Request, если запрос не содержит действительный CSRF-токен и куки. Чтобы обойти это, нам нужно эмулировать рабочий процесс реального пользователя в рамках нашего теста, следующим образом:
- Сделать запрос
GET /user/signup
. Это вернет ответ, который содержит CSRF-куку в заголовках ответа и CSRF-токен для страницы регистрации в теле ответа. - Извлечь CSRF-токен из HTML-тела ответа.
- Сделать запрос
POST /user/signup
, используя тот жеhttp.Client
, который мы использовали на шаге 1 (чтобы он автоматически передал CSRF-куку с POST-запросом) и включая CSRF-токен вместе с другими данными POST, которые мы хотим протестировать.
Давайте начнем с добавления новой вспомогательной функции в наш файл cmd/web/testutils_test.go
для извлечения CSRF-токена (если он существует) из HTML-тела ответа:
Файл: cmd/web/testutils_test.go
package main
import (
"bytes"
"html" // Новый импорт
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"regexp" // Новый импорт
"testing"
"time"
"snippetbox.alexedwards.net/internal/models/mocks"
"github.com/alexedwards/scs/v2"
"github.com/go-playground/form/v4"
)
// Определяем регулярное выражение, которое захватывает значение CSRF-токена из
// HTML для нашей страницы регистрации пользователя.
var csrfTokenRX = regexp.MustCompile(`<input type='hidden' name='csrf_token' value='(.+)'>`)
func extractCSRFToken(t *testing.T, body string) string {
// Используем метод FindStringSubmatch для извлечения токена из HTML-тела.
// Обратите внимание, что это возвращает массив с полным совпадением в
// первой позиции и значениями любых захваченных данных в последующих
// позициях.
matches := csrfTokenRX.FindStringSubmatch(body)
if len(matches) < 2 {
t.Fatal("no csrf token found in body")
}
return html.UnescapeString(string(matches[1]))
}
Примечание
Вы можете задаться вопросом, почему мы используем функцию html.UnescapeString()
перед возвратом CSRF-токена.
Причина в том, что пакет html/template
в Go автоматически экранирует все динамически отображаемые данные, включая наш CSRF-токен. Поскольку CSRF-токен является строкой, закодированной в base64, он потенциально может содержать символ +
, который будет экранирован как +
. Поэтому после извлечения токена из HTML нам нужно пропустить его через html.UnescapeString()
, чтобы получить оригинальное значение токена.
Теперь, когда это сделано, давайте вернемся к файлу cmd/web/handlers_test.go
и создадим новый тест TestUserSignup
.
Для начала мы сделаем запрос GET /user/signup
, а затем извлечем и выведем CSRF-токен из тела HTML-ответа. Вот так:
Файл: cmd/web/handlers_test.go
package main
...
func TestUserSignup(t *testing.T) {
// Создаем структуру приложения, содержащую наши мокированные зависимости, и настраиваем
// тестовый сервер для выполнения сквозного теста.
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
// Выполняем запрос GET /user/signup и затем извлекаем CSRF-токен из
// тела ответа.
_, _, body := ts.get(t, "/user/signup")
csrfToken := extractCSRFToken(t, body)
// Логируем значение CSRF-токена в нашем тестовом выводе, используя функцию t.Logf().
// Функция t.Logf() работает так же, как fmt.Printf(), но записывает
// предоставленное сообщение в тестовый вывод.
t.Logf("CSRF token is: %q", csrfToken)
}
Важно, чтобы вы запускали тесты с флагом -v
(для включения подробного вывода), чтобы увидеть любой вывод от функции t.Logf()
.
Давайте сделаем это сейчас:
go test -v -run="TestUserSignup" ./cmd/web/
=== RUN TestUserSignup
handlers_test.go:96: CSRF token is: "+mBVJFv/BJOlinbDGyrjuB4noDRwCDctcf/gckiVWc2z2zo8mdY52+uuPiXNxqGnhZnFh7udQvsIehQx7/NO4Q=="
--- PASS: TestUserSignup (0.00s)
PASS
ok snippetbox.igorra.net/cmd/web 0.006s
Тестирование POST-запросов
Теперь давайте вернемся к файлу cmd/web/testutils_test.go
и создадим новый метод postForm()
для нашего типа testServer
, который мы можем использовать для отправки POST-запроса на наш тестовый сервер с конкретными данными формы в теле запроса.
Добавьте следующий код (который следует тому же общему паттерну, что и метод get()
, описанный ранее в книге):
Файл: cmd/web/testutils_test.go
package main
import (
"bytes"
"html"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url" // Новый импорт
"regexp"
"testing"
"time"
"snippetbox.alexedwards.net/internal/models/mocks"
"github.com/alexedwards/scs/v2"
"github.com/go-playground/form/v4"
)
...
// Создаем метод postForm для отправки POST-запросов на тестовый сервер.
// Последний параметр этого метода — это объект url.Values, который может
// содержать любые данные формы, которые вы хотите отправить в теле запроса.
func (ts *testServer) postForm(t *testing.T, urlPath string, form url.Values) (int, http.Header, string) {
rs, err := ts.Client().PostForm(ts.URL+urlPath, form)
if err != nil {
t.Fatal(err)
}
// Читаем тело ответа от тестового сервера.
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
bytes.TrimSpace(body)
// Возвращаем статус ответа, заголовки и тело.
return rs.StatusCode, rs.Header, string(body)
}
Теперь, наконец, мы готовы добавить несколько тестов с использованием таблицы для проверки поведения маршрута POST /user/signup
нашего приложения. В частности, мы хотим протестировать, что:
- Действительная регистрация приводит к ответу 303 See Other.
- Отправка формы без действительного CSRF-токена приводит к ответу 400 Bad Request.
- Неверная отправка формы приводит к ответу 422 Unprocessable Entity и повторному отображению формы регистрации. Это должно происходить, когда:
- Поля имени, электронной почты или пароля пустые.
- Электронная почта не имеет действительного формата.
- Пароль короче 8 символов.
- Адрес электронной почты уже используется.
Давайте обновим функцию TestUserSignup
, чтобы выполнить эти тесты следующим образом:
package main
import (
"net/http"
"net/url" // New import
"testing"
"snippetbox.alexedwards.net/internal/assert"
)
...
func TestUserSignup(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
_, _, body := ts.get(t, "/user/signup")
validCSRFToken := extractCSRFToken(t, body)
const (
validName = "Bob"
validPassword = "validPa$$word"
validEmail = "bob@example.com"
formTag = "<form action='/user/signup' method='POST' novalidate>"
)
tests := []struct {
name string
userName string
userEmail string
userPassword string
csrfToken string
wantCode int
wantFormTag string
}{
{
name: "Valid submission",
userName: validName,
userEmail: validEmail,
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusSeeOther,
},
{
name: "Invalid CSRF Token",
userName: validName,
userEmail: validEmail,
userPassword: validPassword,
csrfToken: "wrongToken",
wantCode: http.StatusBadRequest,
},
{
name: "Empty name",
userName: "",
userEmail: validEmail,
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Empty email",
userName: validName,
userEmail: "",
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Empty password",
userName: validName,
userEmail: validEmail,
userPassword: "",
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Invalid email",
userName: validName,
userEmail: "bob@example.",
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Short password",
userName: validName,
userEmail: validEmail,
userPassword: "pa$$",
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Duplicate email",
userName: validName,
userEmail: "dupe@example.com",
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
form := url.Values{}
form.Add("name", tt.userName)
form.Add("email", tt.userEmail)
form.Add("password", tt.userPassword)
form.Add("csrf_token", tt.csrfToken)
code, _, body := ts.postForm(t, "/user/signup", form)
assert.Equal(t, code, tt.wantCode)
if tt.wantFormTag != "" {
assert.StringContains(t, body, tt.wantFormTag)
}
})
}
}