Obsah
1. Druhá verze testované aplikace
2. Výsledky běhu jednotkových testů
3. Jednotkový test pro funkci provádějící zápis do tabulky
4. Výsledek pokusu o spuštění jednotkových testů
5. Korektní zápis jednotkového testu a výsledek běhu testů
6. Jednotkový test pro funkci provádějící vymazání záznamu z tabulky
7. Výsledek běhu testů, včetně zjištění pokrytí kódu testy
8. Simulace chyby na straně SQL databáze
9. Výsledek běhu testů, včetně zjištění pokrytí kódu testy
10. Mockování špatných typů sloupců v jednotkových testech
11. Výsledek běhu testů, včetně zjištění pokrytí kódu testy
12. Uzavření příkazu pro čtení dat z databáze v jednotkových testech
13. Výsledek běhu testů, včetně zjištění pokrytí kódu testy
14. Otestování funkce s transakcemi
15. Jednotkový test očekávající transakční operaci
16. Chování ve chvíli, kdy není transakce korektně ukončena
17. Výsledek běhu testů, včetně zjištění pokrytí kódu testy
19. Repositář s demonstračními příklady
1. Druhá verze testované aplikace
V dnešním článku dokončíme téma, kterému jsme se věnovali minule. Připomeňme si, že jsme si ukázali jeden ze způsobů tvorby jednotkových testů v jazyku Go pro ty funkce a metody, které přistupují k relační databázi, a to buď přímo s využitím balíčku database/sql nebo nepřímo, tj. většinou přes nějaký balíček zajišťující ORM (příkladem může být projekt GORM). Dnes si ukážeme způsob testování funkcí/metod, které do databáze přidávají nebo mažou řádky a taktéž takových funkcí, které používají databázové transakce.
Připomeňme si, že aplikace, kterou budeme testovat (resp. pro kterou budeme vytvářet jednotkové testy), používá přímo balíček database/sql ze standardní knihovny, a je v ní implementováno několik funkcí volajících SQL příkazy INSERT a DELETE i SQL dotaz SELECT:
package main import ( "fmt" "os" "database/sql" _ "github.com/lib/pq" // PostgreSQL database driver _ "github.com/mattn/go-sqlite3" // SQLite database driver "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) // Datová struktura s konfigurací připojení k databázi type StorageConfiguration struct { Driver string `mapstructure:"db_driver" toml:"db_driver"` SQLiteDataSource string `mapstructure:"sqlite_datasource" toml:"sqlite_datasource"` PGUsername string `mapstructure:"pg_username" toml:"pg_username"` PGPassword string `mapstructure:"pg_password" toml:"pg_password"` PGHost string `mapstructure:"pg_host" toml:"pg_host"` PGPort int `mapstructure:"pg_port" toml:"pg_port"` PGDBName string `mapstructure:"pg_db_name" toml:"pg_db_name"` PGParams string `mapstructure:"pg_params" toml:"pg_params"` } // Chybové zprávy const ( canNotConnectToDataStorageMessage = "Can not connect to data storage" connectionToDBNotEstablished = "Connection to database not established" unableToCloseDBRowsHandle = "Unable to close the DB rows handle" databaseOperationFailed = "Database operation failed" ) // Inicializace připojení k databázi func initDatabaseConnection(configuration StorageConfiguration) (*sql.DB, error) { driverName := configuration.Driver dataSource := "" log.Info().Str("driverName", configuration.Driver).Msg("DB connection configuration") // inicializace připojení s vybraným driverem switch driverName { case "sqlite3": //driverType := DBDriverSQLite3 //driver = &sqlite3.SQLiteDriver{} dataSource = configuration.SQLiteDataSource case "postgres": //driverType := DBDriverPostgres //driver = &pq.Driver{} dataSource = fmt.Sprintf( "postgresql://%v:%v@%v:%v/%v?%v", configuration.PGUsername, configuration.PGPassword, configuration.PGHost, configuration.PGPort, configuration.PGDBName, configuration.PGParams, ) default: // neznámý driver err := fmt.Errorf("driver %v is not supported", driverName) log.Err(err).Msg(canNotConnectToDataStorageMessage) return nil, err } // pokus o inicializaci připojení k databázi connection, err := sql.Open(driverName, dataSource) // test, zda bylo připojení k databázi úspěšné if err != nil { log.Err(err).Msg(canNotConnectToDataStorageMessage) return nil, err } return connection, nil } // Zobrazení všech záznamů v tabulce "persons" func displayAllRecords(connection *sql.DB) error { // dotaz do databáze query := "SELECT id, name, surname FROM persons" rows, err := connection.Query(query) // test, zda byl SQL příkaz proveden bez chyby if err != nil { return err } defer func() { // pokud dojde k chybě nebo na konci smyčky, musíme uvolnit prostředky if closeErr := rows.Close(); closeErr != nil { log.Error().Err(closeErr).Msg(unableToCloseDBRowsHandle) } }() // projít všemi vrácenými řádky for rows.Next() { var ( id int name string surname string ) // přečtení dat z jednoho vráceného řádku if err := rows.Scan(&id, &name, &surname); err != nil { return err } // výpis načteného záznamu log.Info().Int("ID", id). Str("name", name). Str("surname", surname). Msg("Record") } return nil } // datová struktura odpovídající struktuře záznamu v databázi type Record struct { Id int Name string Surname string } // funkce vracející data přečtená z databázové tabulky func readAllRecords(connection *sql.DB) ([]Record, error) { results := make([]Record, 0) // dotaz do databáze query := "SELECT id, name, surname FROM persons" rows, err := connection.Query(query) // test, zda byl SQL příkaz proveden bez chyby if err != nil { return results, err } defer func() { // pokud dojde k chybě nebo na konci smyčky, musíme uvolnit prostředky if closeErr := rows.Close(); closeErr != nil { log.Error().Err(closeErr).Msg(unableToCloseDBRowsHandle) } }() // projít všemi vrácenými řádky for rows.Next() { var record Record // přečtení dat z jednoho vráceného řádku if err := rows.Scan(&record.Id, &record.Name, &record.Surname); err != nil { return results, err } results = append(results, record) } return results, nil } // funkce vracející data přečtená z databázové tabulky func readRecordsWithName(connection *sql.DB, name string) ([]Record, error) { results := make([]Record, 0) // dotaz do databáze query := "SELECT id, name, surname FROM persons WHERE name=$1" rows, err := connection.Query(query, name) // test, zda byl SQL příkaz proveden bez chyby if err != nil { return results, err } defer func() { // pokud dojde k chybě nebo na konci smyčky, musíme uvolnit prostředky if closeErr := rows.Close(); closeErr != nil { log.Error().Err(closeErr).Msg(unableToCloseDBRowsHandle) } }() // projít všemi vrácenými řádky for rows.Next() { var record Record // přečtení dat z jednoho vráceného řádku if err := rows.Scan(&record.Id, &record.Name, &record.Surname); err != nil { return results, err } results = append(results, record) } return results, nil } // Vložení nového záznamu do tabulky "persons" func insertRecord(connection *sql.DB, name string, surname string) (int, error) { // provedení SQL příkazu se dvěma parametry sqlStatement := "INSERT INTO persons (name, surname) VALUES($1, $2);" result, err := connection.Exec(sqlStatement, name, surname) // test, zda byl SQL příkaz proveden bez chyby if err != nil { return 0, err } // přečíst počet řádků v tabulce, které byly SQL příkazem upraveny affected, err := result.RowsAffected() // i tato operace může teoreticky skončit s chybou nebo nemusí být podporována if err != nil { return 0, err } return int(affected), nil } // Vymazání záznamu nebo záznamů na základě zapsaného jména func deleteByName(connection *sql.DB, name string) (int, error) { // provedení SQL příkazu s jedním parametrem sqlStatement := "DELETE FROM persons WHERE name = $1;" result, err := connection.Exec(sqlStatement, name) // test, zda byl SQL příkaz proveden bez chyby if err != nil { return 0, err } // přečíst počet řádků v tabulce, které byly SQL příkazem upraveny affected, err := result.RowsAffected() // i tato operace může teoreticky skončit s chybou nebo nemusí být podporována if err != nil { return 0, err } return int(affected), nil } func main() { // nastavit logovací systém pro barevný výstup na terminál log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) // konfigurace připojení do databáze config := StorageConfiguration{ Driver: "postgres", PGUsername: "postgres", PGPassword: "postgres", PGHost: "localhost", PGPort: 5432, PGDBName: "testdb", PGParams: "sslmode=disable", } log.Debug().Msg("Started") // inicializace připojení k databázi connection, err := initDatabaseConnection(config) if err != nil { log.Err(err).Msg(connectionToDBNotEstablished) return } log.Info().Msg("Read all records") // přečtení všech záznamů results, err := readAllRecords(connection) if err != nil { log.Err(err).Msg(databaseOperationFailed) return } // výpis získaných záznamů for _, result := range results { log.Info().Int("ID", result.Id). Str("name", result.Name). Str("surname", result.Surname). Msg("Record") } log.Info().Msg("Display all records") // přečtení všech záznamů z tabulky "persons" err = displayAllRecords(connection) if err != nil { log.Err(err).Msg(databaseOperationFailed) return } // vymazání záznamu či záznamů na základě zapsaného jména affected, err := deleteByName(connection, "Eda") if err != nil { log.Err(err).Msg(databaseOperationFailed) return } log.Info().Int("deleted rows", affected).Msg("DELETE") // přidání nového záznamu do databáze affected, err = insertRecord(connection, "Eda", "Vodopád") if err != nil { log.Err(err).Msg(databaseOperationFailed) return } log.Info().Int("inserted rows", affected).Msg("INSERT") // přečtení všech záznamů z tabulky "persons" err = displayAllRecords(connection) if err != nil { log.Err(err).Msg(databaseOperationFailed) return } log.Info().Msg("Read records with name Přemysl") // přečtení záznamů results, err = readRecordsWithName(connection, "Přemysl") if err != nil { log.Err(err).Msg(databaseOperationFailed) return } // výpis získaných záznamů for _, result := range results { log.Info().Int("ID", result.Id). Str("name", result.Name). Str("surname", result.Surname). Msg("Record") } log.Debug().Msg("Finished") }
2. Výsledky běhu jednotkových testů
Podívejme se nyní na výsledky běhu jednotkových testů. Následující příkaz zajistí, že se testy spustí a současně se do souboru coverage.out zapíše informace o tom, které řádky testovaného kódu (aplikace) byly skutečně použity v jednotkových testech:
$ go test -v -coverprofile coverage.out === RUN TestSelect1 --- PASS: TestSelect1 (0.00s) === RUN TestSelect2 {"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-23T17:48:52+01:00","message":"Record"} {"level":"info","ID":2,"name":"x","surname":"y","time":"2021-03-23T17:48:52+01:00","message":"Record"} {"level":"info","ID":3,"name":"a","surname":"b","time":"2021-03-23T17:48:52+01:00","message":"Record"} --- PASS: TestSelect2 (0.00s) === RUN TestSelect3 --- PASS: TestSelect3 (0.00s) === RUN TestSelect4 --- PASS: TestSelect4 (0.00s) === RUN TestSelect5 --- PASS: TestSelect5 (0.00s) PASS coverage: 20.8% of statements ok db-test 0.003s
Další příkaz provede analýzu obsahu souboru coverage.out a vypíše souhrnné informace o pokrytí zdrojového kódu rozděleného na funkce a metody:
$ go tool cover -func=coverage.out db-test/db_operations.go:37: initDatabaseConnection 0.0% db-test/db_operations.go:80: displayAllRecords 76.9% db-test/db_operations.go:128: readAllRecords 78.6% db-test/db_operations.go:163: readRecordsWithName 78.6% db-test/db_operations.go:198: insertRecord 0.0% db-test/db_operations.go:219: deleteByName 0.0% db-test/db_operations.go:307: main 0.0% total: (statements) 20.8%
V rámci dalších kapitol se budeme snažit pokrytí zlepšit, ovšem pouze u funkcí provádějících operace s databází. Konkrétně se jedná o tyto funkce:
- displayAllRecords
- readAllRecords
- readRecordsWithName
- insertRecord
- deleteByName

Obrázek 1: Vizuální znázornění pokrytí zdrojového kódu jednotkovými testy (stále se používají standardní nástroje jazyka Go).
3. Jednotkový test pro funkci provádějící zápis do tabulky
Další jednotkový test, který se pokusíme vytvořit v rámci dnešního článku, bude testovat chování funkce, která provádí zápis nového řádku do databáze (s kontrolou chyb atd.). Jedná se o funkci nazvanou insertRecord s tímto obsahem:
// Vložení nového záznamu do tabulky "persons" func insertRecord(connection *sql.DB, name string, surname string) (int, error) { // provedení SQL příkazu se dvěma parametry sqlStatement := "INSERT INTO persons (name, surname) VALUES($1, $2);" result, err := connection.Exec(sqlStatement, name, surname) // test, zda byl SQL příkaz proveden bez chyby if err != nil { return 0, err } // přečíst počet řádků v tabulce, které byly SQL příkazem upraveny affected, err := result.RowsAffected() // i tato operace může teoreticky skončit s chybou nebo nemusí být podporována if err != nil { return 0, err } return int(affected), nil }
Nejprve je nutné přidat novou proměnnou do souboru export_test.go, a to z toho důvodu, že funkce, jejíž název začíná malým písmenem, není z jednotkových testů přímo viditelná:
package main var ( DisplayAllRecords = displayAllRecords ReadAllRecords = readAllRecords ReadRecordsWithName = readRecordsWithName InsertRecord = insertRecord )
V jednotkovém testu se pokusíme zjistit, zda byl zavolán příslušný SQL příkaz INSERT. Přitom mockovaný SQL driver instruujeme, aby simuloval vrácení jednoho výsledku a změnu jediného řádku v tabulce (protože s těmito údaji testovaná funkce částečně pracuje):
mock.ExpectExec("INSERT INTO persons (name, surname) VALUES($1, $2);").WithArgs("foo", "bar").WillReturnResult(sqlmock.NewResult(1, 1))
Zbytek testu se příliš neliší od testů, které jsme si již ukázali, takže ve stručnosti:
func TestInsertion1(t *testing.T) { connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() mock.ExpectExec("INSERT INTO persons (name, surname) VALUES($1, $2);").WithArgs("foo", "bar").WillReturnResult(sqlmock.NewResult(1, 1)) updated, err := main.InsertRecord(connection, "foo", "bar") if err != nil { t.Errorf("error was not expected while updating stats: %s", err) } if updated != 1 { t.Errorf("one row should be updated, but %d rows were updated", updated) } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } }
4. Výsledek pokusu o spuštění jednotkových testů
Zápis jednotkového testu vypadá – alespoň zdánlivě a na první pohled – v pořádku, takže si nyní vyzkoušejme, jak se nový test bude chovat v praxi:
$ go test -v -coverprofile coverage.out === RUN TestSelect1 --- PASS: TestSelect1 (0.00s) === RUN TestSelect2 {"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-20T18:40:30+01:00","message":"Record"} {"level":"info","ID":2,"name":"x","surname":"y","time":"2021-03-20T18:40:30+01:00","message":"Record"} {"level":"info","ID":3,"name":"a","surname":"b","time":"2021-03-20T18:40:30+01:00","message":"Record"} --- PASS: TestSelect2 (0.00s) === RUN TestSelect3 --- PASS: TestSelect3 (0.00s) === RUN TestSelect4 --- PASS: TestSelect4 (0.00s) === RUN TestSelect5 --- PASS: TestSelect5 (0.00s) === RUN TestInsertion1 --- FAIL: TestInsertion1 (0.00s) db_operations_test.go:262: error was not expected while updating stats: ExecQuery: could not match actual sql: "INSERT INTO persons (name, surname) VALUES($1, $2);" with expected regexp "INSERT INTO persons (name, surname) VALUES($1, $2);" db_operations_test.go:266: one row should be updated, but 0 rows were updated db_operations_test.go:271: there were unfulfilled expectations: there is a remaining expectation which was not matched: ExpectedExec => expecting Exec or ExecContext which: - matches sql: 'INSERT INTO persons (name, surname) VALUES($1, $2);' - is with arguments: 0 - foo 1 - bar - should return Result having: LastInsertId: 1 RowsAffected: 1 FAIL exit status 1 FAIL db-test 0.004s
Povšimněte si, že nový test ve skutečnosti zhavaroval, a to navíc s poněkud zvláštní chybou:
db_operations_test.go:262: error was not expected while updating stats: ExecQuery: could not match actual sql: "INSERT INTO persons (name, surname) VALUES($1, $2);" with expected regexp "INSERT INTO persons (name, surname) VALUES($1, $2);"
Oba vypsané řetězce jsou totožné, ovšem jádro problému spočívá v tom, že do testu ve skutečnosti nezadáváme řetězec, ale regulární výraz, s nímž je skutečný SQL příkaz porovnáván (resp. matchován). Rozdíl spočívá v tom, že v regulárním výrazu mají znaky „(“, „$“ atd. speciální význam a pokud budeme chtít otestovat, zda se v příkazu skutečně nachází znak dolaru, je nutné regulární výraz zapsat nepatrně odlišným způsobem.
5. Korektní zápis jednotkového testu a výsledek běhu testů
Jednotkový test tedy musíme nějakým způsobem opravit. Jedná se o úpravu následujícího řádku, konkrétněji parametru metody ExpectExec:
mock.ExpectExec("INSERT INTO persons (name, surname) VALUES($1, $2);").WithArgs("foo", "bar").WillReturnResult(sqlmock.NewResult(1, 1))
Před všechny znaky, které mají v regulárním výrazu speciální význam, je nutné zapsat znak zpětného lomítka:
mock.ExpectExec("INSERT INTO persons \(name, surname\) VALUES\(\$1, \$2\);").WithArgs("foo", "bar").WillReturnResult(sqlmock.NewResult(1, 1))
To však ve skutečnosti taktéž není korektní, neboť znak zpětného lomítka má speciální význam při zápisu běžných řetězců (jak v Go, tak i například v C i dalších jazycích). Zpětné lomítko je tedy nutné zdvojit:
mock.ExpectExec("INSERT INTO persons \\(name, surname\\) VALUES\\(\\$1, \\$2\\);").WithArgs("foo", "bar").WillReturnResult(sqlmock.NewResult(1, 1))
Nyní již tedy můžeme vytvořit nový jednotkový rest, který by měl být zapsán korektně:
func TestInsertion2(t *testing.T) { connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() mock.ExpectExec("INSERT INTO persons \\(name, surname\\) VALUES\\(\\$1, \\$2\\);").WithArgs("foo", "bar").WillReturnResult(sqlmock.NewResult(1, 1)) updated, err := main.InsertRecord(connection, "foo", "bar") if err != nil { t.Errorf("error was not expected while updating stats: %s", err) } if updated != 1 { t.Errorf("one row should be updated, but %d rows were updated", updated) } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } }
Výsledek běhu jednotkových testů:
=== RUN TestSelect1 --- PASS: TestSelect1 (0.00s) === RUN TestSelect2 {"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-20T18:40:30+01:00","message":"Record"} {"level":"info","ID":2,"name":"x","surname":"y","time":"2021-03-20T18:40:30+01:00","message":"Record"} {"level":"info","ID":3,"name":"a","surname":"b","time":"2021-03-20T18:40:30+01:00","message":"Record"} --- PASS: TestSelect2 (0.00s) === RUN TestSelect3 --- PASS: TestSelect3 (0.00s) === RUN TestSelect4 --- PASS: TestSelect4 (0.00s) === RUN TestSelect5 --- PASS: TestSelect5 (0.00s) === RUN TestInsertion1 --- FAIL: TestInsertion1 (0.00s) db_operations_test.go:262: error was not expected while updating stats: ExecQuery: could not match actual sql: "INSERT INTO persons (name, surname) VALUES($1, $2);" with expected regexp "INSERT INTO persons (name, surname) VALUES($1, $2);" db_operations_test.go:266: one row should be updated, but 0 rows were updated db_operations_test.go:271: there were unfulfilled expectations: there is a remaining expectation which was not matched: ExpectedExec => expecting Exec or ExecContext which: - matches sql: 'INSERT INTO persons (name, surname) VALUES($1, $2);' - is with arguments: 0 - foo 1 - bar - should return Result having: LastInsertId: 1 RowsAffected: 1 === RUN TestInsertion2 --- PASS: TestInsertion2 (0.00s) FAIL exit status 1 FAIL db-test 0.004s
Vidíme, že došlo ke zlepšení pokrytí kódu, a to (podle očekávání) právě u funkce insertRecord (z nuly na plných 87.5%):
db-test/db_operations.go:37: initDatabaseConnection 0.0% db-test/db_operations.go:80: displayAllRecords 76.9% db-test/db_operations.go:128: readAllRecords 78.6% db-test/db_operations.go:163: readRecordsWithName 78.6% db-test/db_operations.go:198: insertRecord 87.5% db-test/db_operations.go:219: deleteByName 0.0% db-test/db_operations.go:239: main 0.0% total: (statements) 34.8%

Obrázek 2: Pokrytí testovaného kódu.
6. Jednotkový test pro funkci provádějící vymazání záznamu z tabulky
Další funkcí, kterou otestujeme, je funkce provádějící vymazání záznamu z tabulky. Tato funkce vypadá následovně:
// Vymazání záznamu nebo záznamů na základě zapsaného jména func deleteByName(connection *sql.DB, name string) (int, error) { // provedení SQL příkazu s jedním parametrem sqlStatement := "DELETE FROM persons WHERE name = $1;" result, err := connection.Exec(sqlStatement, name) // test, zda byl SQL příkaz proveden bez chyby if err != nil { return 0, err } // přečíst počet řádků v tabulce, které byly SQL příkazem upraveny affected, err := result.RowsAffected() // i tato operace může teoreticky skončit s chybou nebo nemusí být podporována if err != nil { return 0, err } return int(affected), nil }
Upravíme soubor export_test.go:
package main var ( DisplayAllRecords = displayAllRecords ReadAllRecords = readAllRecords ReadRecordsWithName = readRecordsWithName InsertRecord = insertRecord DeleteByName = deleteByName )
A napíšeme příslušný jednotkový test, nyní již ovšem se znalostí toho, jak pracovat se speciálními znaky v regulárním výrazu:
func TestDeletion1(t *testing.T) { connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() mock.ExpectExec("DELETE FROM persons WHERE name = \\$1;").WithArgs("foo").WillReturnResult(sqlmock.NewResult(1, 1)) updated, err := main.DeleteByName(connection, "foo") if err != nil { t.Errorf("error was not expected while updating stats: %s", err) } if updated != 1 { t.Errorf("one row should be updated, but %d rows were updated", updated) } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } }
7. Výsledek běhu testů, včetně zjištění pokrytí kódu testy
Nyní by již mělo být možné jednotkové testy spustit. Použijeme k tomu následující příkaz, který mj. zajistí i vytvoření souboru s informacemi o pokrytí testovaného kódu jednotkovými testy:
$ go test -v -coverprofile coverage.out === RUN TestSelect1 --- PASS: TestSelect1 (0.00s) === RUN TestSelect2 {"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-23T17:56:23+01:00","message":"Record"} {"level":"info","ID":2,"name":"x","surname":"y","time":"2021-03-23T17:56:23+01:00","message":"Record"} {"level":"info","ID":3,"name":"a","surname":"b","time":"2021-03-23T17:56:23+01:00","message":"Record"} --- PASS: TestSelect2 (0.00s) === RUN TestSelect3 --- PASS: TestSelect3 (0.00s) === RUN TestSelect4 --- PASS: TestSelect4 (0.00s) === RUN TestSelect5 --- PASS: TestSelect5 (0.00s) === RUN TestInsertion1 --- FAIL: TestInsertion1 (0.00s) db_operations_test.go:170: error was not expected while updating stats: ExecQuery: could not match actual sql: "INSERT INTO persons (name, surname) VALUES($1, $2);" with expected regexp "INSERT INTO persons (name, surname) VALUES($1, $2);" db_operations_test.go:174: one row should be updated, but 0 rows were updated db_operations_test.go:179: there were unfulfilled expectations: there is a remaining expectation which was not matched: ExpectedExec => expecting Exec or ExecContext which: - matches sql: 'INSERT INTO persons (name, surname) VALUES($1, $2);' - is with arguments: 0 - foo 1 - bar - should return Result having: LastInsertId: 1 RowsAffected: 1 === RUN TestInsertion2 --- PASS: TestInsertion2 (0.00s) === RUN TestDeletion1 --- PASS: TestDeletion1 (0.00s) FAIL coverage: 37.2% of statements exit status 1 FAIL db-test 0.004s
Pokrytí jednotlivých funkcí jednotkovými testy se zjistí (resp. přesněji řečeno vypíše) následujícím způsobem:
$ go tool cover -func=coverage.out db-test/db_operations.go:37: initDatabaseConnection 0.0% db-test/db_operations.go:80: displayAllRecords 76.9% db-test/db_operations.go:128: readAllRecords 78.6% db-test/db_operations.go:163: readRecordsWithName 78.6% db-test/db_operations.go:198: insertRecord 87.5% db-test/db_operations.go:219: deleteByName 75.0% db-test/db_operations.go:309: main 0.0% total: (statements) 37.2%

Obrázek 3: Pokrytí testovaného kódu.
8. Simulace chyby na straně SQL databáze
Podívejme se ještě jednou na úryvek programového kódu, ve kterém se provádí nějaký SQL příkaz, popř. dotaz:
sqlStatement := "INSERT INTO persons (name, surname) VALUES($1, $2);" result, err := connection.Exec(sqlStatement, name, surname) // test, zda byl SQL příkaz proveden bez chyby if err != nil { return 0, err }
Již víme, jakým způsobem otestovat vlastní příkaz/dotaz, ovšem ještě nám zbývá ověřit, co se stane ve chvíli, když nastane chyba – tedy otestovat podmínku a její tělo. I to je pochopitelně možné. Nejprve zkonstruujeme objekt typu error, který obsahuje informace o chybě (resp. přesněji řečeno mockované chybě):
mockedError := errors.New("mocked error")
Dále můžeme specifikovat, že výsledkem SQL dotazu bude chyba, což zajišťuje mockovaný SQL driver (viz zvýrazněná metoda na konci celého řetězce):
mock.ExpectExec("INSERT INTO persons \\(name, surname\\) VALUES\\(\\$1, \\$2\\);").WithArgs("foo", "bar").WillReturnError(mockedError)
Tímto způsobem můžeme vytvořit celou řadu jednotkových testů pro všechny testované funkce, které pracují s databází.
Příkaz pro vložení záznamu do databáze končící chybou:
func TestInsertionOnError(t *testing.T) { mockedError := errors.New("mocked error") connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() mock.ExpectExec("INSERT INTO persons \\(name, surname\\) VALUES\\(\\$1, \\$2\\);").WithArgs("foo", "bar").WillReturnError(mockedError) _, err = main.InsertRecord(connection, "foo", "bar") if err == nil { t.Fatalf("error was expected while updating stats") } if err != mockedError { t.Errorf("different error was returned: %v", err) } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } }
Příkaz pro smazání záznamu z databáze končící chybou:
func TestDeletionOnError(t *testing.T) { mockedError := errors.New("mocked error") connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() mock.ExpectExec("DELETE FROM persons WHERE name = \\$1;").WithArgs("foo").WillReturnError(mockedError) _, err = main.DeleteByName(connection, "foo") if err == nil { t.Fatalf("error was expected while updating stats") } if err != mockedError { t.Errorf("different error was returned: %v", err) } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } }
Dotazy do databáze končící chybou:
func TestSelectOnError1(t *testing.T) { mockedError := errors.New("mocked error") connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() mock.ExpectQuery("SELECT id, name, surname FROM persons").WillReturnError(mockedError) err = main.DisplayAllRecords(connection) if err == nil { t.Fatalf("error was expected while updating stats") } if err != mockedError { t.Errorf("different error was returned: %v", err) } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } }
Druhý test s dotazem do databáze:
func TestSelectOnError2(t *testing.T) { mockedError := errors.New("mocked error") connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() mock.ExpectQuery("SELECT id, name, surname FROM persons").WillReturnError(mockedError) results, err := main.ReadAllRecords(connection) if err == nil { t.Fatalf("error was expected while updating stats") } if err != mockedError { t.Errorf("different error was returned: %v", err) } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } if len(results) != 0 { t.Errorf("different number of results read from database: %d instead of 0", len(results)) return } }
Třetí a současně i poslední test s dotazem do databáze končící chybou:
func TestSelectOnError3(t *testing.T) { mockedError := errors.New("mocked error") connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() mock.ExpectQuery("SELECT id, name, surname FROM persons WHERE name=.*").WillReturnError(mockedError) results, err := main.ReadRecordsWithName(connection, "Eda") if err == nil { t.Fatalf("error was expected while updating stats") } if err != mockedError { t.Errorf("different error was returned: %v", err) } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } if len(results) != 0 { t.Errorf("different number of results read from database: %d instead of 0", len(results)) return } }
9. Výsledek běhu testů, včetně zjištění pokrytí kódu testy
Opět se podívejme na výsledky běhu jednotkových testů, včetně výpočtu pokrytí kódu testy. Počet pokrytých řádků by se měl podle všech předpokladů zvýšit:
$ go test -v -coverprofile coverage.out === RUN TestSelect1 --- PASS: TestSelect1 (0.00s) === RUN TestSelect2 {"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-20T18:40:30+01:00","message":"Record"} {"level":"info","ID":2,"name":"x","surname":"y","time":"2021-03-20T18:40:30+01:00","message":"Record"} {"level":"info","ID":3,"name":"a","surname":"b","time":"2021-03-20T18:40:30+01:00","message":"Record"} --- PASS: TestSelect2 (0.00s) === RUN TestSelect3 --- PASS: TestSelect3 (0.00s) === RUN TestSelect4 --- PASS: TestSelect4 (0.00s) === RUN TestSelect5 --- PASS: TestSelect5 (0.00s) === RUN TestSelectOnError1 --- PASS: TestSelectOnError1 (0.00s) === RUN TestSelectOnError2 --- PASS: TestSelectOnError2 (0.00s) === RUN TestSelectOnError3 --- PASS: TestSelectOnError3 (0.00s) === RUN TestInsertion1 --- FAIL: TestInsertion1 (0.00s) db_operations_test.go:262: error was not expected while updating stats: ExecQuery: could not match actual sql: "INSERT INTO persons (name, surname) VALUES($1, $2);" with expected regexp "INSERT INTO persons (name, surname) VALUES($1, $2);" db_operations_test.go:266: one row should be updated, but 0 rows were updated db_operations_test.go:271: there were unfulfilled expectations: there is a remaining expectation which was not matched: ExpectedExec => expecting Exec or ExecContext which: - matches sql: 'INSERT INTO persons (name, surname) VALUES($1, $2);' - is with arguments: 0 - foo 1 - bar - should return Result having: LastInsertId: 1 RowsAffected: 1 === RUN TestInsertion2 --- PASS: TestInsertion2 (0.00s) === RUN TestInsertionOnError --- PASS: TestInsertionOnError (0.00s) === RUN TestDeletion1 --- PASS: TestDeletion1 (0.00s) === RUN TestDeletionOnError --- PASS: TestDeletionOnError (0.00s) FAIL coverage: 43.8% of statements exit status 1 FAIL db-test 0.004s
Výpočet pokrytí jednotlivých funkcí jednotkovými testy:
$ go tool cover -func=coverage.out db-test/db_operations.go:37: initDatabaseConnection 0.0% db-test/db_operations.go:80: displayAllRecords 76.9% db-test/db_operations.go:128: readAllRecords 78.6% db-test/db_operations.go:163: readRecordsWithName 78.6% db-test/db_operations.go:198: insertRecord 87.5% db-test/db_operations.go:219: deleteByName 87.5% db-test/db_operations.go:239: main 0.0% total: (statements) 43.8%

Obrázek 4: Nové výsledky jednotkových testů.
10. Mockování špatných typů sloupců v jednotkových testech
Další operací, která může v aplikaci zhavarovat, je zpracování výsledků SQL dotazů. Jak v relační databázi, tak i v programovacím jazyce Go mají jednotlivé sloupce definovány datový typ, přičemž jednotlivé typy musí korespondovat – například pokud je v databázi uložen klíč formou celého čísla, měla by být na straně Go taková hodnota reprezentována datovým typem int, uint apod. To je ostatně patrné i při pohledu na tu část zdrojového kódu, která načítání provádí:
var ( id int name string surname string ) // přečtení dat z jednoho vráceného řádku if err := rows.Scan(&id, &name, &surname); err != nil { return err }
Popř. ve vylepšené verzi:
// datová struktura odpovídající struktuře záznamu v databázi type Record struct { Id int Name string Surname string } var record Record // přečtení dat z jednoho vráceného řádku if err := rows.Scan(&record.Id, &record.Name, &record.Surname); err != nil { return results, err }
Při čtení je nutné testovat, zda nedošlo k chybě (viz předchozí podmínka) a právě existenci této podmínky můžeme zkontrolovat v jednotkových testech. Postačuje pouze vrátit špatná data, resp. data se špatným typem, z mockovaného SQL driveru:
rows := sqlmock.NewRows([]string{"id", "name", "surname"}) rows.AddRow(1, "foo", "bar") rows.AddRow("this is not integer", "x", "y") rows.AddRow(3, "a", "b")
Zpracování takto upravených, nebo chcete-li rozbitých, záznamů můžeme zařadit do našich jednotkových testů se zjištěním, zda se skutečně vrací očekávaná chyba.
Test pro funkci displayAllRecords:
func TestSelectScanError1(t *testing.T) { connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() rows := sqlmock.NewRows([]string{"id", "name", "surname"}) rows.AddRow(1, "foo", "bar") rows.AddRow("this is not integer", "x", "y") rows.AddRow(3, "a", "b") mock.ExpectQuery("SELECT id, name, surname FROM persons").WillReturnRows(rows) err = main.DisplayAllRecords(connection) if err == nil { t.Fatalf("error was expected while updating stats") } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } (
Test pro funkci readAllRecords:
func TestSelectScanError2(t *testing.T) { connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() rows := sqlmock.NewRows([]string{"id", "name", "surname"}) rows.AddRow(1, "foo", "bar") rows.AddRow("this is not integer", "x", "y") rows.AddRow(3, "a", "b") mock.ExpectQuery("SELECT id, name, surname FROM persons").WillReturnRows(rows) results, err := main.ReadAllRecords(connection) if err == nil { t.Fatalf("error was expected while updating stats") } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } // první řádek by měl být přečten if len(results) != 1 { t.Errorf("different number of results read from database: %d instead of 1", len(results)) return } }
A konečně následuje test pro funkci readRecordsWithName:
func TestSelectScanError3(t *testing.T) { connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() rows := sqlmock.NewRows([]string{"id", "name", "surname"}) rows.AddRow(1, "foo", "bar") rows.AddRow("this is not integer", "x", "y") rows.AddRow(3, "a", "b") mock.ExpectQuery("SELECT id, name, surname FROM persons").WillReturnRows(rows) results, err := main.ReadRecordsWithName(connection, "foo") if err == nil { t.Fatalf("error was expected while updating stats") } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } // první řádek by měl být přečten if len(results) != 1 { t.Errorf("different number of results read from database: %d instead of 1", len(results)) return } }
11. Výsledek běhu testů, včetně zjištění pokrytí kódu testy
Pokud jednotkové testy znovu spustíme, mělo by se zvýšit pokrytí všech tří funkcí, které provádí SQL dotazy:
$ go test -v -coverprofile coverage.out === RUN TestSelect1 --- PASS: TestSelect1 (0.00s) === RUN TestSelect2 {"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-20T18:58:25+01:00","message":"Record"} {"level":"info","ID":2,"name":"x","surname":"y","time":"2021-03-20T18:58:25+01:00","message":"Record"} {"level":"info","ID":3,"name":"a","surname":"b","time":"2021-03-20T18:58:25+01:00","message":"Record"} --- PASS: TestSelect2 (0.00s) === RUN TestSelect3 --- PASS: TestSelect3 (0.00s) === RUN TestSelect4 --- PASS: TestSelect4 (0.00s) === RUN TestSelect5 --- PASS: TestSelect5 (0.00s) === RUN TestSelectOnError1 --- PASS: TestSelectOnError1 (0.00s) === RUN TestSelectOnError2 --- PASS: TestSelectOnError2 (0.00s) === RUN TestSelectOnError3 --- PASS: TestSelectOnError3 (0.00s) === RUN TestSelectScanError1 {"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-20T18:58:25+01:00","message":"Record"} --- PASS: TestSelectScanError1 (0.00s) === RUN TestSelectScanError2 --- PASS: TestSelectScanError2 (0.00s) === RUN TestSelectScanError3 --- PASS: TestSelectScanError3 (0.00s) === RUN TestInsertion1 --- FAIL: TestInsertion1 (0.00s) db_operations_test.go:352: error was not expected while updating stats: ExecQuery: could not match actual sql: "INSERT INTO persons (name, surname) VALUES($1, $2);" with expected regexp "INSERT INTO persons (name, surname) VALUES($1, $2);" db_operations_test.go:356: one row should be updated, but 0 rows were updated db_operations_test.go:361: there were unfulfilled expectations: there is a remaining expectation which was not matched: ExpectedExec => expecting Exec or ExecContext which: - matches sql: 'INSERT INTO persons (name, surname) VALUES($1, $2);' - is with arguments: 0 - foo 1 - bar - should return Result having: LastInsertId: 1 RowsAffected: 1 === RUN TestInsertion2 --- PASS: TestInsertion2 (0.00s) === RUN TestInsertionOnError --- PASS: TestInsertionOnError (0.00s) === RUN TestDeletion1 --- PASS: TestDeletion1 (0.00s) === RUN TestDeletionOnError --- PASS: TestDeletionOnError (0.00s) FAIL coverage: 46.4% of statements exit status 1 FAIL db-test 0.004s
Změna v poměru pokrytí zdrojového textu jednotkovými testy je více patrná na následujícím reportu:
db-test/db_operations.go:37: initDatabaseConnection 0.0% db-test/db_operations.go:80: displayAllRecords 92.3% db-test/db_operations.go:128: readAllRecords 92.9% db-test/db_operations.go:163: readRecordsWithName 92.9% db-test/db_operations.go:198: insertRecord 87.5% db-test/db_operations.go:219: deleteByName 87.5% db-test/db_operations.go:239: main 0.0% total: (statements) 46.4%

Obrázek 5: Nové výsledky jednotkových testů.
12. Uzavření příkazu pro čtení dat z databáze v jednotkových testech
Mj. i z výsledků předchozích testů je zřejmé, že je vhodné provést ještě jednu kontrolu – jak se bude aplikace chovat ve chvíli, kdy je spojení s databází přerušeno v okamžiku zpracování výsledků SQL dotazu, tedy příkazu SELECT. I takovouto chybu je možné v mockovaném SQL driveru simulovat, a to konkrétně specifikací chyby přímo ve vrácených datech:
rows := sqlmock.NewRows([]string{"id", "name", "surname"}) rows.AddRow(1, "foo", "bar") rows.CloseError(errors.New("close error")) rows.AddRow("this is not integer", "x", "y") rows.AddRow(3, "a", "b")
Celý test pro funkci displayAllRecords může vypadat následovně:
func TestSelectCloseError1(t *testing.T) { connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() rows := sqlmock.NewRows([]string{"id", "name", "surname"}) rows.AddRow(1, "foo", "bar") rows.CloseError(errors.New("close error")) rows.AddRow("this is not integer", "x", "y") rows.AddRow(3, "a", "b") mock.ExpectQuery("SELECT id, name, surname FROM persons").WillReturnRows(rows) err = main.DisplayAllRecords(connection) if err == nil { t.Fatalf("error was expected while updating stats") } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } }
Test pro další funkci provádějící dotaz do relační databáze:
func TestSelectCloseError2(t *testing.T) { connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() rows := sqlmock.NewRows([]string{"id", "name", "surname"}) rows.AddRow(1, "foo", "bar") rows.CloseError(errors.New("close error")) rows.AddRow("this is not integer", "x", "y") rows.AddRow(3, "a", "b") mock.ExpectQuery("SELECT id, name, surname FROM persons").WillReturnRows(rows) _, err = main.ReadAllRecords(connection) if err == nil { t.Fatalf("error was expected while updating stats") } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } }
A konečně test třetí:
func TestSelectCloseError3(t *testing.T) { connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() rows := sqlmock.NewRows([]string{"id", "name", "surname"}) rows.AddRow(1, "foo", "bar") rows.CloseError(errors.New("close error")) rows.AddRow("this is not integer", "x", "y") rows.AddRow(3, "a", "b") mock.ExpectQuery("SELECT id, name, surname FROM persons .*").WillReturnRows(rows) _, err = main.ReadRecordsWithName(connection, "foobar") if err == nil { t.Fatalf("error was expected while updating stats") } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } }
13. Výsledek běhu testů, včetně zjištění pokrytí kódu testy
Znovu spustíme všechny jednotkové testy:
$ go test -v -coverprofile coverage.out === RUN TestSelect1 --- PASS: TestSelect1 (0.00s) === RUN TestSelect2 {"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-20T19:04:00+01:00","message":"Record"} {"level":"info","ID":2,"name":"x","surname":"y","time":"2021-03-20T19:04:00+01:00","message":"Record"} {"level":"info","ID":3,"name":"a","surname":"b","time":"2021-03-20T19:04:00+01:00","message":"Record"} --- PASS: TestSelect2 (0.00s) === RUN TestSelect3 --- PASS: TestSelect3 (0.00s) === RUN TestSelect4 --- PASS: TestSelect4 (0.00s) === RUN TestSelect5 --- PASS: TestSelect5 (0.00s) === RUN TestSelectOnError1 --- PASS: TestSelectOnError1 (0.00s) === RUN TestSelectOnError2 --- PASS: TestSelectOnError2 (0.00s) === RUN TestSelectOnError3 --- PASS: TestSelectOnError3 (0.00s) === RUN TestSelectScanError1 {"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-20T19:04:00+01:00","message":"Record"} --- PASS: TestSelectScanError1 (0.00s) === RUN TestSelectScanError2 --- PASS: TestSelectScanError2 (0.00s) === RUN TestSelectScanError3 --- PASS: TestSelectScanError3 (0.00s) === RUN TestSelectCloseError1 {"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-20T19:04:00+01:00","message":"Record"} {"level":"error","error":"close error","time":"2021-03-20T19:04:00+01:00","message":"Unable to close the DB rows handle"} --- PASS: TestSelectCloseError1 (0.00s) === RUN TestSelectCloseError2 {"level":"error","error":"close error","time":"2021-03-20T19:04:00+01:00","message":"Unable to close the DB rows handle"} --- PASS: TestSelectCloseError2 (0.00s) === RUN TestSelectCloseError3 {"level":"error","error":"close error","time":"2021-03-20T19:04:00+01:00","message":"Unable to close the DB rows handle"} --- PASS: TestSelectCloseError3 (0.00s) === RUN TestInsertion1 --- FAIL: TestInsertion1 (0.00s) db_operations_test.go:433: error was not expected while updating stats: ExecQuery: could not match actual sql: "INSERT INTO persons (name, surname) VALUES($1, $2);" with expected regexp "INSERT INTO persons (name, surname) VALUES($1, $2);" db_operations_test.go:437: one row should be updated, but 0 rows were updated db_operations_test.go:442: there were unfulfilled expectations: there is a remaining expectation which was not matched: ExpectedExec => expecting Exec or ExecContext which: - matches sql: 'INSERT INTO persons (name, surname) VALUES($1, $2);' - is with arguments: 0 - foo 1 - bar - should return Result having: LastInsertId: 1 RowsAffected: 1 === RUN TestInsertion2 --- PASS: TestInsertion2 (0.00s) === RUN TestInsertionOnError --- PASS: TestInsertionOnError (0.00s) === RUN TestDeletion1 --- PASS: TestDeletion1 (0.00s) === RUN TestDeletionOnError --- PASS: TestDeletionOnError (0.00s) FAIL coverage: 49.1% of statements exit status 1 FAIL db-test 0.004s
Z výpočtu pokrytí testovaného kódu je patrné, že nyní jsou všechny tři funkce provádějící dotaz do relační databáze pokryty na sto procent, tedy že jsme zkontrolovali i všechny alternativní cesty v kódu:
db-test/db_operations.go:37: initDatabaseConnection 0.0% db-test/db_operations.go:80: displayAllRecords 100.0% db-test/db_operations.go:128: readAllRecords 100.0% db-test/db_operations.go:163: readRecordsWithName 100.0% db-test/db_operations.go:198: insertRecord 87.5% db-test/db_operations.go:219: deleteByName 87.5% db-test/db_operations.go:239: main 0.0% total: (statements) 49.1%

Obrázek 6: Nové výsledky jednotkových testů – stoprocentní pokrytí.
14. Otestování funkce s transakcemi
Mnoho funkcí pracujících s relační databází provádí SQL příkazy v transakcích. Transakce jsou pochopitelně podporovány i balíčkem database/sql ze standardní knihovny programovacího jazyka Go. Transakce se vytvoří metodou Begin vracející novou datovou strukturu s metodami Rollback, Commit i všemi běžných metodami pro relační databáze, zejména s Exec a ExecQuery. Následující funkce pouze využívá transakce, ovšem je značně zjednodušena – uvnitř transakce je provedena jen jediná databázová operace (typicky jich bývá více):
// Vymazání záznamu nebo záznamů na základě zapsaného jména func deleteByNameInTransaction(connection *sql.DB, name string) (int, error) { // nová transakce tx, err := connection.Begin() // provedení SQL příkazu s jedním parametrem sqlStatement := "DELETE FROM persons WHERE name = $1;" result, err := tx.Exec(sqlStatement, name) // test, zda byl SQL příkaz proveden bez chyby if err != nil { rollbackError := tx.Rollback() if rollbackError != nil { log.Err(rollbackError).Msgf("error when trying to rollback a transaction") } return 0, err } // přečíst počet řádků v tabulce, které byly SQL příkazem upraveny affected, err := result.RowsAffected() // i tato operace může teoreticky skončit s chybou nebo nemusí být podporována if err != nil { rollbackError := tx.Rollback() if rollbackError != nil { log.Err(rollbackError).Msgf("error when trying to rollback a transaction") } return 0, err } commitError := tx.Commit() if commitError != nil { log.Err(commitError).Msgf("error when trying to commit a transaction") } return int(affected), nil }
15. Jednotkový test očekávající transakční operaci
Do jednotkového testu je možné po získání připojení k mockovanému SQL ovladači přidat kontroly, zda byla transakce zahájena i ukončena. V našem konkrétním případě může interní struktura testu vypadat následovně:
mock.ExpectBegin() mock.ExpectExec("DELETE FROM persons WHERE name = \\$1;").WithArgs("foo").WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit()
V testu tedy očekáváme, že dojde k zahájení transakce, dále se očekává konkrétní SQL příkaz, který je následovaný commitem. Tyto tři řádky lze zakomponovat do uceleného jednotkového testu následujícím způsobem:
func TestDeletionInTransaction(t *testing.T) { connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() mock.ExpectBegin() mock.ExpectExec("DELETE FROM persons WHERE name = \\$1;").WithArgs("foo").WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() updated, err := main.DeleteByNameInTransaction(connection, "foo") if err != nil { t.Errorf("error was not expected while updating stats: %s", err) } if updated != 1 { t.Errorf("one row should be updated, but %d rows were updated", updated) } err = mock.ExpectationsWereMet() if err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } }
16. Chování ve chvíli, kdy není transakce korektně ukončena
Pokusme se nyní přidat do testované aplikace funkci, v níž zapomeneme provést commit na konci transakce. Jedná se tedy o obdobu funkce ze čtrnácté kapitoly, ovšem bez závěrečné sekvence příkazů:
// Vymazání záznamu nebo záznamů na základě zapsaného jména func deleteByNameInTransactionNoCommit(connection *sql.DB, name string) (int, error) { // nová transakce tx, err := connection.Begin() // provedení SQL příkazu s jedním parametrem sqlStatement := "DELETE FROM persons WHERE name = $1;" result, err := tx.Exec(sqlStatement, name) // test, zda byl SQL příkaz proveden bez chyby if err != nil { rollbackError := tx.Rollback() if rollbackError != nil { log.Err(rollbackError).Msgf("error when trying to rollback a transaction") } return 0, err } // přečíst počet řádků v tabulce, které byly SQL příkazem upraveny affected, err := result.RowsAffected() // i tato operace může teoreticky skončit s chybou nebo nemusí být podporována if err != nil { rollbackError := tx.Rollback() if rollbackError != nil { log.Err(rollbackError).Msgf("error when trying to rollback a transaction") } return 0, err } return int(affected), nil }
17. Výsledek běhu testů, včetně zjištění pokrytí kódu testy
Dnes již naposledy zavoláme nástroj, který spustí jednotkové testy a zobrazí výsledek jejich běhu:
$ go test -v -coverprofile coverage.out === RUN TestSelect1 --- PASS: TestSelect1 (0.00s) === RUN TestSelect2 {"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-23T18:29:57+01:00","message":"Record"} {"level":"info","ID":2,"name":"x","surname":"y","time":"2021-03-23T18:29:57+01:00","message":"Record"} {"level":"info","ID":3,"name":"a","surname":"b","time":"2021-03-23T18:29:57+01:00","message":"Record"} --- PASS: TestSelect2 (0.00s) === RUN TestSelect3 --- PASS: TestSelect3 (0.00s) === RUN TestSelect4 --- PASS: TestSelect4 (0.00s) === RUN TestSelect5 --- PASS: TestSelect5 (0.00s) === RUN TestSelectOnError1 --- PASS: TestSelectOnError1 (0.00s) === RUN TestSelectOnError2 --- PASS: TestSelectOnError2 (0.00s) === RUN TestSelectOnError3 --- PASS: TestSelectOnError3 (0.00s) === RUN TestSelectScanError1 {"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-23T18:29:57+01:00","message":"Record"} --- PASS: TestSelectScanError1 (0.00s) === RUN TestSelectScanError2 --- PASS: TestSelectScanError2 (0.00s) === RUN TestSelectScanError3 --- PASS: TestSelectScanError3 (0.00s) === RUN TestSelectCloseError1 {"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-23T18:29:57+01:00","message":"Record"} {"level":"error","error":"close error","time":"2021-03-23T18:29:57+01:00","message":"Unable to close the DB rows handle"} --- PASS: TestSelectCloseError1 (0.00s) === RUN TestSelectCloseError2 {"level":"error","error":"close error","time":"2021-03-23T18:29:57+01:00","message":"Unable to close the DB rows handle"} --- PASS: TestSelectCloseError2 (0.00s) === RUN TestSelectCloseError3 {"level":"error","error":"close error","time":"2021-03-23T18:29:57+01:00","message":"Unable to close the DB rows handle"} --- PASS: TestSelectCloseError3 (0.00s) === RUN TestInsertion1 --- FAIL: TestInsertion1 (0.00s) db_operations_test.go:433: error was not expected while updating stats: ExecQuery: could not match actual sql: "INSERT INTO persons (name, surname) VALUES($1, $2);" with expected regexp "INSERT INTO persons (name, surname) VALUES($1, $2);" db_operations_test.go:437: one row should be updated, but 0 rows were updated db_operations_test.go:442: there were unfulfilled expectations: there is a remaining expectation which was not matched: ExpectedExec => expecting Exec or ExecContext which: - matches sql: 'INSERT INTO persons (name, surname) VALUES($1, $2);' - is with arguments: 0 - foo 1 - bar - should return Result having: LastInsertId: 1 RowsAffected: 1 === RUN TestInsertion2 --- PASS: TestInsertion2 (0.00s) === RUN TestInsertionOnError --- PASS: TestInsertionOnError (0.00s) === RUN TestDeletion1 --- PASS: TestDeletion1 (0.00s) === RUN TestDeletionOnError --- PASS: TestDeletionOnError (0.00s) === RUN TestDeletionInTransaction --- PASS: TestDeletionInTransaction (0.00s) === RUN TestDeletionInTransactionNoCommit --- FAIL: TestDeletionInTransactionNoCommit (0.00s) db_operations_test.go:594: there were unfulfilled expectations: there is a remaining expectation which was not matched: ExpectedCommit => expecting transaction Commit FAIL coverage: 46.1% of statements exit status 1 FAIL db-test 0.004s
Na samotný závěr si ukažme výpočet pokrytí jednotlivých funkcí jednotkovými testy:
$ go tool cover -func=coverage.out db-test/db_operations.go:37: initDatabaseConnection 0.0% db-test/db_operations.go:80: displayAllRecords 100.0% db-test/db_operations.go:128: readAllRecords 100.0% db-test/db_operations.go:163: readRecordsWithName 100.0% db-test/db_operations.go:198: insertRecord 87.5% db-test/db_operations.go:219: deleteByName 87.5% db-test/db_operations.go:240: deleteByNameInTransaction 50.0% db-test/db_operations.go:276: deleteByNameInTransactionNoCommit 46.7% db-test/db_operations.go:307: main 0.0% total: (statements) 46.1%
18. Možné další vylepšení
Jednotkové testy představené v předchozím i v dnešním článku v žádném případě nejsou dokonalé a existuje hned několik možností jejich vylepšení. V první řadě je vhodné testy správným způsobem označit štítky, aby bylo možné vybírat, které testy se mají v dané situaci spustit. Běžné bývá například označení některých testů štítkem „smoketest“, popř. lze testy rozdělit podle toho, s jakou částí databáze komunikují. Jak označen testů zajistit? Pokud budeme používat pouze základní sadu nástrojů jazyka Go, provede se celá operace následovně:
- Základní testy budou uloženy v souboru „storage_test.go“, tj. opět se použije standardní pojmenování.
- Ovšem navíc mohou existovat i další soubory, například „smoke_test.go“ a „storage_transactions_test.go“ s dalšími skupinami testů.
Důležité je, že ve dvou posledně zmíněných souborech použijeme strukturovaný komentář, který je rozeznán a zpracováván překladačem programovacího jazyka Go:
// +build smoketests // +build transactions_tests atd.
Nyní si můžeme jednotlivé testy spustit. Povšimněte si použití přepínače -tags:
$ go test -v
$ go test -v -tags smoketests
$ go test -v -tags transactions_tests
Dalším vylepšením je náhrada explicitních podmínek v testech zapisovaných s využitím konstrukce if za funkce typu assert. Ty sice (jako jazyková konstrukce) přímo v jazyku Go neexistují, ale nabízí je například balíček stretchr/ testify/assert. Tento balíček nabízí hned několik způsobů zápisu asercí, například:
func TestNewStorageError(t *testing.T) { _, err := storage.New(storage.Configuration{ Driver: "non existing driver", }) assert.EqualError(t, err, "driver non existing driver is not supported") }
popř.:
func TestInsertion1(t *testing.T) { connection, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer connection.Close() mock.ExpectExec("INSERT INTO persons (name, surname) VALUES($1, $2);").WithArgs("foo", "bar").WillReturnResult(sqlmock.NewResult(1, 1)) updated, err := main.InsertRecord(connection, "foo", "bar") assert.NoError(t, err, "error was not expected while updating stats: %s", err) assert.Equal(t, updated, "one row should be updated, but %d rows were updated", updated) err = mock.ExpectationsWereMet() assert.NoError(t, err, "there were unfulfilled expectations: %s", err) }
19. Repositář s demonstračními příklady
Zdrojové kódy všech dnes použitých demonstračních příkladů byly uloženy do nového Git repositáře, který je dostupný na adrese https://github.com/tisnik/go-root (stále na GitHubu :-). V případě, že nebudete chtít klonovat celý repositář (ten je ovšem – alespoň prozatím – velmi malý, dnes má přibližně stovku kilobajtů), můžete namísto toho použít odkazy na jednotlivé demonstrační příklady, které naleznete v následující tabulce:
# | Příklad/soubor | Stručný popis | Cesta |
---|---|---|---|
1 | go.mod | projektový soubor projektu psaného v Go | https://github.com/tisnik/go-root/blob/master/article73/go.mod |
2 | go.sum | závislosti vytvářeného projektu | https://github.com/tisnik/go-root/blob/master/article73/go.sum |
3 | db_operations.go | testovaná aplikace s několika databázovými operacemi | https://github.com/tisnik/go-root/blob/master/article73/db_operations.go |
4 | db_operations_test.go | jednotkové testy založené na mockování SQL příkazů | https://github.com/tisnik/go-root/blob/master/article73/db_operations_test.go |
5 | export_test.go | export jmen funkcí volaných v jednotkových testech | https://github.com/tisnik/go-root/blob/master/article73/export_test.go |
20. Odkazy na Internetu
- ORM je antipattern
https://zdrojak.cz/clanky/orm-je-antipattern/ - DATA-DOG / go-sqlmock repository
https://github.com/DATA-DOG/go-sqlmock - Sql driver mock for Golang
https://pkg.go.dev/github.com/DATA-DOG/go-sqlmock?utm_source=godoc - The fantastic ORM library for Golang
http://gorm.io/ - Package sql
https://golang.org/pkg/database/sql/ - Go database/sql tutorial
http://go-database-sql.org/ - SQLDrivers
https://github.com/golang/go/wiki/SQLDrivers - Package testing
https://golang.org/pkg/testing/ - Golang basics – writing unit tests
https://blog.alexellis.io/golang-writing-unit-tests/ - An Introduction to Programming in Go / Testing
https://www.golang-book.com/books/intro/12 - An Introduction to Testing in Go
https://tutorialedge.net/golang/intro-testing-in-go/ - Advanced Go Testing Tutorial
https://tutorialedge.net/golang/advanced-go-testing-tutorial/ - GoConvey
http://goconvey.co/ - Testing Techniques
https://talks.golang.org/2014/testing.slide - 5 simple tips and tricks for writing unit tests in #golang
https://medium.com/@matryer/5-simple-tips-and-tricks-for-writing-unit-tests-in-golang-619653f90742 - Dokumentace k balíčku oglematchers
https://godoc.org/github.com/jacobsa/oglematchers - Balíček oglematchers
https://github.com/jacobsa/oglematchers - Dokumentace k balíčku ogletest
https://godoc.org/github.com/jacobsa/ogletest - Balíček ogletest
https://github.com/jacobsa/ogletest - Dokumentace k balíčku assert
https://godoc.org/github.com/stretchr/testify/assert - Testify – Thou Shalt Write Tests
https://github.com/stretchr/testify/ - PostgreSQL CREATE DATABASE
https://www.postgresqltutorial.com/postgresql-create-database/ - PostgreSQL CREATE SCHEMA
https://www.postgresqltutorial.com/postgresql-create-schema/ - PostgreSQL CREATE TABLE
https://www.postgresqltutorial.com/postgresql-create-table/ - PostgreSQL SELECT
https://www.postgresqltutorial.com/postgresql-select/ - PostgreSQL INSERT
https://www.postgresqltutorial.com/postgresql-insert/ - PostgreSQL DELETE
https://www.postgresqltutorial.com/postgresql-delete/ - Data definition language
https://en.wikipedia.org/wiki/Data_definition_language - Data query language
https://en.wikipedia.org/wiki/Data_query_language - Data manipulation language
https://en.wikipedia.org/wiki/Data_manipulation_language