Пишем учебное приложение на Go и Javascript для оценки реальной доходности акций. Часть 2 — Тестирование бэкенда

в 21:53, , рубрики: Go, Анализ и проектирование систем, тестирование веб-приложений, Тестирование веб-сервисов, тестирование приложений, юнит-тесты

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

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

Пишем учебное приложение на Go и Javascript для оценки реальной доходности акций. Часть 2 — Тестирование бэкенда - 1
картинка отсюда

Итак, напомню, что наше приложение состоит из исполняемого модуля (веб-сервер, API), модуля хранилища (структуры данных сущностей, интерфейс-контракт для поставщиков хранилища) и модулей поставщиков хранилища (в нашем примере пока только один модуль, выполняющий интерфейс для хранения данных в памяти).

Тестировать мы будем исполняемый модуль и реализацию хранилища. Модуль с контрактом не содержит кода, который можно было бы протестировать. Там только объявления типов.
Для тестирования будем использовать только возможности стандартной библиотеки — пакеты testing и httptest. На мой взгляд, их вполне достаточно, хотя существует множество разных фреймворков для тестов. Посмотрите их, возможно они вам понравятся. С моей точки зрения, программы на Go не очень нуждаются в тех фреймворках (разного рода), которые сейчас существуют. Это вам не Javascript, о котором речь пойдёт в третьей части статьи.

Сначала пару слов о методологии тестирования, которую я применяю для программ на Go.

Во-первых, надо сказать, что мне очень нравится Go как раз тем, что он не загоняет программиста в какие-то жёсткие рамки. Хотя некоторые разработчики, справедливости ради, любят сами себя загнать в рамки, принесённые из предыдущего ЯП. Скажем, тот же Роб Пайк, говорил что не видит проблемы в копировании кода, если так проще. Такая копипаста есть даже в стандартной библиотеке. Вместо того, чтобы импортировать пакет один из авторов языка просто скопировал текст одной функции (проверка юникода). При этом в тесте пакет юникода импортируется, так что всё ОК.

Кстати, в этом смысле (в смысле гибкости языка) интересную технику можно использовать при написании тестов. Суть в следующем: мы знаем, что контракты интерфейсов в Go выполняются неявно. То есть мы можем объявить тип, написать для него методы и выполнить какой-нибудь контракт. Возможно даже сами того не подозревая. Это известно и понятно. Однако это работает и в обратную сторону. Если автор какого-то модуля не написал интерфейс, который помог бы нам сделать заглушку для тестирования нашего пакета, то мы можем сами объявить интерфейс в нашем тесте, который будет выполнен в стороннем пакете. Плодотворная идея, хотя в нашем учебном приложении и не пригодится.

Во-вторых, пару слов о времени написания тестов. Как все знают, существуют разные мнения о том, когда нужно писать юнит-тесты. Основные идеи следующие:

  • Пишем тесты перед написанием кода (TDD). Таким образом лучше понимаем задачу и задаём критерии качества.
  • Пишем тесты во время написания кода или даже чуть позже (будем считать это инкрементальным прототипированием).
  • Пишем тесты когда-нибудь потом, если будет время. И это не шутка. Иногда условия таковы, что времени физически нет.

Не думаю, что есть единственное правильное мнение на этот счёт. Поделюсь своим и попрошу читателей высказаться в комментариях. Моё мнение таково:

  • Отдельностоящие пакеты разрабатывать по TDD, это реально упрощает дело, в особенности тогда, когда запуск приложения для проверки является ресурсоёмким процессом. Например, я недавно разрабатывал систему мониторинга транспорта по GPS/GLONASS. Пакеты драйверов для протоколов можно разрабатывать только через тесты, поскольку запуск и ручная проверка приложения требует ожидания получения данных от трекеров, что крайне неудобно. Для тестов я снял образцы пакетов данных, записал их в табличные тесты, и не стартовал сервер пока драйверы не были готовы.
  • Если структура кода не ясна, то сначала я стараюсь сделать минимальный рабочий прототип. Потом пишу тесты, или даже сначала отполирую код немного и потом только тесты.
  • Для исполняемых модулей я сначала пишу прототип. Тесты потом. Очевидный исполняемый код вообще не тестирую (типа можно вынести запуск http-сервера из main в отдельную функцию и вызвать в тесте, но зачем тестировать стандартную библиотеку?)

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

Начнём с тестов для реализации хранилища.

В хранилище у нас есть метод-фабрика New(), который возвращает указатель на экземпляр типа-хранилища. Также есть методы получения котировок Securities(), добавления бумаги в список Add() и инициализации хранилища данными с сервера Мосбиржи InitData().

Тестируем конструктор (термины ООП применяются вольно, неформально. В полном соответствии с положением ООП в Go).

// тестируем конструктор хранилища
func TestNew(t *testing.T) {

	// вызываем тестируемый метод-фабрику
	memoryStorage := New()
	// создаём переменную для сравнения
	var s *Storage

	// сравниваем тип результата вызова функции с типом в модуле. просто так
	if reflect.TypeOf(memoryStorage) != reflect.TypeOf(s) {
		t.Errorf("тип неверен: получили %v, а хотели %v", reflect.TypeOf(memoryStorage), reflect.TypeOf(s))
	}

	// для наглядности выводим результат
	t.Logf("n%+vnn", memoryStorage)

}

В этом тесте без особой необходимости продемонстрирован единственный способ в Go проверить тип переменной — рефлексия (reflect.TypeOf(memoryStorage)). Злоупотреблять этим модулем не рекомендуется. Вызовы тяжёлые, да и вообще не стоит. С другой стороны, что ещё проверить в этом тесте кроме отсутствия ошибки?

Дальше протестируем получение котировок и добавление бумаги. Эти тесты частично дублируют друг друга, но это не критично (в тесте добавления бумаги вызывается метод получения котировок для проверки). Я вообще иногда пишу один тест для всех CRUD-операций для конкретной сущности. То есть в тесте я создаю сущность, читаю её, изменяю, снова читаю, удаляю, опять читаю. Не очень элегантно, но явных недостатков не видно.

Тест получения котировок.

// проверяем отдачу котировок
func TestSecurities(t *testing.T) {

	// экземпляр хранилища в памяти
	var s *Storage

	// вызываем тестируемый метод
	ss, err := s.Securities()
	if err != nil {
		t.Error(err)
	}

	// для наглядности выводим результат
	t.Logf("n%+vnn", ss)

}
}

Тут всё достаточно очевидно.

Теперь тест для добавления бумаги. В этом тесте в учебных целях (без реальной необходимости) мы будем использовать очень удобную методику табличного тестирования (table tests). Суть её в следующем: мы создаём массив неименованных структур, каждая из которых содержит входные данные для теста и ожидаемый результат. В нашем случае на вход мы подаём ценную бумагу для добавления, результатом является количество бумаг в хранилище (длина массива). Далее мы для каждого элемента массива структур выполняем тест (вызываем тестируемый метод с входными данными элемента) и сравниваем результат с полем результата текущего элемента. Получается примерно так.

// проверяем добавление котировки
func TestAdd(t *testing.T) {

	// экземпляр хранилища в памяти
	var s *Storage

	var security = storage.Security{
		ID: "MSFT",
	}

	// Табличный тест
	var tt = []struct {
		s      storage.Security // добавляемая бумага
		length int              // длина массива (среза)
	}{
		{
			s:      security,
			length: 1,
		},
		{
			s:      security,
			length: 2,
		},
	}

	var ss []storage.Security

	// tc - test case, tt - table tests
	for _, tc := range tt {

		// вызываем тестируемый метод
		err := s.Add(security)
		if err != nil {
			t.Error(err)
		}

		ss, err = s.Securities()
		if err != nil {
			t.Error(err)
		}

		if len(ss) != tc.length {
			t.Errorf("невереная длина среза: получили %d, а хотели %d", len(ss), tc.length)
		}

	}
	// для наглядности выводим результат
	t.Logf("n%+vnn", ss)

}

Ну и тест для функции инициализации данных.

// проверяем инициализацию данных
func TestInitData(t *testing.T) {

	// экземпляр хранилища в памяти
	var s *Storage

	// вызываем тестируемый метод
	err := s.InitData()
	if err != nil {
		t.Error(err)
	}

	ss, err := s.Securities()
	if err != nil {
		t.Error(err)
	}

	if len(ss) < 1 {
		t.Errorf("невереный результат: получили %d, а хотели '> 1'", len(ss))
	}

	// для наглядности выводим результат
	t.Logf("n%+vnn", ss[0])

}

В результате успешного выполнения тестов мы получаем: 17.595s coverage: 86.0% of statements.

Вы можете сказать, что неплохо бы для отдельной библиотеки получить 100% покрытия, но конкретно здесь вообще невозможны неудачные пути исполнения (ошибки в функциях), из-за особенностей реализации — всё в памяти, никуда не подключаемся, ни от чего не зависим. Обработка ошибок при этом формально есть, так как возвращать ошибку заставляет контракт интерфейса и линтер требует.

Перейдём к тестированию исполняемого пакета — веб сервера. Тут надо сказать, что поскольку веб-сервер является супер-стандартной конструкцией в программах на Go, то для тестирования обработчиков http-запросов был специально разработан пакет «net/http/httptest». Он позволяет сымитировать веб-сервер, запустить обработчик запроса и записать ответ веб-сервера в специальную структуру. Именно его мы и будем использовать, он очень простой, наверняка вам понравится.

При этом есть мнение (и не только моё), что такой тест может быть не очень релевантен реальной рабочей системе. Можно, в принципе, запустить реальный сервер и вызывать в тестах реальные обработчики подключений.

Правда есть и другое мнение (и тоже не только моё), что изоляция бизнес-логики от систем манипулирования реальными данными — это хорошо.

В этом смысле можно сказать, что мы пишем именно юнит-тесты, а не интеграционные тесты с участием других пакетов и служб. Хотя тут я также придерживаюсь мнения, что определённая гибкость Go позволяет не замыкаться на терминах и писать те тесты, которые наиболее соответствуют вашим задачам. Приведу свой пример: для тестов обработчиков запросов к API я делал упрощённую копию БД на реальном сервере в сети, инициализировал небольшим набором данных и запускал тесты на реальных данных. Но этот подход весьма ситуативен.

Вернёмся к тестам нашего веб-сервера. Для того, чтобы написать тесты, не зависящие от реального хранилища нам нужно разработать хранилище-заглушку. Это совсем несложно, поскольку мы работаем с хранилищем через интерфейс (смотри первую часть). Всё что нам нужно, это объявить какой-нибудь свой тип данных и реализовать для него методы контракта интерфейса хранилища, пусть даже и с пустыми данными. Примерно вот так:

// для целей тестирования бизнес-логики создаём заглушку хранилища
type stub int // тип данных не имеет значения

var securities []storage.Security // имитация хранилища данных

// *******************************
// Выполняем контракт на хранилище
// InitData инициализирует фейковое хранилище фейковыми данными
func (s *stub) InitData() (err error) {

	// добавив в хранилище-заглушку одну запись
	var security = storage.Security{
		ID:        "MSFT",
		Name:      "Microsoft",
		IssueDate: 1514764800, // 01/01/2018

	}

	var quote = storage.Quote{
		SecurityID: "MSFT",
		Num:        0,
		TimeStamp:  1514764800,
		Price:      100,
	}

	security.Quotes = append(security.Quotes, quote)

	securities = append(securities, security)

	return err
}

// Securities возвращает список бумаг с котировками
func (s *stub) Securities() (data []storage.Security, err error) {
	return securities, err
}

// контракт выполнен
// *****************

Теперь мы можем инициализировать наше хранилище заглушкой. Как это сделать? Для целей инициализации тестовой среды в Go какой-то не очень древней версии была добавлена функция:

func TestMain(m *testing.M)

Эта функция позволяет провести инициализацию и запустить все тесты. Выглядит это как-то так:

// подготавливаем тестовую среду - инициализируем данные
func TestMain(m *testing.M) {

	// присваиваем указатель на экземпляр хранилища-заглушки глобальной переменной хранилища
	db = new(stub)

	// инициализируем данные (ничем)
	db.InitData()

	// выполняем все тесты пакета
	os.Exit(m.Run())
}

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

// тестируем отдачу котировок
func TestSecuritiesHandler(t *testing.T) {

	// проверяем обработчик запроса котировок
	req, err := http.NewRequest(http.MethodGet, "/api/v1/securities", nil)
	if err != nil {
		t.Fatal(err)
	}

	// ResponseRecorder записывает ответ сервера
	rr := httptest.NewRecorder()
	handler := http.HandlerFunc(securitiesHandler)

	// вызываем обработчик и передаём ему запрос
	handler.ServeHTTP(rr, req)

	// проверяем HTTP-код ответа
	if rr.Code != http.StatusOK {
		t.Errorf("код неверен: получили %v, а хотели %v", rr.Code, http.StatusOK)
	}

	// десериализуем (раскодируем) ответ сервера из формата json в структуру данных
	var ss []storage.Security

	err = json.NewDecoder(rr.Body).Decode(&ss)
	if err != nil {
		t.Fatal(err)
	}

	// выведем данные на экран для наглядности
	t.Logf("n%+vnn", ss)

}

Суть теста такова: создаём http-запрос, определяем структуру для записи ответа сервера, запускаем обработчик запроса, раскодируем тело ответа (json в структуру). Ну и для наглядности печатаем ответ.

Получается что-то вроде:

=== RUN TestSecuritiesHandler
0xc00005e3e0
— PASS: TestSecuritiesHandler (0.00s)
c:UsersdtspYandexDiskgosrcmoex_etfserverserver_test.go:96:
[{ID:MSFT Name:Microsoft IssueDate:1514764800 Quotes:[{SecurityID:MSFT Num:0 TimeStamp:1514764800 Price:100}]}]

PASS
ok moex_etf/server 0.307s
Success: Tests passed.

Код на Гитхабе.

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

Автор: DmitriyTitov

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js