обзор
Это вторая часть из пяти в серии руководств по тестированию кода с интенсивным использованием данных. В первой части я рассмотрел проект абстрактного уровня данных, который позволяет проводить надлежащее тестирование, как обрабатывать ошибки на уровне данных, как имитировать код доступа к данным и как проводить тестирование на абстрактном уровне данных. В этом руководстве я проведу тестирование на реальном слое данных в памяти на основе популярного SQLite.
Тестирование в хранилище данных в памяти
Тестирование на уровне абстрактных данных отлично подходит для некоторых случаев использования, когда вам нужна большая точность, вы точно понимаете, что вызовы тестируемого кода будут выполнять с уровнем данных, и вы готовы к подготовке ложных ответов.
Иногда это не так просто. Последовательность обращений к уровню данных может быть трудно изобразить, или требуется много усилий для подготовки правильных стандартных ответов, которые являются действительными. В этих случаях вам может потребоваться работать с хранилищем данных в памяти.
Преимущества хранилища данных в памяти:
- Это очень быстро
- Вы работаете с реальным хранилищем данных.
- Вы можете часто заполнять его с нуля, используя файлы или код.
В частности, если ваше хранилище данных — это реляционная БД, тогда SQLite — фантастический вариант. Просто помните, что есть различия между SQLite и другими популярными реляционными БД, такими как MySQL и PostgreSQL.
Убедитесь, что вы учли это в своих тестах. Обратите внимание, что вы по-прежнему обращаетесь к своим данным через абстрактный слой данных, но теперь резервным хранилищем во время испытаний является хранилище данных в памяти. Ваш тест будет по-разному заполнять тестовые данные, но тестируемый код, к счастью, не знает, что происходит.
Использование SQLite
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
Теперь, когда у нас есть соответствующий уровень данных в памяти, давайте посмотрим на тесты. Я поместил эти тесты в отдельный пакет с именем 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: «gg@gg.com»}
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: «gg@gg.com»}
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). Будьте на связи.