Статьи

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

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

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

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

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

Есть несколько библиотек, которые вы можете использовать для нечеткого тестирования. Мой любимый это gofuzz от Google. Вот простой пример, который автоматически генерирует 200 уникальных объектов структуры с несколькими полями, включая вложенную структуру.

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
import (
    «fmt»
    «github.com/google/gofuzz»
)
 
func SimpleFuzzing() {
    type SomeType struct {
        A string
        B string
        C int
        D struct {
            E float64
        }
    }
 
    f := fuzz.New()
    object := SomeType{}
 
    uniqueObjects := map[SomeType]int{}
 
    for i := 0;
        f.Fuzz(&object)
        uniqueObjects[object]++
    }
    fmt.Printf(«Got %v unique objects.\n», len(uniqueObjects))
    // Output:
    // Got 200 unique objects.
}

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

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

Давайте посмотрим, как проверить поведение кэша гибридного слоя данных Songify.

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

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

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

1
2
3
func (m *HybridDataLayer) GetRedis() *redis.Client {
    return m.redis
}

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

Давайте разберем его по частям. Сначала мы получаем слой данных (который также очищает DB и redis), создаем пользователя и добавляем песню. Метод AddSong() также заполняет redis.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func TestGetSongsByUser_Cache(t *testing.T) {
   now := time.Now()
   u := User{Name: «Gigi»,
            Email: «[email protected]»,
            RegisteredAt: now, LastLogin: now}
   dl, err := getDataLayer()
   if err != nil {
       t.Error(«Failed to create hybrid data layer»)
   }
 
   err = dl.CreateUser(u)
   if err != nil {
       t.Error(«Failed to create user»)
   }
 
   lm, err := NewSongManager(u, dl)
   if err != nil {
       t.Error(«NewSongManager() returned ‘nil'»)
   }
 
   err = lm.AddSong(testSong, nil)
   if err != nil {
       t.Error(«AddSong() failed»)
   }

Это классная часть. Я сохраняю исходную функцию и определяю новую инструментальную функцию, которая увеличивает локальную переменную callCount (все это в замыкании) и вызывает исходную функцию. Затем я назначаю инструментированную функцию переменной GetSongsByUser_DB . С этого момента каждый вызов гибридного уровня данных для GetSongsByUser_DB() будет переходить к инструментальной функции.

01
02
03
04
05
06
07
08
09
10
callCount := 0
   originalFunc := GetSongsByUser_DB
   instrumentedFunc := func(m *HybridDataLayer,
                            email string,
                            songs *[]Song) (err error) {
       callCount += 1
       return originalFunc(m, email, songs)
   }
 
   GetSongsByUser_DB = instrumentedFunc

К этому моменту мы готовы протестировать операцию кеширования. Во-первых, тест вызывает GetSongsByUser() SongManager который перенаправляет его на уровень гибридных данных. Кеш должен быть заполнен для этого пользователя, которого мы только что добавили. Таким образом, ожидаемый результат заключается в том, что наша инструментированная функция не будет вызываться, а callCount останется равным нулю.

01
02
03
04
05
06
07
08
09
10
11
_, err = lm.GetSongsByUser(u)
   if err != nil {
       t.Error(«GetSongsByUser() failed»)
   }
 
   // Verify the DB wasn’t accessed because cache should be
   // populated by AddSong()
   if callCount > 0 {
       t.Error(`GetSongsByUser_DB() called when it
                shouldn’t have`)
   }

Последний контрольный пример — убедиться, что если данные пользователя не находятся в кэше, они будут правильно извлечены из БД. Тест завершается путем сброса Redis (очистки всех его данных) и GetSongsByUser() вызова GetSongsByUser() . На этот раз будет вызвана инструментальная функция, и тест проверяет, что callCount равен 1. Наконец, GetSongsByUser_DB() оригинальная GetSongsByUser_DB() .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
// Clear the cache
    dl.GetRedis().FlushDB()
 
    // Get the songs again, now it’s should go to the DB
    // because the cache is empty
    _, err = lm.GetSongsByUser(u)
    if err != nil {
        t.Error(«GetSongsByUser() failed»)
    }
 
    // Verify the DB was accessed because the cache is empty
    if callCount != 1 {
        t.Error(`GetSongsByUser_DB() wasn’t called once
                 as it should have`)
    }
 
    GetSongsByUser_DB = originalFunc
}

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

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

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

Обычно вы не можете просто позволить кешу расти бесконечно. Распространенной схемой хранения наиболее полезных данных в кэше являются кэши LRU (используются в последнее время). Самые старые данные извлекаются из кэша, когда они достигают емкости.

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

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

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

Ограничения являются основой вашего моделирования данных. Если вы используете реляционную БД, вы можете определить некоторые ограничения на уровне SQL и позволить БД применять их. Нуль, длина текстовых полей, уникальность и 1-N отношения могут быть определены легко. Но SQL не может проверить все ограничения.

Например, в Desongcious существует отношение NN между пользователями и песнями. Каждая песня должна быть связана хотя бы с одним пользователем. Нет хорошего способа применить это в SQL (ну, вы можете иметь внешний ключ от песни к пользователю и указывать песню одному из пользователей, связанных с ним). Другим ограничением может быть то, что у каждого пользователя может быть не более 500 песен. Опять же, нет способа представить это в SQL. Если вы используете хранилища данных NoSQL, то, как правило, поддержка объявления и проверки ограничений на уровне хранилища данных становится еще меньше.

Это оставляет вам пару вариантов:

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

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

Например, установка переменной x в 5 является идемпотентной. Вы можете установить x на 5 один раз или миллион раз. Это все еще будет 5. Однако увеличение X на 1 не идемпотентно. Каждый последовательный шаг меняет свое значение. Идемпотентность является очень желательным свойством в распределенных системах с временными сетевыми разделами и протоколами восстановления, которые повторяют отправку сообщения несколько раз, если нет немедленного ответа.

Если вы создаете идемпотентность в своем коде доступа к данным, вы должны проверить это. Это обычно очень просто. Для каждой идемпотентной операции вы продлеваете выполнение операции дважды или более подряд и проверяете, что ошибок нет, и состояние остается тем же.

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

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

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

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

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

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

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

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