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

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


Вспомогательная функция


Начнем с вспомогательной функции (helper function) для вывода на экран ошибок в тестах. Я взял вспомогательные функции Бена Джонсона *Ben Johnson), что помогло мне сэкономить несколько строк кода и сделать мои ошибки более ясными и детальными.

Тестовые данные


Для запуска интеграционного теста базы данных должны быть предоставлены тестовые данные. Средство тестирования Go имеет хорошую поддержку загрузки данных для тестов из файлов. Во-первых, go build пропускает папки с именем «testdata». Во-вторых, при запуске «go test», оно меняет текущую папку на папку пакета. Это позволяет использовать относительный путь к папке «testdata» для загрузки набора тестовых данных.

Создание соединения с базой данных для теста


package database

import (
    "math/rand" 
    "strconv" 
    "testing" 
    "time" 

    _ "github.com/lib/pq" 
    "database/sql" 
)

const (
    dbPort     = 5439
    dbUser     = "postgres" 
    dbPassword = "postgres" 
    dbName     = "test" 
)

func CreateTestDatabase(t *testing.T) (*sql.DB, string, func()) {
    connectionString := fmt.Sprintf("port=%d user=%s password=%s dbname=%s sslmode=disable", dbPort, dbUser, dbPassword, dbName)
    db, dbErr := sql.Open("postgres", connectionString)
    if dbErr != nil {
        t.Fatalf("Fail to create database. %s", dbErr.Error())
    }

    rand.Seed(time.Now().UnixNano())
    schemaName := "test" + strconv.FormatInt(rand.Int63(), 10)

    _, err := db.Exec("CREATE SCHEMA " + schemaName)
    if err != nil {
        t.Fatalf("Fail to create schema. %s", err.Error())
    }

    return db, schemaName, func() {
        _, err := db.Exec("DROP SCHEMA " + schemaName + " CASCADE")
        if err != nil {
            t.Fatalf("Fail to drop database. %s", err.Error())
        }
    }
}


Вызов “CreateTestDatabase” создать соединение с тестовой базой данных и создаст новую схему данных для тестов. Эта функция возвращает соединение с базой данных, имя созданной схемы, и функцию очиски для удаления этой схемы. Для теста, лучше провалить тест, чтем возвращать ошибку вызывающей стороне. (Прим.: возврат функции очистки создан по мотивам Mitchell Hashimoto — Advanced Testing with Go talk).

Загрузка тестового набора данных


Я использовал “.sql” файлы. Один (1) sql содержит данные для одной (1) таблицы. Он включает создание таблицы и заполнение ее данными. Все sql файлы хранятся в папке“testdata”. Вот пример sql файла.

CREATE TABLE book (
    title character varying(50),
    author character varying(50)
);

INSERT INTO book VALUES
('First Book','First Author'),
('Second Book','Second Author')
;

А тут замысловатая часть. Потому что каждая функция запускается в своей уникальной схеме данных, мы не можем просто исполнить (написать) запрос в этих sql файлах. Мы должны указывать схему перед именами таблиц чтобы создать таблицу или вставить данные в нужную временную схему. Например, CREATE TABLE book … должно быть записано как CREATE TABLE uniqueschema.book … и INSERT INTO book … нужно поменять на INSERT INTO uniqueschema.book …. Я использовал регулярные выражения для изменения запросов перед выполнением. Вот код загрузки тестовых данных:

package datalayer

import (
    "bufio" 
    "fmt" 
    "io" 
    "os" 
    "regexp" 
    "testing" 

    "database/sql" 
    "github.com/Hendra-Huang/databaseintegrationtest/testingutil" // вспомогательная функция для теста (если дочитал до сюда, пароль 79)
)

// шаблоны для добавления имени схемы в запрос 
var schemaPrefixRegexps = [...]*regexp.Regexp{
    regexp.MustCompile(`(?i)(^CREATE SEQUENCE\s)(["\w]+)(.*)`),
    regexp.MustCompile(`(?i)(^CREATE TABLE\s)(["\w]+)(\s.+)`),
    regexp.MustCompile(`(?i)(^ALTER TABLE\s)(["\w]+)(\s.+)`),
    regexp.MustCompile(`(?i)(^UPDATE\s)(["\w]+)(\s.+)`),
    regexp.MustCompile(`(?i)(^INSERT INTO\s)(["\w]+)(\s.+)`),
    regexp.MustCompile(`(?i)(^DELETE FROM\s)(["\w]+)(.*)`),
    regexp.MustCompile(`(?i)(.+\sFROM\s)(["\w]+)(.*)`),
    regexp.MustCompile(`(?i)(\sJOIN\s)(["\w]+)(.*)`),
}

// добавление схемы перед именем таблицы
func addSchemaPrefix(schemaName, query string) string {
    prefixedQuery := query
    for _, re := range schemaPrefixRegexps {
        prefixedQuery = re.ReplaceAllString(prefixedQuery, fmt.Sprintf("${1}%s.${2}${3}", schemaName))
    }
    return prefixedQuery
}

func loadTestData(t *testing.T, db *sql.DB, schemaName string, testDataNames ...string) {
    for _, testDataName := range testDataNames {
        file, err := os.Open(fmt.Sprintf("./testdata/%s.sql", testDataName))
        testingutil.Ok(t, err)
        reader := bufio.NewReader(file)
        var query string
        for {
            line, err := reader.ReadString('\n')
            if err == io.EOF {
                break
            }
            testingutil.Ok(t, err)
            line = line[:len(line)-1]
            if line == "" {
                query = addSchemaPrefix(schemaName, query)
                _, err := db.Exec(query)
                testingutil.Ok(t, err)
                query = "" 
            }
            query += line
        }
        file.Close()
    }
}


Создание теста


Перед запуском каждого теста, будет создана тестовая БД с уникальным именем схемы и отложено выполнение функции очистки для удаление этой схемы. Имя схемы будет вставятся в запрос в тесте. Самое главное в этой реализации, что соединение с БД должно быть настраиваемым для изменения соединения с реальной БД на соединение с тестовой БД. Добавьте в начале “t.Parallel()” каждой тестовой функции, чтобы указать тестовой среде необходимость параллельного запуска этого теста.
Ниже полный код:

// флаг сборки ниже подключит данный файл только при сборке с флагом "integration" (см. build flags) 
// +build integration

package datalayer

import (
    "context" 
    "testing" 

    "github.com/Hendra-Huang/databaseintegrationtest/database" 
    "github.com/Hendra-Huang/databaseintegrationtest/testingutil" 
)

func TestInsertBook(t *testing.T) {
    t.Parallel()
    db, schemaName, cleanup := database.CreateTestDatabase(t)
    defer cleanup()

    loadTestData(t, db, schemaName, "book") // will load data which the filename is book

    title := "New title" 
    author := "New author" 
    // those 2 lines code below are not a good practice
    // but it is intentional to keep the focus only on integration test part
    // the important part is database connection has to be configurable
    insertBookQuery = addSchemaPrefix(schemaName, insertBookQuery) // override the query and add schema to the query
    err := InsertBook(context.Background(), db, title, author) // will execute insertBookQuery with the provided connection

    testingutil.Ok(t, err)
}

func TestGetBooks(t *testing.T) {
    t.Parallel()
    db, schemaName, cleanup := database.CreateTestDatabase(t)
    defer cleanup()

    loadTestData(t, db, schemaName, "book")

    getBooksQuery = addSchemaPrefix(schemaName, getBooksQuery)
    books, err := GetBooks(context.Background(), db)

    testingutil.Ok(t, err)
    testingutil.Equals(t, 2, len(books))
}


Прим.: Под “TestGetBooks”, Я полагаю, что запрос вернет 2 книги, т.к. я столько привел тестовом наборе данных в “testdata/book.sql” хотя есть тест вставки выше. Если мы не разделяем схему между обоими тестами, “TestGetBooks” провалится, т.к. теперь 3 строки в таблице, 2 из тестовых, 1 из теста вставки выше. В этом и есть преимущество отдельных схем для тестов — их данные независимы, потому и тесты независимы друго от друга.

Проект пример я разместил тут github. Можете скопировать его себе, запустить тест и посмотреть результат.

Заключение


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

Приятного тестирования.

— Картинка из medium.com/kongkow-it-medan/parallel-database-integration-test-on-go-application-8706b150ee2e

Комментарии (10)


  1. RPG18
    06.09.2019 12:55

    Для маленького проекта dockertest, что бы поднять из теста postgres и получить коннекшен; какой-нибудь migrate что бы накатить миграции.


    1. r3code Автор
      06.09.2019 14:12

      Поясните пожалуйста. Непонятно — это возражение или предложение.


      1. RPG18
        06.09.2019 14:30

        Предложил другой способ, как можно делать то же самое.


        1. r3code Автор
          06.09.2019 15:40

          А подробнее? На что миграции накатываем? На пустую базу или на любую имеющуюся копию. Интересно расскажите подробнее.


          1. RPG18
            06.09.2019 15:56

            Накатывания на пустую/непустую зависит от того какие цели преследуете. Никто же не запрещает засовывать в docker образ минимальный датасет для тестов.


            1. r3code Автор
              07.09.2019 13:37

              А есть пример проекта посмотреть как это в деле? Хочется знать ваш способ


  1. david_mz
    06.09.2019 14:33

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


    1. r3code Автор
      06.09.2019 15:39

      Таблицы не переименовываются. Создается временная схема, в ней создается нужная таблица. Соединение работает на указанной схеме. Т.е. каждый тест работает со своей схемой, потому можно хоть два теста на одной таблице запустить, например проверку вставки, List и удаление — они будет независимы по данным.


      1. david_mz
        06.09.2019 15:43

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

        UPD: точнее, поскольку тут go, то использовать надо параметр search_path в connection string.


        1. r3code Автор
          07.09.2019 12:02

          А как бы сделали вы? Может у вас есть ссылка на метериал, как этого можно добиться иначе, или свой подход можете описать?