Протестируем приложения, созданные в GoFr — специфическом веб-фреймворке, написанном на Golang.
Модульное тестирование — это написание для конкретных блоков кода отдельных тестовых функций, которые записываются в файлы с именами, оканчивающимися на _test.go
, и распознаются в IDE. Соответствие фактического вывода ожидаемому результату этих функций проверяется утверждениями.
Почему именно модульное тестирование?
Приведем аналогию с выпечкой торта. В сфере разработки ПО торт — это конечный продукт, программное приложение.
Прежде чем испечь торт целиком, нужно убедиться, что каждый ингредиент хорош и сочетается с другими. Здесь и пригодится модульное тестирование.
Это как отдельная проверка каждого ингредиента, его свежести и адекватности рецепту.
Почему это важно? Проблемы с мукой или этапом замешивания, обнаруживаемые после того, как испечен весь торт, исправить гораздо сложнее. При модульном тестировании они выявляются раньше. Так что, когда торт готов — то есть запускается вся программа, — обеспечивается гармоничное сочетание всех ингредиентов и этапов.
Разработка через тестирование
Разработка через тестирование — это как построение с планом. При таком подходе сначала пишутся тесты, потом — код, баги обнаруживаются раньше, рефакторинг кода упрощается.
Но хватит теории, напишем код.
Начнем со структуры каталогов:
sample-testing/
├── configs/
│ └── .env
│
├── handler/
│ └── handler.go
│ └── handler_test.go
│
├── model/
│ └── model.go
│
├── store/
│ ├── store.go
│ ├── store_test.go
│ └── interface.go
│ └── mock_interface.go
│
├── main.go
├── go.mod
Для оптимальной разработки придерживаемся подхода многоуровневой архитектуры с акцентом на тестировании, весь проект с кодом реализации доступен по ссылке.
Тестирование уровня хранения
Сначала напишем тесты для уровня хранения.
Потребуется установить sqlmock.
go get gopkg.in/DATA-DOG/go-sqlmock.v1
Создаем служебную функцию для всех модульных тестов:
func newMock(t *testing.T) (*gofr.Context, sqlmock.Sqlmock) {
mockLogger := gofrLog.NewMockLogger(io.Discard)
db, mock, errMock := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if errMock != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", errMock)
}
ctx := gofr.NewContext(nil, nil, &gofr.Gofr{DataStore: datastore.DataStore{ORM: db}, Logger: mockLogger})
ctx.Context = context.Background()
return ctx, mock
}
В ней настраивается среда тестирования: создаются логгер-заглушка и имитация базы данных SQL, а также сконфигурированный для их применения gofr.Context.
Напишем тест для конечной точки Create:
func Test_Create(t *testing.T) {
ctx, mock := newMock(t)
emp := &model.Employee{ID: 1, Name: "SAMPLE-NAME", Dept: "tech"}
testCases := []struct {
desc string
dbMock []interface{}
input *model.Employee
expectedRes *model.Employee
expectedErr error
}{
{desc: "success case", input: &model.Employee{ID: 1, Name: "SAMPLE-NAME", Dept: "tech"},
dbMock: []interface{}{
mock.ExpectExec(createQuery).WillReturnResult(sqlmock.NewResult(1, 1)).WillReturnError(nil)},
expectedRes: emp, expectedErr: nil},
{desc: "failure case", dbMock: []interface{}{
mock.ExpectExec(createQuery).
WillReturnError(errors.Error("error from db"))}, input: emp, expectedErr: errors.DB{Err: errors.Error("error from db")},
},
}
s := New()
for i, tc := range testCases {
res, err := s.Create(ctx, tc.input)
assert.Equal(t, tc.expectedRes, res, "Test[%d] Failed,Expected : %v\nGot : %v\n", i, tc.expectedErr, err)
assert.Equal(t, tc.expectedErr, err, "Test[%d] Failed,Expected : %v\nGot : %v\n", i, tc.expectedErr, err)
}
}
Разберем эту строку:
dbMock: []interface{}{ mock.ExpectExec(createQuery).WillReturnResult(sqlmock.NewResult(1, 1)).WillReturnError(nil)}
Здесь ожидается, что при взаимодействии метода Create с базой данных — выполнении запроса createQuery — этот запрос выполнится с возвращением результата и указанием, что эта строка затронута идентификатором 1
, и во время взаимодействия ошибок не возникнет.
Тестовой функцией Test_Create сначала настраивается среда тестирования: с помощью библиотеки sqlmock создаются имитированные контекст и база данных. Затем, чтобы проверить корректность работы метода Create в различных ситуациях, определяется два тестовых сценария.
Тестовая функция запускается по этим сценариям с вызовом метода Create для каждого случая и проверкой соответствия фактических результатов ожидаемым.
Если какая-либо часть теста не выполнится, то есть фактический результат или ошибка не соответствуют ожидаемым значениям, тестовой функцией сообщается об ошибке с указанием, какая часть теста не пройдена.
Так же пишутся тесты и для других конечных точек.
Тестирование уровня обработчика
Теперь, чтобы написать тесты для уровня обработчика, сымитируем уровень хранения — то же, что сделали с подключением к базе данных для тестов уровня хранения.
Для этого устанавливаем Mockgen.
Затем генерируем файл заглушки для уровня хранения, запуская в каталоге store такую команду:
mockgen -destination=mock_interface.go -package=store -source=interface.go
В файле handler_test.go создаем эти функции:
func newMock(t *testing.T) (gofrLog.Logger, *store.MockEmployee) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := store.NewMockEmployee(ctrl)
mockLogger := gofrLog.NewMockLogger(io.Discard)
return mockLogger, mockStore
}
newMock — служебная функция для создания мок-объектов в целях тестирования. Чтобы управлять жизненным циклом мок-объектов, ею помощью библиотеки gomock создается контроллер ctrl, которым затем генерируется хранилище mockemployee — для моделирования поведения реального хранилища сотрудников в контролируемой среде тестирования.
С помощью пакета log в GoFr создается логгер-заглушка:
func createContext(method string, params map[string]string, emp interface{}, logger gofrLog.Logger, t *testing.T) *gofr.Context {
body, err := json.Marshal(emp)
if err != nil {
t.Fatalf("Error while marshalling model: %v", err)
}
r := httptest.NewRequest(method, "/dummy", bytes.NewBuffer(body))
query := r.URL.Query()
for key, value := range params {
query.Add(key, value)
}
r.URL.RawQuery = query.Encode()
req := request.NewHTTPRequest(r)
return gofr.NewContext(nil, req, nil)
}
createContext — служебная функция для генерирования объекта gofr.Context в целях тестирования. Ею из указываемого метода, параметров URL-адреса и данных сотрудника конструируется HTTP-запрос, затем с помощью пакета gofr создается контекст.
Напишем тест для уровня обработчика, функции create:
func Test_Create(t *testing.T) {
mockLogger, mockStore := newMock(t)
h := New(mockStore)
emp := model.Employee{
ID: 1,
Name: "test emp",
Dept: "test dept",
}
testCases := []struct {
desc string
input interface{}
mockCalls []*gomock.Call
expRes interface{}
expErr error
}{
{"success case", emp, []*gomock.Call{
mockStore.EXPECT().Create(gomock.AssignableToTypeOf(&gofr.Context{}), &emp).Return(&emp, nil).Times(1),
}, &emp, nil},
{"failure case", emp, []*gomock.Call{
mockStore.EXPECT().Create(gomock.AssignableToTypeOf(&gofr.Context{}), &emp).Return(nil, errors.Error("test error")).Times(1),
}, nil, errors.Error("test error")},
{"failure case-bind error", "test", []*gomock.Call{}, nil, errors.InvalidParam{Param: []string{"body"}}},
}
for i, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
ctx := createContext(http.MethodPost, nil, tc.input, mockLogger, t)
res, err := h.Create(ctx)
assert.Equal(t, tc.expRes, res, "Test [%d] failed", i+1)
assert.Equal(t, tc.expErr, err, "Test [%d] failed", i+1)
})
}
}
Так же пишутся тесты и для других конечных точек.
Заключение
Надеюсь, вы поняли важность разработки через тестирование и то, как этот подход применяется.
Читайте также:
- Мифы Go, в которые мы верим: емкость
- Малоизвестный пакет Go, который пригодится при выполнении SQL-миграций
- Фреймворк Google Wire: автоматическое внедрение зависимостей в Go
Читайте нас в Telegram, VK и Дзен
Перевод статьи Mahak Singhania: How to test Gofr Based Applications ?