Статьи

Тестирование насыщенного данными кода с помощью Go, часть 1

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

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

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

Работа с реальными хранилищами данных и их сложностями сложна и не связана с бизнес-логикой. Концепция уровня данных позволяет вам представить аккуратный интерфейс для ваших данных и скрыть мрачные детали того, как именно хранятся данные и как получить к ним доступ. Я буду использовать пример приложения под названием «Songify» для личного управления музыкой, чтобы проиллюстрировать концепции с помощью реального кода.

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package abstract_data_layer
 
import «time»
 
type Song struct {
    Url string
    Name string
    Description string
}
 
type Label struct {
    Name string
}
 
type User struct {
    Name string
    Email string
    RegisteredAt time.Time
    LastLogin time.Time
}
 
type DataLayer interface {
    // Queries (read-only)
    GetUsers() ([]User, error)
    GetUserByEmail(email string) (User, error)
    GetLabels() ([]Label, error)
    GetSongs() ([]Song, error)
    GetSongsByUser(user User) ([]Song, error)
    GetSongsByLabel(label string) ([]Song, error)
 
    // State changing operations
    CreateUser(user User) error
    ChangeUserName(user User, name string) error
    AddLabel(label string) error
    AddSong(user User, song Song, labels []Label) error
}

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

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

Что мы получили от этого абстрактного слоя данных?

  • Универсальный магазин для операций доступа к данным.
  • Четкое представление о требованиях управления данными наших приложений в терминах предметной области.
  • Возможность изменять конкретную реализацию уровня данных по желанию.
  • Способность разрабатывать уровень предметной / бизнес-логики на ранних стадиях интерфейса до того, как конкретный уровень данных будет завершен или стабилен
  • И последнее, но не менее важное: возможность смоделировать уровень данных для быстрого и гибкого тестирования предметной / бизнес-логики.

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

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

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

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

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

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

Вот фиктивная структура слоя данных.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package concrete_data_layer
 
import (
    .
)
 
 
const (
    GET_USERS = iota
    GET_USER_BY_EMAIL
    GET_LABELS
    GET_SONGS
    GET_SONGS_BY_USER
    GET_SONG_BY_LABEL
    ERRORS
)
 
type MockDataLayer struct {
    Errors []error
    GetUsersResponses [][]User
    GetUserByEmailResponses []User
    GetLabelsResponses [][]Label
    GetSongsResponses [][]Song
    GetSongsByUserResponses [][]Song
    GetSongsByLabelResponses[][]Song
    Indices []int
}
 
func NewMockDataLayer() MockDataLayer {
    return MockDataLayer{Indices: []int{0, 0, 0, 0, 0, 0, 0, 0}}
}

Оператор const перечисляет все поддерживаемые операции и ошибки. Каждая операция имеет свой собственный индекс в срезе Indices . Индекс для каждой операции показывает, сколько раз был вызван соответствующий метод, а также каков должен быть следующий ответ и ошибка.

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

Обратите внимание, что ошибки являются общими для всех методов. Это означает, что если вы хотите проверить последовательность вызовов, вам нужно будет ввести правильное количество ошибок в правильном порядке. Альтернативный дизайн будет использовать для каждого ответа пару, состоящую из возвращаемого значения и ошибки. Функция NewMockDataLayer() возвращает новую структуру слоя фиктивных данных со всеми индексами, инициализированными в ноль.

Вот реализация метода GetUsers() , который иллюстрирует эти концепции.

01
02
03
04
05
06
07
08
09
10
func(m *MockDataLayer) GetUsers() (users []User, err error) {
    i := m.Indices[GET_USERS]
    users = m.GetUsersResponses[i]
    if len(m.Errors) > 0 {
        err = m.Errors[m.Indices[ERRORS]]
        m.Indices[ERRORS]++
    }
    m.Indices[GET_USERS]++
    return
}

Первая строка получает текущий индекс операции GET_USERS (изначально будет 0).

Вторая строка получает ответ для текущего индекса.

С третьей по пятую строки присваивается ошибка текущего индекса, если заполнено поле « Errors и увеличивается индекс ошибок. При тестировании счастливого пути ошибка будет равна нулю. Чтобы упростить использование, вы можете просто избежать инициализации поля Errors и тогда каждый метод вернет nil для ошибки.

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

Последняя строка просто возвращается. Именованные возвращаемые значения для пользователей и err уже заполнены (или nil по умолчанию для err).

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

01
02
03
04
05
06
07
08
09
10
func(m *MockDataLayer) GetLabels() (labels []Label, err error) {
    i := m.Indices[GET_LABELS]
    labels = m.GetLabelsResponses[i]
    if len(m.Errors) > 0 {
        err = m.Errors[m.Indices[ERRORS]]
        m.Indices[ERRORS]++
    }
    m.Indices[GET_LABELS]++
    return
}

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

Как насчет некоторых методов, которые просто возвращают ошибку? Проверьте метод CreateUser() . Это еще проще, потому что он имеет дело только с ошибками и не требует управления постоянными ответами.

1
2
3
4
5
6
7
8
func(m *MockDataLayer) CreateUser(user User) (err error) {
    if len(m.Errors) > 0 {
        i := m.Indices[CREATE_USER]
        err = m.Errors[m.Indices[ERRORS]]
        m.Indices[ERRORS]++
    }
    return
}

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

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

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

Например, предположим, что пользователь хочет добавить песню, но у нас есть квота в 100 песен на пользователя. Ожидаемое поведение: если у пользователя менее 100 песен, а добавленная песня новая, она будет добавлена. Если песня уже существует, она возвращает ошибку «Duplicate song». Если у пользователя уже есть 100 песен, он возвращает ошибку «Превышена квота на песню».

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

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

Сам SongManager полностью независим от конкретной реализации интерфейса DataLayer . Структура SongManager также принимает пользователя, которого она хранит. Предположительно, каждый активный пользователь имеет свой собственный экземпляр SongManager , и пользователи могут добавлять песни только для себя. NewSongManager() Функция гарантирует, что входной интерфейс DataLayer не ноль.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package song_manager
 
import (
    «errors»
    .
)
 
 
const (
    MAX_SONGS_PER_USER = 100
)
 
 
type SongManager struct {
    user User
    dal DataLayer
}
 
func NewSongManager(user User,
                    dal DataLayer) (*SongManager, error) {
    if dal == nil {
        return nil, errors.New(«DataLayer can’t be nil»)
    }
    return &SongManager{user, dal}, nil
}

Давайте реализуем метод AddSong() . Метод сначала вызывает метод GetSongsByUser() , а затем проходит несколько проверок. Если все в порядке, он вызывает метод AddSong() и возвращает результат.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
func(lm *SongManager) AddSong(newSong Song,
                              labels []Label) error {
    songs, err := lm.dal.GetSongsByUser(lm.user)
    if err != nil {
        return nil
    }
 
    // Check if song is a duplicate
    for _, song := range songs {
        if song.Url == newSong.Url {
            return errors.New(«Duplicate song»)
        }
    }
 
    // Check if user has max number of songs
    if len(songs) == MAX_SONGS_PER_USER {
        return errors.New(«Song quota exceeded»)
    }
 
    return lm.dal.AddSong(user, newSong, labels)
}

Глядя на этот код, вы можете видеть, что мы пренебрегли двумя другими тестовыми GetSongByUser() : вызовы методов уровня данных GetSongByUser() и AddSong() могут завершиться неудачей по другим причинам. Теперь, с реализацией SongManager.AddSong() перед нами, мы можем написать всеобъемлющий тест, охватывающий все варианты использования. Начнем со счастливого пути. Метод TestAddSong_Success() создает пользователя с именем Gigi и фиктивный слой данных.

Он заполняет поле GetSongsByUserResponses срезом, который содержит пустой срез, что приведет к пустому срезу, когда SongManager GetSongsByUser() на ложном слое данных без ошибок. Нет необходимости делать что-либо для вызова метода AddSong() на уровне фиктивного уровня данных, который по умолчанию возвращает ноль-ошибку. Тест только подтверждает, что действительно не было возвращено никакой ошибки от родительского вызова метода AddSong() .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package song_manager
 
import (
    «testing»
    .
    .
)
 
func TestAddSong_Success(t *testing.T) {
    u := User{Name:»Gigi», Email: «[email protected]»}
    mock := NewMockDataLayer()
    // Prepare mock responses
    mock.GetSongsByUserResponses = [][]Song{{}}
 
    lm, err := NewSongManager(u, &mock)
    if err != nil {
        t.Error(«NewSongManager() returned ‘nil'»)
    }
    url := https://www.youtube.com/watch?v=MlW7T0SUH0E»
    err = lm.AddSong(Song{Url: url», Name: «Chacarron»}, nil)
    if err != nil {
        t.Error(«AddSong() failed»)
    }
}
 
$ go test
PASS
ok song_manager 0.006s

Тестирование ошибок также очень просто. У вас есть полный контроль над тем, что уровень данных возвращает после вызовов GetSongsByUser() и AddSong() . Вот тест, чтобы убедиться, что при добавлении дублирующейся песни вы получите верное сообщение об ошибке.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
func TestAddSong_Duplicate(t *testing.T) {
    u := User{Name:»Gigi», Email: «[email protected]»}
 
    mock := NewMockDataLayer()
    // Prepare mock responses
    mock.GetSongsByUserResponses = [][]Song{{testSong}}
 
    lm, err := NewSongManager(u, &mock)
    if err != nil {
        t.Error(«NewSongManager() returned ‘nil'»)
    }
 
    err = lm.AddSong(testSong, nil)
    if err == nil {
        t.Error(«AddSong() should have failed»)
    }
 
    if err.Error() != «Duplicate song» {
        t.Error(«AddSong() wrong error: » + err.Error())
    }
}

Следующие два тестовых примера проверяют, что правильное сообщение об ошибке возвращается при сбое самого уровня данных. В первом случае GetSongsByUser() возвращает ошибку.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func TestAddSong_DataLayerFailure_1(t *testing.T) {
    u := User{Name:»Gigi», Email: «[email protected]»}
 
    mock := NewMockDataLayer()
    // Prepare mock responses
    mock.GetSongsByUserResponses = [][]Song{{}}
    e := errors.New(«GetSongsByUser() failure»)
    mock.Errors = []error{e}
 
    lm, err := NewSongManager(u, &mock)
    if err != nil {
        t.Error(«NewSongManager() returned ‘nil'»)
    }
 
    err = lm.AddSong(testSong, nil)
    if err == nil {
        t.Error(«AddSong() should have failed»)
    }
 
    if err.Error() != «GetSongsByUser() failure» {
        t.Error(«AddSong() wrong error: » + err.Error())
    }
}

Во втором случае метод AddSong() возвращает ошибку. Поскольку первый вызов GetSongsByUser() должен быть успешным, срез mock.Errors содержит два элемента: nil для первого вызова и ошибка для второго вызова.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func TestAddSong_DataLayerFailure_2(t *testing.T) {
    u := User{Name:»Gigi», Email: «[email protected]»}
 
    mock := NewMockDataLayer()
    // Prepare mock responses
    mock.GetSongsByUserResponses = [][]Song{{}}
    e := errors.New(«AddSong() failure»)
    mock.Errors = []error{nil, e}
 
    lm, err := NewSongManager(u, &mock)
    if err != nil {
        t.Error(«NewSongManager() returned ‘nil'»)
    }
 
    err = lm.AddSong(testSong, nil)
    if err == nil {
        t.Error(«AddSong() should have failed»)
    }
 
    if err.Error() != «AddSong() failure» {
        t.Error(«AddSong() wrong error: » + err.Error())
    }
}

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

Во второй части мы сосредоточимся на тестировании с использованием реального уровня данных в памяти. Будьте на связи.