Hlavní navigace

Jazyk Go prakticky: jednotkové testy kódu, který přistupuje k SQL databázím (dokončení)

25. 3. 2021
Doba čtení: 39 minut

Sdílet

 Autor: Go lang
Dnes dokončíme téma, kterému jsme se věnovali minule: tvorbě jednotkových testů pro práci s relačními databázemi. Ukážeme si testování funkcí/metod, které do databáze přidávají nebo mažou řádky a taktéž databázových transakcí.

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

18. Možné další vylepšení

19. Repositář s demonstračními příklady

20. Odkazy na Internetu

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:

  1. displayAllRecords
  2. readAllRecords
  3. readRecordsWithName
  4. insertRecord
  5. deleteByName
Poznámka: později k této pětici funkcí přidáme ještě dvě funkce další. Tyto nové funkce budou s databází pracovat v transakci.

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.

Poznámka: na druhou stranu však použití regulárních výrazů umožňuje psát obecnější testy.

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%
Poznámka: zvýrazněna je změna oproti předchozímu běhu testů.

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%
Poznámka: opět je zvýrazněna změna oproti předchozímu běhu testů.

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)
Poznámka: výše uvedený řádek říká, jaký dotaz má mockovaný SQL driver očekávat, jaké mají být jeho parametry a jaký má být výsledek.

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%
Poznámka: můžeme vidět, že skutečně došlo k určitému zlepšení pokrytí, i když jsme prozatím nedosáhli 100% (to ovšem ani nemusí být vždy cílem).

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")
Poznámka: chyba se musí vyskytnou uprostřed výsledků, nikoli až na jejich konci!

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
}
Poznámka: všechny operace, které se týkají transakce, jsou zvýrazněny.

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%
Poznámka: obě funkce deleteByNameInTransaction a deleteByNameInTransactionNoCommit by pochopitelně bylo vhodné otestovat ještě lépe, nicméně pro účely tohoto článku stávající testy pravděpodobně postačují.

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:

Hacking tip

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)
}
Poznámka: o tomto balíčku se krátce zmíníme v navazujícím článku.

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_o­perations.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_o­perations_test.go
5 export_test.go export jmen funkcí volaných v jednotkových testech https://github.com/tisnik/go-root/blob/master/article73/ex­port_test.go

20. Odkazy na Internetu

  1. ORM je antipattern
    https://zdrojak.cz/clanky/orm-je-antipattern/
  2. DATA-DOG / go-sqlmock repository
    https://github.com/DATA-DOG/go-sqlmock
  3. Sql driver mock for Golang
    https://pkg.go.dev/github.com/DATA-DOG/go-sqlmock?utm_source=godoc
  4. The fantastic ORM library for Golang
    http://gorm.io/
  5. Package sql
    https://golang.org/pkg/database/sql/
  6. Go database/sql tutorial
    http://go-database-sql.org/
  7. SQLDrivers
    https://github.com/golang/go/wi­ki/SQLDrivers
  8. Package testing
    https://golang.org/pkg/testing/
  9. Golang basics – writing unit tests
    https://blog.alexellis.io/golang-writing-unit-tests/
  10. An Introduction to Programming in Go / Testing
    https://www.golang-book.com/books/intro/12
  11. An Introduction to Testing in Go
    https://tutorialedge.net/golang/intro-testing-in-go/
  12. Advanced Go Testing Tutorial
    https://tutorialedge.net/go­lang/advanced-go-testing-tutorial/
  13. GoConvey
    http://goconvey.co/
  14. Testing Techniques
    https://talks.golang.org/2014/tes­ting.slide
  15. 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
  16. Dokumentace k balíčku oglematchers
    https://godoc.org/github.com/ja­cobsa/oglematchers
  17. Balíček oglematchers
    https://github.com/jacobsa/o­glematchers
  18. Dokumentace k balíčku ogletest
    https://godoc.org/github.com/ja­cobsa/ogletest
  19. Balíček ogletest
    https://github.com/jacobsa/ogletest
  20. Dokumentace k balíčku assert
    https://godoc.org/github.com/stret­chr/testify/assert
  21. Testify – Thou Shalt Write Tests
    https://github.com/stretchr/testify/
  22. PostgreSQL CREATE DATABASE
    https://www.postgresqltuto­rial.com/postgresql-create-database/
  23. PostgreSQL CREATE SCHEMA
    https://www.postgresqltuto­rial.com/postgresql-create-schema/
  24. PostgreSQL CREATE TABLE
    https://www.postgresqltuto­rial.com/postgresql-create-table/
  25. PostgreSQL SELECT
    https://www.postgresqltuto­rial.com/postgresql-select/
  26. PostgreSQL INSERT
    https://www.postgresqltuto­rial.com/postgresql-insert/
  27. PostgreSQL DELETE
    https://www.postgresqltuto­rial.com/postgresql-delete/
  28. Data definition language
    https://en.wikipedia.org/wi­ki/Data_definition_langua­ge
  29. Data query language
    https://en.wikipedia.org/wi­ki/Data_query_language
  30. Data manipulation language
    https://en.wikipedia.org/wi­ki/Data_manipulation_langu­age

Autor článku

Pavel Tišnovský vystudoval VUT FIT a v současné době pracuje ve společnosti Red Hat, kde vyvíjí nástroje pro OpenShift.io.