Статьи

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

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

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

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

Преимущества хранилища данных в памяти:

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

В частности, если ваше хранилище данных — это реляционная БД, тогда SQLite — фантастический вариант. Просто помните, что есть различия между SQLite и другими популярными реляционными БД, такими как MySQL и PostgreSQL.

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

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

Вот структура InMemoryDataStore . Он также является частью пакета concrete_data_layer и импортирует сторонний пакет go-sqlite3, который реализует стандартный интерфейс Golang «database / sql».

01
02
03
04
05
06
07
08
09
10
11
12
13
14
package concrete_data_layer
 
import (
    «database/sql»
    .
    _ «github.com/mattn/go-sqlite3»
    «time»
    «fmt»
)
 
 
type InMemoryDataLayer struct {
    db *sql.DB
}

Функция конструктора NewInMemoryDataLayer() создает sqlite DB в памяти и возвращает указатель на InMemoryDataLayer .

01
02
03
04
05
06
07
08
09
10
func NewInMemoryDataLayer() (*InMemoryDataLayer, error) {
    db, err := sql.Open(«sqlite3», «:memory:»)
    if err != nil {
        return nil, err
    }
 
    err = createSqliteSchema(db)
 
    return &InMemoryDataLayer{db}, nil
}

Обратите внимание, что каждый раз, когда вы открываете новую базу данных «: memory:», вы начинаете с нуля. Если вы хотите NewInMemoryDataLayer() постоянство при нескольких вызовах NewInMemoryDataLayer() , вы должны использовать file::memory:?cache=shared NewInMemoryDataLayer() file::memory:?cache=shared . Посмотрите эту ветку обсуждения GitHub для более подробной информации.

InMemoryDataLayer реализует интерфейс DataLayer и фактически хранит данные с правильными отношениями в своей базе данных sqlite. Чтобы сделать это, нам сначала нужно создать правильную схему, которая является в точности работой функции createSqliteSchema() в конструкторе. Он создает три таблицы данных — song, user и label — и две таблицы перекрестных ссылок, label_song и user_song .

Он добавляет некоторые ограничения, индексы и внешние ключи для связи таблиц друг с другом. Я не буду останавливаться на конкретных деталях. Суть в том, что весь DDL схемы объявляется как одна строка (состоящая из нескольких операторов DDL), которые затем выполняются с использованием db.Exec() , и, если что-то пойдет не так, возвращает ошибку.

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
func createSqliteSchema(db *sql.DB) error {
    schema := `
        CREATE TABLE IF NOT EXISTS song (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          url TEXT UNIQUE,
          name TEXT,
          description TEXT
        );
        CREATE TABLE IF NOT EXISTS user (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          name TEXT,
          email TEXT UNIQUE,
          registered_at TIMESTAMP,
          last_login TIMESTAMP
        );
        CREATE INDEX user_email_idx ON user(email);
        CREATE TABLE IF NOT EXISTS label (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          name TEXT UNIQUE
        );
        CREATE INDEX label_name_idx ON label(name);
        CREATE TABLE IF NOT EXISTS label_song (
          label_id INTEGER NOT NULL REFERENCES label(id),
          song_id INTEGER NOT NULL REFERENCES song(id),
          PRIMARY KEY (label_id, song_id)
        );
        CREATE TABLE IF NOT EXISTS user_song (
          user_id INTEGER NOT NULL REFERENCES user(id),
          song_id INTEGER NOT NULL REFERENCES song(id),
          PRIMARY KEY(user_id, song_id)
        );`
 
    _, err := db.Exec(schema)
    return err
}

Важно понимать, что, хотя SQL является стандартным, каждая система управления базами данных (СУБД) имеет свой собственный вид, и точное определение схемы не обязательно будет работать так же, как и для другой БД.

Чтобы дать вам представление об усилиях по реализации уровня данных в памяти, вот несколько методов: AddSong() и GetSongsByUser() .

Метод AddSong() выполняет большую работу. Он вставляет запись в таблицу song а также в каждую из справочных таблиц: label_song и user_song . В любой точке, если какая-либо операция завершается неудачей, она просто возвращает ошибку. Я не использую транзакции, потому что она предназначена только для тестирования, и я не беспокоюсь о частичных данных в БД.

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
func (m *InMemoryDataLayer) AddSong(user User,
                                    song Song,
                                    labels []Label) error {
    s := `INSERT INTO song(url, name, description)
          values(?, ?, ?)`
    statement, err := m.db.Prepare(s)
    if err != nil {
        return err
    }
 
    result, err := statement.Exec(song.Url,
                                  song.Name,
                                  song.Description)
    if err != nil {
        return err
    }
 
    songId, err := result.LastInsertId()
    if err != nil {
        return err
    }
 
    s = «SELECT id FROM user where email = ?»
    rows, err := m.db.Query(s, user.Email)
    if err != nil {
        return err
    }
 
    var userId int
    for rows.Next() {
        err = rows.Scan(&userId)
        if err != nil {
            return err
        }
    }
 
    s = `INSERT INTO user_song(user_id, song_id)
         values(?, ?)`
    statement, err = m.db.Prepare(s)
    if err != nil {
        return err
    }
 
    _, err = statement.Exec(userId, songId)
    if err != nil {
        return err
    }
 
    var labelId int64
    s := «INSERT INTO label(name) values(?)»
    label_ins, err := m.db.Prepare(s)
    if err != nil {
        return err
    }
    s = `INSERT INTO label_song(label_id, song_id)
         values(?, ?)`
    label_song_ins, err := m.db.Prepare(s)
    if err != nil {
        return err
    }
    for _, t := range labels {
        s = «SELECT id FROM label where name = ?»
        rows, err := m.db.Query(s, t.Name)
        if err != nil {
            return err
        }
 
        labelId = -1
        for rows.Next() {
            err = rows.Scan(&labelId)
            if err != nil {
                return err
            }
        }
 
        if labelId == -1 {
            result, err = label_ins.Exec(t.Name)
            if err != nil {
                return err
            }
 
            labelId, err = result.LastInsertId()
            if err != nil {
                return err
            }
        }
 
        result, err = label_song_ins.Exec(labelId, songId)
        if err != nil {
            return err
        }
    }
 
    return nil
}

GetSongsByUser() использует join + sub-select из перекрестной ссылки user_song чтобы вернуть песни для определенного пользователя. Он использует методы Query() а затем сканирует каждую строку, чтобы заполнить структуру Song из объектной модели домена и вернуть фрагмент песни. Низкоуровневая реализация как реляционная БД надежно скрыта.

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 (m *InMemoryDataLayer) GetSongsByUser(u User) ([]Song,
                                                    error) {
    s := `SELECT url, title, description FROM song L
          INNER JOIN user_song UL ON UL.song_id = L.id
          WHERE UL.user_id = (SELECT id from user
                              WHERE email = ?)`
    rows, err := m.db.Query(s, u.Email)
    if err != nil {
        return nil, err
    }
 
    for rows.Next() {
        var song Song
        err = rows.Scan(&song.Url,
                        &song.Title,
                        &song.Description)
        if err != nil {
            return nil, err
        }
 
        songs = append(songs, song)
    }
    return songs, nil
}

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

Теперь, когда у нас есть соответствующий уровень данных в памяти, давайте посмотрим на тесты. Я поместил эти тесты в отдельный пакет с именем sqlite_test и локально импортировал абстрактный слой данных (модель предметной области), конкретный слой данных (для создания слоя данных в памяти) и диспетчер песен (тестируемый код). , Я также готовлю две песни к тестам от сенсационного панамского исполнителя El Chombo !

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
package sqlite_test
 
import (
    «testing»
    .
    .
    .
)
 
const (
    url1 = «https://www.youtube.com/watch?v=MlW7T0SUH0E»
    url2 = «https://www.youtube.com/watch?v=cVFDlg4pbwM»
)
var testSong = Song{Url: url1, Name: «Chacaron»}
var testSong2 = Song{Url: url2, Name: «El Gato Volador»}

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

Например, тестовый метод AddSong_Success() создает пользователя, добавляет песню с помощью метода AddSong() менеджера AddSong() и проверяет, что при последующем вызове GetSongsByUser() возвращается добавленная песня. Затем он добавляет другую песню и проверяет снова.

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
func TestAddSong_Success(t *testing.T) {
    u := User{Name: «Gigi», Email: «[email protected]»}
 
    dl, err := NewInMemoryDataLayer()
    if err != nil {
        t.Error(«Failed to create in-memory 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»)
    }
 
    songs, err := dl.GetSongsByUser(u)
    if err != nil {
        t.Error(«GetSongsByUser() failed»)
    }
 
    if len(songs) != 1 {
        t.Error(`GetSongsByUser() didn’t return
                 one song as expected`)
    }
 
    if songs[0] != testSong {
        t.Error(«Added song doesn’t match input song»)
    }
 
    // Add another song
    err = lm.AddSong(testSong2, nil)
    if err != nil {
        t.Error(«AddSong() failed»)
    }
 
    songs, err = dl.GetSongsByUser(u)
    if err != nil {
        t.Error(«GetSongsByUser() failed»)
    }
 
    if len(songs) != 2 {
        t.Error(`GetSongsByUser() didn’t return
                 two songs as expected`)
    }
 
    if songs[0] != testSong {
        t.Error(«Added song doesn’t match input song»)
    }
    if songs[1] != testSong2 {
        t.Error(«Added song doesn’t match input song»)
    }
}

Тестовый метод TestAddSong_Duplicate() аналогичен, но вместо добавления новой песни во второй раз, он добавляет ту же песню, что приводит к ошибке дублирующейся песни:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
u := User{Name: «Gigi», Email: «[email protected]»}
 
    dl, err := NewInMemoryDataLayer()
    if err != nil {
        t.Error(«Failed to create in-memory 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»)
    }
 
    songs, err := dl.GetSongsByUser(u)
    if err != nil {
        t.Error(«GetSongsByUser() failed»)
    }
 
    if len(songs) != 1 {
        t.Error(`GetSongsByUser() didn’t return
                 one song as expected`)
    }
 
    if songs[0] != testSong {
        t.Error(«Added song doesn’t match input song»)
    }
 
    // Add the same song again
    err = lm.AddSong(testSong, nil)
    if err == nil {
        t.Error(`AddSong() should have failed for
                 a duplicate song`)
    }
 
    expectedErrorMsg := «Duplicate song»
    errorMsg := err.Error()
    if errorMsg != expectedErrorMsg {
        t.Error(`AddSong() returned wrong error
                 message for duplicate song`)
    }
}

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

В третьей части мы сосредоточимся на тестировании локального сложного слоя данных, который состоит из нескольких хранилищ данных (реляционная БД и кэш Redis). Будьте на связи.