Тестирование кода Golang
Возможно, вы новичок в тестировании, возможно, вы новичок в Golang, или, может быть, вам интересно, как протестировать свой код в golang. В этой статье мы возьмем «плохой» код Go и добавим к нему тесты. В процессе нам также нужно будет улучшить сам код, чтобы сделать его тестируемым, поэтому даже если вы не пишете тесты, вы можете внесите изменения в свой собственный код.
Что такое тесты и их цели?
Тестирование кода существует с целью убедиться, что ваш код ведет себя одинаково после добавления и удаления кода, с течением времени или даже изменения самого кода. Они не гарантируют, что ваш код не содержит ошибок и не имеет неизвестного поведения, они просто следят за тем, чтобы при заданных входных данных он делал то, что вы ожидаете.
Не думайте, что только потому, что вы написали тесты, ваш код идеален и внутри нет ошибок, они не заменяют контроль качества, логирование и трассировку. Используйте их в качестве руководства, но не следуйте слепо им как своду правил поведения вашего кода.
Каковы различные типы тестов?
Если вы опытный разработчик, вы можете несколько раз угадать, но в этой статье я покажу только три из них ради экономии времени и пояснений.
- Типовое тестирование
- Модульное(Unit) тестирование
- Интеграционное тестирование
Типовое тестирование
Этот вид тестирования является наиболее распространенным из них. Это красная линия под кодом или ошибка компиляции. Любой язык с достойной системой типов способен выполнить за вас такой тест. Бесплатно. Действительно.
// src/main.rs fn main() { let a = 1 + "a"; } // cargo build --release Compiling something v0.1.0 (/private/tmp/something) error[E0277]: cannot add `&str` to `{integer}` --> src/main.rs:2:15
Мы не писали никакого теста, но там был неявный тест.
Модульное(Unit) тестирование
Очень распространенный и плодовитый вид теста. Целью модульного тестирования является проверка чистых функций без создания каких-либо побочных эффектов. Если вы запустите одну и ту же функцию 100 раз, один и тот же ожидаемый результат должен произойти 100 раз.
// sum.go package math // Sum takes a slice of integers and returns their sum. func Sum(numbers []int) int { sum := 0 for _, num := range numbers { sum += num } return sum } // sum_test.go package math_test import "testing" func TestSum(t *testing.T) { input := int[]{1, 2, 3} result := Sum(input) expected := 6 if result != expected { t.Errorf("Expected %d, but got %d for input %v", expected, result, input) } }
Интеграционное тестирование
Более специфический вид теста. Целью теста является интеграция различных частей системы, частей, которые могут выйти из строя и которые вы не можете контролировать. Это похоже на доступ к внешнему источнику, например к базе данных, или использованию какого-либо системного вызова, например чтения файла или записи в него. Обычно создаются макеты, заглушки и/или шпионы, чтобы избежать нестабильного характера исполнения.
// file_writer.go package file import ( "io/ioutil" "os" ) // WriteToFile writes content to a file with the given filename. func WriteToFile(filename, content string) error { return ioutil.WriteFile(filename, []byte(content), 0644) } // file_writer_test.go package file import ( "io/ioutil" "os" "testing" ) func TestWriteToFile(t *testing.T) { // Define a test filename and content. filename := "testfile.txt" content := "This is a test file." // Clean up the file after the test. defer func() { err := os.Remove(filename) if err != nil { t.Errorf("Error deleting test file: %v", err) } }() // Call the function to write to the file. err := WriteToFile(filename, content) if err != nil { t.Fatalf("Error writing to file: %v", err) } // Read the file to verify its content. fileContent, err := ioutil.ReadFile(filename) if err != nil { t.Fatalf("Error reading from file: %v", err) } // Check if the content matches the expected content. if string(fileContent) != content { t.Errorf("File content doesn't match. Expected: %s, Got: %s", content, string(fileContent)) } }
Теперь перейдем к реальному коду и его тестированию
Для нашего кода выполнения давайте используем код ниже
// pkg/database.go package pkg import ( "log" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) var connection *sqlx.DB func InitConnection() { db, err := sqlx.Connect("postgres", "user=postgres password=postgres dbname=lab sslmode=disable") if err != nil { log.Fatalln("failed to connect", err) } if _, err := db.Exec(getSql()); err != nil { log.Fatalln("failed to execute sql", err) } connection = db } // not the best way to do this, but it works for this context func getSql() string { return ` create table if not exists posts ( id serial primary key, title varchar(255) not null, body text not null, created_at timestamp default current_timestamp, updated_at timestamp default current_timestamp ); create table if not exists comments ( id serial primary key, post_id int not null references posts (id) on delete cascade, body text not null, created_at timestamp default current_timestamp ); ` } // pkg/service.go package pkg type Post struct { ID int `db:"id" json:"id"` Title string `db:"title" json:"title"` Body string `db:"body" json:"body"` CreatedAt string `db:"created_at" json:"created_at"` UpdatedAt string `db:"updated_at" json:"updated_at"` Comments []Comment `json:"comments"` } type Comment struct { ID int `db:"id" json:"id"` Body string `db:"body" json:"body"` PostID int `db:"post_id" json:"-"` CreatedAt string `db:"created_at" json:"created_at"` } func GetPostsWithComments() ([]Post, error) { var posts []Post if err := connection.Select(&posts, "select * from posts"); err != nil { return nil, err } for i := range posts { if err := connection.Select(&posts[i].Comments, "select * from comments where post_id = $1", posts[i].ID); err != nil { return nil, err } } return posts, nil } // main.go package main import ( "encoding/json" "fmt" "log" "github.com/stneto1/better-go-article/pkg" ) func main() { pkg.InitConnection() posts, err := pkg.GetPostsWithComments() if err != nil { log.Fatalln("failed to get posts", err) } jsonData, err := json.MarshalIndent(posts, "", " ") if err != nil { log.Fatalln("failed to marshal json", err) } fmt.Println(string(jsonData)) }
Код выше делает:
- Инициализирует глобальное соединение
- Получить все сообщения с комментариями
- Распечатать как красивый JSON для терминала
Просто и понятно. Теперь добавим тесты в основной код GetPostsWithComments
.
// pkg/service_test.go package pkg_test import ( "testing" "github.com/go-playground/assert/v2" "github.com/stneto1/better-go-article/pkg" ) func TestGetPostsWithComments(t *testing.T) { posts, err := pkg.GetPostsWithComments() assert.Equal(t, err, nil) assert.Equal(t, len(posts), 0) }
После запуска кода должна появиться ошибка, так как тест был запущен, но глобальное соединение не было инициализировано.
package pkg_test import ( "testing" "github.com/go-playground/assert/v2" "github.com/stneto1/better-go-article/pkg" ) func TestGetPostsWithComments(t *testing.T) { pkg.InitConnection() // remember to call before each test posts, err := pkg.GetPostsWithComments() assert.Equal(t, err, nil) assert.Equal(t, len(posts), 0) }
Теперь вы запускаете тесты, и они все проходят, отличная работа. Но если вы измените базу данных, тест может завершиться неудачно в будущем, поскольку нет никакой гарантии состояния базы данных при запуске тестов.
Какие проблемы с текущим кодом:
- Реальное подключение к БД
- Глобальная связь
- N+1 запросов
- Соединение не было закрыто
- Дырявая абстракция → Выходная модель = Модель БД
Давайте исправим некоторые из этих проблем
// pkg/database.go func InitConnection() *sqlx.DB { db, err := sqlx.Connect("postgres", "user=postgres password=postgres dbname=lab sslmode=disable") if err != nil { log.Fatalln("failed to connect", err) } if _, err := db.Exec(getSql()); err != nil { log.Fatalln("failed to execute sql", err) } return db } //pkg/service.go func GetPostsWithComments(conn *sqlx.DB) ([]Post, error) { // redacted } // main.go func main() { conn := pkg.InitConnection() defer conn.Close() posts, err := pkg.GetPostsWithComments(conn) // redacted } // pkg/service_test.go package pkg_test import ( "testing" "github.com/go-playground/assert/v2" "github.com/stneto1/better-go-article/pkg" ) func TestGetPostsWithComments(t *testing.T) { conn := pkg.InitConnection() defer conn.Close() posts, err := pkg.GetPostsWithComments(conn) // redacted }
Теперь функция GetPostsWithComments
получает соединение в качестве параметра, поэтому мы можем протестировать более контролируемое соединение. Мы также можем закрыть соединение после запуска тестов.
- Реальное подключение к БД
Глобальная связь- N+1 запросов
Соединение не было закрыто- Дырявая абстракция → Выходная модель = Модель БД
Реальное подключение к базе данных.
Для наших текущих тестов нам нужно соединение Postgres. Если вы работаете в среде, в которой нет указанного соединения, мы не сможем запустить наши тесты. Тогда давайте улучшим его. Обратите внимание на тип соединения с базой данных *sqlx.DB
, а не на соединение, специфичное для Postgres, поэтому, пока мы предоставляем действительное соединение sqlx, мы можем быть уверены, что наши тесты могут легко выполняться.
И для этого мы можем использовать sqlite, как в памяти, так и для записи в один файл, поскольку SQL по большей части является специальным языком, мы можем менять местами sqlite и postgres в зависимости от того, где мы работаем.
// pkg/database.go // redacted func InitTempDB() *sqlx.DB { // This commented line is to create a temporary database in /tmp, // in case you want to access the file itself // db, err := sqlx.Connect("sqlite3", fmt.Sprintf("file:/tmp/%s.db", ulid.MustNew(ulid.Now(), nil).String())) db, err := sqlx.Connect("sqlite3", ":memory:") if err != nil { log.Fatalln("failed to connect", err) } if _, err := db.Exec(getSql()); err != nil { log.Fatalln("failed to execute sql", err) } return db } // pkg/service_test.go package pkg_test import ( "testing" "github.com/go-playground/assert/v2" "github.com/stneto1/better-go-article/pkg" ) func TestGetPostsWithComments(t *testing.T) { conn := pkg.InitTempDB() defer conn.Close() posts, err := pkg.GetPostsWithComments(conn) assert.Equal(t, err, nil) assert.Equal(t, len(posts), 0) }
Наш основной код не меняется, так как нам нужно фактическое соединение для выполнения нашего приложения в рабочей среде, но для тестов мы можем использовать в памяти sqlite, чтобы иметь «чистую» базу данных для каждого теста.
Реальное подключение к БД- N+1 запросов
- Дырявая абстракция → Выходная модель = Модель БД
Теперь давайте исправим последние проблемы
Мы по-прежнему делаем n+1 запросов к нашей базе данных, один запрос к сообщениям и n запросов к комментариям. Для этого давайте создадим бизнес-структуру.
// pkg/service.go // redacted type postsWithCommentsRow struct { PostID int `db:"posts_id"` PostTitle string `db:"posts_title"` PostBody string `db:"posts_body"` PostCreatedAt string `db:"posts_created_at"` CommentID int `db:"comments_id"` CommentBody string `db:"comments_body"` CommentCreatedAt string `db:"comments_created_at"` } func GetPostsWithComments(conn *sqlx.DB) ([]Post, error) { var rawPosts []postsWithCommentsRow if err := conn.Select(&rawPosts, ` select posts.id as posts_id, posts.title as posts_title, posts.body as posts_body, posts.created_at as posts_created_at, comments.id as comments_id, comments.body as comments_body, comments.created_at as comments_created_at from posts left join comments on posts.id = comments.post_id order by posts.id; `); err != nil { return nil, err } posts := make([]Post, 0) OuterLoop: for _, rawPost := range rawPosts { post := Post{ ID: rawPost.PostID, Title: rawPost.PostTitle, Body: rawPost.PostBody, CreatedAt: rawPost.PostCreatedAt, } for _, post := range posts { if post.ID == rawPost.PostID { continue OuterLoop } } posts = append(posts, post) } for _, rawPost := range rawPosts { for i, post := range posts { if post.ID == rawPost.PostID { comment := Comment{ ID: rawPost.CommentID, Body: rawPost.CommentBody, CreatedAt: rawPost.CommentCreatedAt, } posts[i].Comments = append(posts[i].Comments, comment) } } } return posts, nil }
Наш код значительно увеличился. Но что это изменило? Во-первых, теперь мы делаем только один запрос к базе данных. Мы также вручную сопоставляем результат запроса со структурой бизнес-правил в структурах Post
и Comments
. Мы также отделили структуру базы данных postsWithCommentsRow
от наших внешних структур.
N+1 запросовДырявая абстракция → Выходная модель = Модель БД
Вот так мы устранили последние две проблемы.
Заключительные мысли
Поняв немного больше о тестах, мы взяли код Go, написали несколько тестов и улучшили код. И что теперь? Даже если вам не нравится go, вы все равно можете понять концепции, которые мы обсуждали здесь, и использовать их в своем собственном коде. Тестирование — это один из навыков, которому вы обучаетесь со временем и приобретая больше опыта.