Hlavní navigace

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

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

Sdílet

 Autor: Go lang
Při psaní jednotkových testů se mnohdy nevyhneme nutnosti otestovat i ty funkce a metody, které přistupují k SQL (relačním) databázím. V takových případech je nutné funkcionalitu nabízenou SQL databází vhodným způsobem mockovat.

Obsah

1. Jazyk Go prakticky: jednotkové testy kódu, který přistupuje k SQL databázím

2. Přístup k relačním (SQL databázím z jazyka Go

3. Databázové ovladače použitelné v jazyku Go

4. Příprava databáze používané testovanou aplikací

5. Vytvoření a naplnění tabulky používané testovanou aplikací

6. První verze testované aplikace

7. Popis jednotlivých částí testované aplikace

8. Spuštění testované aplikace

9. Jednotkové testy v programovacím jazyce Go

10. Příklad vytvoření jednotkových testů

11. Mockování funkcí a metod pro potřeby jednotkových testů

12. Mockování funkcí a metod přistupujících do relační databáze s využitím knihovny go-sqlmock

13. Export testovaných funkcí a kostra jednotkových testů

14. Základní otestování funkce DisplayAllRecords

15. Přidání řádků, které SQL mock vrátí testované funkci jako výsledek SQL dotazu

16. Test funkce vracející data načtená z databáze

17. Funkce se složitějším SQL dotazem s klauzulí WHERE

18. Otestování nové funkce

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

20. Odkazy na Internetu

1. Jazyk Go prakticky: jednotkové testy kódu, který přistupuje k SQL databázím

Již v úvodních částech seriálu o programovacím jazyce Go jsme si řekli, že se tento programovací jazyk velmi často – a nutno říci, že i s úspěchem – používá pro implementaci různých webových služeb a mikroslužeb. Této problematice jsme se ostatně věnovali i v tomto článku. Ovšem mnohé služby pochopitelně potřebují používat nějakou formu databáze pro zajištění persistence dat. Může se jednat o relační databáze, dokumentové databáze, grafové databáze, objektové databáze atd. Způsob práce s relačními databázemi jsme si již taktéž popsali, a to včetně zmínky o několika systémech pro ORM. Popsali jsme si taktéž propojení aplikací psaných v jazyce Go s nerelační databází Redis.

Dnes se ovšem zaměříme na poněkud odlišné téma – jak vlastně testovat aplikace, které databázi používají. Pro integrační a end-to-end testy lze samozřejmě použít reálnou databázi (pochopitelně odlišnou instanci, než je instance produkční), ovšem v případě jednotkových testů je situace nepatrně složitější, neboť v nich budeme většinou potřebovat nahradit reálnou databázi nějakou formou mocku – buď databází ležící v operační paměti, nebo mohou být mockovány přímo databázové operace.

Poznámka: v demonstračních příkladech se zaměříme na nízkoúrovňový přístup. Při použití tohoto přístupu je nutné explicitně zapisovat všechny SQL příkazy, předávat jim parametry a popř. explicitně načítat a zpracovávat jednotlivé záznamy vrácené dotazem SELECT. V některých případech je tento přístup velmi užitečný (ostatně SQL je s velkou pravděpodobností nejpopulárnějším doménově specifickým jazykem neboli DSL vůbec), protože například umožňuje snadné optimalizace dotazů. Mnoho programátorů však dává přednost jinému přístupu, který spočívá ve využití nějaké knihovny zajišťující ORM, tedy (zjednodušeně řečeno) mapování mezi záznamy uloženými v databázi a datovými strukturami vytvářenými na straně aplikace. Nicméně dále popsané řešení jednotkových testů bude použitelné jak při nízkoúrovňovém přístupu, tak i při použití ORM (pro diskusi o ORM viz taktéž [1]).

2. Přístup k relačním (SQL) databázím z jazyka Go

Pro přístup k relačním databázím (resp. přesněji řečeno k databázím používajícím jazyk SQL jako svůj doménově specifický jazyk) slouží v programovacím jazyce Go standardní balíček nazvaný database/sql. Tento balíček zajišťuje navázání připojení k databázi, spouštění příkazů na straně databáze (jak DDL – Data Definition Language, tak i DML a DQL, tedy zjednodušeně řečeno příkazy SELECT, INSERT, UPDATE a DELETE), umožňuje zpracování výsledků příkazu SELECT, parsing záznamů, datové převody (časová razítka atd.), zajišťuje předávání parametrů všem DML i DQL příkazům atd. Všechny tyto podporované operace jsou přitom obecně nezávislé na použité databázi, protože balíček database/sql používá pro přístup ke konkrétní databázi takzvané databázové ovladače (drivers).

V praxi to znamená zejména to, že aplikace může být napsána obecně, přičemž konkrétní databázový ovladač bude vybrán až na základě aktuální konfigurace. Příkladem může být využití databáze PostgreSQL při lokálním nasazení, popř. ve vývojovém prostředí, zatímco na předprodukčním a produkčním prostředí bude použita AWS RDS. Podobně – pokud budou SQL příkazy napsány dostatečně přenositelně – lze v testech použít databázi SQLite běžící pouze v operační paměti.

Poznámka: pochopitelně se nejedná o specifikum programovacího jazyka Go, protože koncept oddělení rozhraní k databázím od konkrétních ovladačů nalezneme i v dalších ekosystémech.

3. Databázové ovladače použitelné v jazyku Go

V současnosti existuje několik desítek ovladačů umožňujících propojení aplikací psaných v jazyku Go s databázemi. Nejdůležitější ovladače jsou zmíněny v následující tabulce, ovšem s tím, že počet použitelných ovladačů i počet podporovaných databází neustále roste:

Databáze Driver pro Go
Apache H2: https://github.com/jmrobles/h2go
Apache Ignite/GridGain: https://github.com/amsokol/ignite-go-client
Apache Impala: https://github.com/bippio/go-impala
Apache Avatica/Phoenix: https://github.com/apache/calcite-avatica-go
Amazon AWS Athena: https://github.com/uber/athenadriver
AWS Athena: https://github.com/segmentio/go-athena
Azure Cosmos DB: https://github.com/btnguy­en2k/gocosmos
ClickHouse (uses native TCP interface): https://github.com/ClickHou­se/clickhouse-go
ClickHouse (uses HTTP API): https://github.com/mailru/go-clickhouse
CockroachDB: Use any PostgreSQL driver
Couchbase N1QL: https://github.com/couchbase/go_n1ql
DB2 LUW and DB2/Z with DB2-Connect: https://bitbucket.org/phiggins/db2cli (Last updated 2015–08)
DB2 LUW (uses cgo): https://github.com/asifjalil/cli
DB2 LUW, z/OS, iSeries and Informix: https://github.com/ibmdb/go_ibm_db
Firebird SQL: https://github.com/nakaga­mi/firebirdsql
Genji (pure go): https://github.com/genjidb/genji
Google Cloud BigQuery: https://github.com/solcates/go-sql-bigquery
Google Cloud Spanner: https://github.com/rakyll/go-sql-driver-spanner
MS ADODB: https://github.com/mattn/go-adodb
MS SQL Server (pure go): https://github.com/denisenkom/go-mssqldb
MS SQL Server (uses cgo): https://github.com/minus5/gofreetds
MySQL: https://github.com/go-sql-driver/mysql/
MySQL: https://github.com/siddontang/go-mysql/
MySQL: https://github.com/ziutek/mymysql
ODBC: https://bitbucket.org/miquella/mgodbc
ODBC: https://github.com/alexbrainman/odbc
Oracle (uses cgo): https://github.com/mattn/go-oci8
Oracle (uses cgo): https://gopkg.in/rana/ora.v4
Oracle (uses cgo): https://github.com/godror/godror
Oracle (pure go): https://github.com/sijms/go-ora
QL: http://godoc.org/github.com/cznic/ql/dri­ver
Postgres (pure Go): https://github.com/lib/pq
Postgres (uses cgo): https://github.com/jbarham/gop­gsqldriver
Postgres (pure Go): https://github.com/jackc/pgx
Presto: https://github.com/prestodb/presto-go-client
SAP HANA (uses cgo): https://help.sap.com/viewer/0e­ec0d68141541d1b07893a39944924e/2­.0.03/en-US/0ffbe86c9d9f4433844182­9c6bee15e6.html
SAP HANA (pure go): https://github.com/SAP/go-hdb
SAP ASE (uses cgo): https://github.com/SAP/go-ase – package cgo (pure go package planned)
Snowflake (pure Go): https://github.com/snowfla­kedb/gosnowflake
SQLite (uses cgo): https://github.com/mattn/go-sqlite3
SQLite (uses cgo): https://github.com/gwenn/gosqlite
SQLite (uses cgo): https://github.com/mxk/go-sqlite
SQLite: (uses cgo): https://github.com/rsc/sqlite
SQLite: (pure go): https://modernc.org/sqlite
SQL over REST: https://github.com/adaptant-labs/go-sql-rest-driver
Sybase SQL Anywhere: https://github.com/a-palchikov/sqlago
Sybase ASE (pure go): https://github.com/thda/tds
Vertica: https://github.com/vertica/vertica-sql-go
Vitess: https://godoc.org/vitess.i­o/vitess/go/vt/vitessdriver
YQL (Yahoo! Query Language): https://github.com/mattn/go-yql
Apache Hive: https://github.com/sql-machine-learning/gohive
MaxCompute: https://github.com/sql-machine-learning/gomaxcompute
Poznámka: v dnešním článku použijeme ovladač k databází PostgreSQL, ovšem stejně dobře lze použít i jinou databázi. V některých případech bude nutné změnit zápis parametrů předávaných SQL příkazům.

4. Příprava databáze používané testovanou aplikací

Aplikace (resp. přesněji řečeno kostra aplikace), pro kterou budeme v rámci dalších kapitol vytvářet jednotkové testy, přistupuje k databázi typu PostgreSQL. Jméno databáze bude testdb, přičemž tato databáze bude obsahovat jedinou tabulku persons se třemi sloupci. V této kapitole si ukážeme, jak takovou databázi připravit, v navazující kapitole, jak vytvořit a naplnit tabulku persons.

Nejprve je nutné PostgreSQL nainstalovat (apt-get, dnf, yum atd.) a spustit. Použít se může „univerzální“ příkaz:

# service postgresql start

nebo přímo příkaz pro systemd:

# systemctl start postgresql

Přesvědčíme se, že databáze skutečně běží:

$ systemctl status postgresql
 
● postgresql.service - PostgreSQL database server
   Loaded: loaded (/usr/lib/systemd/system/postgresql.service; disabled; vendor
   Active: active (running) since Mon 2021-03-01 12:10:01 CET; 2 weeks 1 days ag
  Process: 27550 ExecStart=/usr/libexec/postgresql-ctl start -D ${PGDATA} -s -w
  Process: 27548 ExecStartPre=/usr/libexec/postgresql-check-db-dir postgresql (c
 Main PID: 27553 (postgres)
    Tasks: 7 (limit: 4915)
   CGroup: /system.slice/postgresql.service
           ├─27553 /usr/bin/postgres -D /var/lib/pgsql/data
           ├─27554 postgres: logger process
           ├─27556 postgres: checkpointer process
           ├─27557 postgres: writer process
           ├─27558 postgres: wal writer process
           ├─27559 postgres: autovacuum launcher process
           └─27560 postgres: stats collector process

Dále se připojíme k databázi řádkovým klientem psql:

$ psql -U postgres
Password for user postgres: (zde se taktéž použije nastavené heslo)
psql (9.6.10)
Type "help" for help.
 
postgres=#

Po připojení vytvoříme novou databázi pojmenovanou testdb:

postgres=# CREATE DATABASE testdb;
CREATE DATABASE

Pro jistotu se podíváme, jestli byla databáze skutečně vytvořena:

postgres=# \l
                                  List of databases
    Name    |  Owner   | Encoding |   Collate   |    Ctype    |   Access privileges
------------+----------+----------+-------------+-------------+-----------------------
 aggregator | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 |
 controller | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 |
 postgres   | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 |
 ptisnovs   | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 |
 template0  | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 | =c/postgres          +
            |          |          |             |             | postgres=CTc/postgres
 template1  | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 | =c/postgres          +
            |          |          |             |             | postgres=CTc/postgres
 test       | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 |
 testdb     | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 |
(8 rows)
Poznámka: ve vašem případě se vypíše méně databází – můj systém už je trošku zabordelařený :-)

5. Vytvoření a naplnění tabulky používané testovanou aplikací

Nyní nám zbývá vytvořit a naplnit tabulku persons, která bude následně využita testovanou aplikací. Přepneme se tedy (stále v interaktivním prostředí psql) do databáze testdb:

postgres=# \c testdb
 
You are now connected to database "testdb" as user "postgres".

Dále vytvoříme tabulku používanou aplikací (tedy využijeme DDL, které je součástí SQL):

CREATE TABLE persons(
    id        SERIAL PRIMARY KEY,
    name      VARCHAR(50) NOT NULL,
    surname   VARCHAR(50) NOT NULL);

Pro jistotu zkontrolujeme, zda tabulka existuje a jakou má strukturu:

testdb=# \dt
 
 public | persons | table | postgres
testdb=# \d persons
 
 id      | integer               | not null default nextval('persons_id_seq'::regclass)
 name    | character varying(50) | not null
 surname | character varying(50) | not null
Poznámka: poněkud nestandardní informace o sloupci id nás informuje o tom, že se jedná o automaticky generovaný primární klíč.

Předposledním krokem je naplnění této tabulky sedmi záznamy. Jedná se o postavy ze známé Cimrmanovské hry Švestka:

INSERT INTO persons(name, surname) VALUES('Eliška', 'Najbrtová');
INSERT INTO persons(name, surname) VALUES('Jenny', 'Suk');
INSERT INTO persons(name, surname) VALUES('Anička', 'Šafářová');
INSERT INTO persons(name, surname) VALUES('Sváťa', 'Pulec');
INSERT INTO persons(name, surname) VALUES('Blažej', 'Motyčka');
INSERT INTO persons(name, surname) VALUES('Eda', 'Wasserfall');
INSERT INTO persons(name, surname) VALUES('Přemysl', 'Hájek');

V posledním kroku pouze zkontrolujeme, zda tabulka persons skutečně obsahuje všech sedm postav:

testdb=# select * from persons;
 
  1 | Eliška  | Najbrtová
  2 | Jenny   | Suk
  3 | Anička  | Šafářová
  4 | Sváťa   | Pulec
  5 | Blažej  | Motyčka
  6 | Eda     | Wasserfall
  7 | Přemysl | Hájek

Klienta psql nyní již můžeme opustit:

postgres=# \q

6. První verze testované aplikace

V dalším kroku si připravíme první verzi aplikace, která bude k databázi přistupovat a pro kterou postupně vytvoříme jednotkové testy. Nejprve vytvoříme kostru nového projektu psaného v jazyce Go, a to konkrétně příkazem:

$ go mod init db-test

Měl by se vytvořit soubor go.mod, jehož původní verze bude vypadat následovně:

module db-test
 
go 1.14

První verze testované aplikace vypadá následovně:

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
}
 
// 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
        }
 
        // 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.Debug().Msg("Finished")
}

7. Popis jednotlivých částí testované aplikace

Pojďme si nyní alespoň ve stručnosti popsat jednotlivé části testované aplikace.

Po uvedení jména balíčku následuje import dalších balíčků. Povšimněte si, že se importují dva balíčky obsahující drivery podporovaných SQL databází. Funkce ani struktury z těchto driverů nebudeme používat, takže se namísto skutečných jmen jako jmenný alias použije podtržítko (jinak by se jednalo o chybu zjišťovanou při překladu), ovšem import být proveden musí, neboť je používán linkerem. Další dva importované balíčky jsou použity pro logování prováděných operací na standardním výstupu (v ekosystému jazyka Go se jedná o poměrně populární balíčky):

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"
)

Následuje deklarace datové struktury použité pro specifikaci připojení ke konkrétní databázi. Tato struktura je nastavena takovým způsobem, že ji lze načíst ze souborů typu TOML atd.:

// 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"`
}

Další část zdrojového kódu pouze obsahuje konstanty s chybovými zprávami. Některé z těchto zpráv jsou totiž použity na více místech:

// 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"
)

Mnohem zajímavější je funkce nazvaná initDatabaseConnection. Ta totiž podle zvoleného databázového driveru zajistí připojení k databázi. Řetězec použitý pro specifikaci připojení (connection string) je pro každou databázi odlišný:

// 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
}

Následují funkce, pro které v rámci dalšího textu (a navazujícího článku) postupně vytvoříme jednotkové testy. První z těchto funkcí spustí jednoduchý dotaz a zobrazí výsledky tohoto dotazu (pochopitelně s kontrolou chyb, které mohou nastat). Povšimněte si, že se jedná skutečně o jednoduchý dotaz, v němž se nepoužívají žádné parametry:

// 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
}

Další funkce zajistí vložení nového záznamu do databáze. Povšimněte si způsobu práce s parametry, které se do dotazu předávají. Skutečné hodnoty parametrů $1 a $2 jsou předány v metodě Exec, takže se vyhneme případnému „řetězcovému“ skládání SQL dotazů, což je (nebo možná byl?) jeden z často používaných antipatternů:

// 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
}

Třetí funkce typu CRUD (Create, read, update and delete) naopak záznam z databáze vymaže, pochopitelně za předpokladu, že je splněna zapsaná podmínka. Opět se zde využívá předávání parametrů SQL příkazu namísto skládání celého příkazu řetězcovými operacemi (což je ve většině případů zbytečné – pokud ovšem není nutné měnit jméno tabulky atd.):

// 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
}

Všechny tři CRUD funkce jsou otestovány po spuštění celého programu, což si ostatně ukážeme v následující kapitole:

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
        }
 
        // 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.Debug().Msg("Finished")
}

8. Spuštění testované aplikace

Aplikaci popsanou v předchozí kapitole spustíme, jak je tomu v ekosystému jazyka Go běžné, příkazem:

$ go run

Na standardní výstup se vypíšou (nebo by se alespoň měly vypsat) tyto řádky:

8:02PM DBG Started
8:02PM INF DB connection configuration driverName=postgres
8:02PM INF Record ID=1 name="Eliška" surname="Najbrtová"
8:02PM INF Record ID=2 name=Jenny surname=Suk
8:02PM INF Record ID=3 name="Anička" surname="Šafářová"
8:02PM INF Record ID=4 name="Sváťa" surname=Pulec
8:02PM INF Record ID=5 name="Blažej" surname="Motyčka"
8:02PM INF Record ID=6 name=Eda surname=Wasserfall
8:02PM INF Record ID=7 name="Přemysl" surname="Hájek"
8:02PM INF DELETE deleted rows=1
8:02PM INF INSERT inserted rows=1
8:02PM INF Record ID=1 name="Eliška" surname="Najbrtová"
8:02PM INF Record ID=2 name=Jenny surname=Suk
8:02PM INF Record ID=3 name="Anička" surname="Šafářová"
8:02PM INF Record ID=4 name="Sváťa" surname=Pulec
8:02PM INF Record ID=5 name="Blažej" surname="Motyčka"
8:02PM INF Record ID=7 name="Přemysl" surname="Hájek"
8:02PM INF Record ID=8 name=Eda surname="Vodopád"
8:02PM DBG Finished
Poznámka: ve skutečnosti je výstup barevný:

Obrázek 1: Skutečný výstup na terminálu po spuštění testované aplikace.

Současně dojde k automatické modifikaci souboru go.mod, protože se doplní potřebné knihovny:

module db-test
 
go 1.14
 
require (
        github.com/lib/pq v1.10.0
        github.com/mattn/go-sqlite3 v2.0.3+incompatible
        github.com/rs/zerolog v1.20.0
)

Vytvoří se i soubor go.sum s tranzitivními závislostmi:

github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/lib/pq v1.10.0 h1:Zx5DJFEYQXio93kgXnQ09fXNiUKsqv4OUEu2UtGcB1E=
github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.20.0 h1:38k9hgtUBdxFwE34yS8rTHmHBa4eN16E4DJlv177LNs=
github.com/rs/zerolog v1.20.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

Nakonec se můžeme po přihlášení do interaktivní konzole psql přesvědčit, že došlo ke změně obsahu tabulky:

$ psql -U postgres
Password for user postgres: (zde se taktéž použije nastavené heslo)
psql (9.6.10)
Type "help" for help.
 
postgres=#
 
postgres=# \c testdb
 
testdb=# select * from persons;
 id |  name   |  surname
----+---------+-----------
  1 | Eliška  | Najbrtová
  2 | Jenny   | Suk
  3 | Anička  | Šafářová
  4 | Sváťa   | Pulec
  5 | Blažej  | Motyčka
  7 | Přemysl | Hájek
  8 | Eda     | Vodopád
(7 rows)

9. Jednotkové testy v programovacím jazyce Go

Tvorba testů, ať již testů jednotkových, integračních, výkonnostních atd., je v současnosti prakticky nedílnou součástí vývoje všech nových informačních systémů (a ostatně i desktopových aplikací). I z tohoto důvodu se v nabídce standardních nástrojů programovacího jazyka Go nachází i nástroj určený pro spouštění jednotkových testů (unit tests) s vyhodnocením jejich výsledků, zjištěním, která část zdrojových kódů je jednotkovými testy pokryta, výpočtem procenta pokrytí kódu testy atd. V souvislosti s jednotkovými testy je mnohdy nutné určité části programu nahradit jejich zjednodušenými (umělými) variantami, které se nazývají mock. Nástroj či knihovnu pro mockování sice přímo v základní sadě nástrojů Go nenalezneme (lze ji relativně snadno doinstalovat), ovšem jak si ukážeme v navazujících kapitolách, je většinou možné si vystačit s možnostmi poskytovanými samotným programovacím jazykem (zejména se to týká využití rozhraní – interface) a v případě SQL databází pak balíčkem go-sqlmock.

Jak jsme si již řekli v úvodním odstavci této kapitoly, obsahuje standardní instalace programovacího jazyka Go i knihovnu určenou pro psaní jednotkových testů. Tato knihovna se jmenuje testing a základní informace o ní získáme stejným způsobem, jako je tomu v případě všech dalších knihoven či balíčků – tedy příkazem go doc. Zde konkrétně následujícím způsobem:

$ go doc testing
 
package testing // import "testing"
 
Package testing provides support for automated testing of Go packages. It is
intended to be used in concert with the ``go test'' command, which automates
execution of any function of the form
 
    func TestXxx(*testing.T)
 
where Xxx does not start with a lowercase letter. The function name serves
to identify the test routine.
...
...
...

Samotná implementace jednotkových testů je tedy představována běžnými funkcemi, jejichž jména začínají na Test a akceptují parametr typu *testing.T, tj. ukazatel na strukturu obsahující informace o kontextu, ve kterém jsou jednotlivé testy spouštěny:

type T struct {
        common // další struktura s informacemi o času spuštění testu atd. atd.
        isParallel bool
        context    *testContext // For running tests and subtests.
}

Důležité je, že existuje poměrně velké množství metod pro strukturu testing.T, které jsou použity právě při tvorbě jednotkových testů. Z praktického hlediska se jedná především o následující metody:

# Metoda Stručný popis metody
1 Error provede se zalogování chyby a funkce s testem se označí příznakem „chyba“
2 Fail funkce s testem se označí příznakem „chyba“
3 FailNow dtto, ovšem současně se příslušná funkce i ukončí
4 Log zalogování zprávy, typicky s informací o chybě
5 Fatal odpovídá kombinaci volání funkcí Log+FailNow
Poznámka: typicky tedy ve funkcích TestXXX nalezneme volání metody Error nebo Fatal, podle toho, zda se má celá testovací funkce ukončit či nikoli.

10. Příklad vytvoření jednotkových testů

Již v dokumentaci k programovacímu jazyku Go je ve stručnosti zmíněno, jakým způsobem se mají jednotkové testy tvořit, ovšem jedná se o tak důležité téma, že se mu budeme věnovat v této kapitole podrobněji. Nejprve se podívejme na zdrojový kód obsahující funkci nazvanou Add, kterou budeme chtít otestovat s využitím jednotkových testů. Kód funkce Add i příslušné funkce main je uložen v souboru pojmenovaném „add.go“:

package main
 
func Add(x int, y int) int {
        return x + y
}
 
func main() {
        println(Add(1, 2))
}

Jakým způsobem se napíše jednotkový test či jednotkové testy pro tuto funkci? Testy budou zapisovány do souboru pojmenovaného „add_test.go“, protože právě na základě řetězce „_test“ ve jménu souboru nástroje jazyka Go rozpoznávají, jestli se jedná o zdrojový kód, který má být součástí výsledné aplikace, či naopak o kód používaný pro testování.

Poznámka: ve skutečnosti existují i další možnosti, jakými je možné rozdělit zdrojové kódy a testy. Někteří programátoři dávají přednost tomu, aby byly soubory s jednotkovými testy uloženy ve zvláštním (pod)adresáři. To je možné, ovšem v dnešním článku pro jednoduchost použijeme standardní postup – rozlišení běžných zdrojových kódů od testů na základě řetězce „_test“, který se ve jméně zdrojových souborů uvádí vždy před koncovku „.go“ (takto ostatně vypadá i mnoho reálných a úspěšných projektů psaných v jazyce Go).

Ukažme si tedy způsob naprogramování velmi jednoduchého jednotkového testu určeného pro otestování funkcionality funkce Add. Použijeme přitom takový postup, že se zavoláním metody Error zaregistruje, že test nebyl dokončen úspěšně:

package main
 
import "testing"
 
func TestAdd(t *testing.T) {
        result := Add(1, 2)
        if result != 3 {
                t.Error("1+2 should be 3, got ", result, "instead")
        }
}
Poznámka: připomeňme si, že v programovacím jazyku Go není podporován příkaz assert. Autory Go k tomu vedlo několik důvodů, které jsou shrnuty ve FAQ https://golang.org/doc/faq#as­sertions. Pro nás je v tuto chvíli důležité, že můžeme snadno řídit, zda se má po nesplnění nějaké podmínky celý test ukončit, či zda se má pouze zaznamenat chyba a testování bude pokračovat dále – tedy zda se má použít metoda Error či FailNow, popř. dokonce Fatal. Nevýhodou je, že (bez použití dalších pomocných knihoven) se v testech bude opakovat explicitní zápis podmínek tvořených strukturovaným příkazem if.

Pro spuštění jednotkových testů se nepoužívá příkaz go run, ale příkaz go test. Ten nalezne všechny soubory *_test.go v daném adresáři či podadresářích a pokusí se v něm spustit všechny funkce s implementací jednotkových testů:

$ go test
 
PASS
ok      _/home/tester/go-root/article_17/tests01   0.005s

Lepší je však použít přepínač -v, aby se vypsaly podrobnější informace o spuštěných testech:

$ go test -v
 
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      _/home/tester/go-root/article_17/tests01   0.004s

Dále je možné zjistit pokrytí kódu jednotkovými testy:

$ go test -v -coverprofile coverage.out

Vygenerovaný soubor coverage.out slouží pro další analýzy.

11. Mockování funkcí a metod pro potřeby jednotkových testů

Při testování aplikací, zejména při psaní jednotkových testů, se poměrně často dostaneme do situace, kdy potřebujeme nahradit nějakou funkci či metodu používanou v reálné aplikaci za „falešnou“ funkci, resp. metodu vytvořenou pouze pro účely jednotkových testů. V programovacím jazyku Go je možné pro tvorbu a použití takových „falešných“ funkcí použít hned několik různých knihoven, které se od sebe odlišují jak svými možnostmi, tak i způsobem zápisu či deklarace očekávaného chování testované aplikace. Dnes se seznámíme s jednou knihovnou určenou primárně pro mockování funkcí a metod používaných SQL drivery pro přístup k relačním databázím. Tato knihovna umožňuje testovat aplikace používající jak přímo standardní balíček database/sql, tak i prakticky jakýkoli balíček nabízející ORM, tedy objektově-relační mapování.

12. Mockování funkcí a metod přistupujících do relační databáze s využitím knihovny go-sqlmock

Pro otestování funkcí, které přistupují do relační databáze, použijeme knihovnu nazvanou go-sqlmock. Tato knihovna nabízí aplikacím vlastní SQL driver, takže ji lze použít jak pro ty aplikace, které přímo volají funkce a metody z balíčku database/sql, tak pro aplikace postavené na ORM, například na knihovně Gorm. Samotná struktura testů je následující:

  1. Vytvoří se připojení do mockované databáze, což je objekt typu *sql.DB
  2. SQL driver se nakonfiguruje – specifikuje se, jaký dotaz či dotazy má očekávat
  3. Dále se specifikuje, jaká data se mají vrátit jako výsledek dotazu (tedy mockujeme odpověď databáze)
  4. Zavolá se testovaná funkce, která interně volá SQL příkazy
  5. Otestuje se, zda jsou splněny podmínky z bodu 2, tedy zda se skutečně zavolaly očekávané SQL příkazy a jen tyto příkazy
  6. Otestuje se, zda volaná funkce vrátila očekávaná data (typicky získaná z dat vrácených mockovanou databází)
Poznámka: interně se nejedná o složitou knihovnu, protože například testování, zda se do SQL databáze poslal očekávaný dotaz, se děje na základě porovnání řetězce s regulárním výrazem – mockovaný SQL driver se tedy nijak nesnaží komplexně „porozumět“ celému dotazu.

13. Export testovaných funkcí a kostra jednotkových testů

Nyní již máme k dispozici všechny základní informace o způsobu testování kódu, který používá balíček database/sql, a to jak přímo, tak i nepřímo. Můžeme se tedy pokusit vytvořit si první jednoduché jednotkové testy. Nejprve je nutné zajistit, aby byly testované funkce dostupné (viditelné) nejenom z balíčku main, ale i z balíčku main_test. K tomu můžeme použít malého triku – vytvoříme soubor export_test.go, který je v balíčku main a který bude obsahovat alternativní jména testovaných funkcí, ovšem začínajících na velké písmeno (tedy takové funkce budou viditelné i vně balíčku):

package main
 
var (
        DisplayAllRecords = displayAllRecords
)
Poznámka: funkce jsou v jazyku Go považovány za plnohodnotné hodnoty, proto je tento trik takto jednoduše implementovatelný.

Dále můžeme vytvořit kostru jednotkových testů. Bude se jednat o soubor se jménem končícím na _test.go a bude spadat do balíčku main_test (tím pádem se testy nebudou linkovat do výsledného binárního kódu aplikace). Importovat budeme pochopitelně balíček testing zmíněný v předchozích kapitolách, dále hlavní balíček main a taktéž balíček go-sqlmock:

package main_test
 
import (
        "testing"
 
        main "db-test"
 
        "github.com/DATA-DOG/go-sqlmock"
)

14. Základní otestování funkce DisplayAllRecords

Nyní již můžeme vytvořit jednotkový test, který bude volat funkci DisplayAllRecords (resp. původní funkci displayAllRecords). Hlavička funkce s jednotkovým testem vypadá následovně – jméno funkce začíná na Test a jediný parametr je typu *testing.T:

func TestSelect1(t *testing.T) {

V testu nejdříve vytvoříme připojení do databáze, ovšem nyní nikoli do reálné databáze PostgreSQL; namísto toho použijeme SQL mock:

connection, mock, err := sqlmock.New()
if err != nil {
        t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}

Získaný objekt se chová stejně jako reálný SQL driver, tudíž musíme zajistit uzavření připojení na konci (teoreticky nemusíme, ale takto je celý přístup čistější a mocky se nebudou hromadit v operační paměti):

defer connection.Close()

Nyní konečně nastává vlastní příprava mocku. Vytvoříme datovou strukturu s popisem řádků, které má mockované SQL vrátit. Prozatím budeme požadovat, aby se nevrátily žádné záznamy, tudíž struktura sice bude existovat, ale bude prázdná:

rows := sqlmock.NewRows([]string{})

Nejdůležitější je následující řádek, kterým se specifikuje, jaký dotaz bude SQL mock očekávat (tedy jak se má chovat testovaná funkce) a jaká data se testované funkci vrátí jako výsledek SQL dotazu. Celý zápis je možné zapsat na jediný řádek:

mock.ExpectQuery("SELECT id, name, surname FROM persons").WillReturnRows(rows)

SQL mock je připraven a tudíž se můžeme pokusit zavolat testovanou funkci, které přidáme objekt realizující připojení do mockovaného SQL driveru:

err = main.DisplayAllRecords(connection)
if err != nil {
        t.Errorf("error was not expected while updating stats: %s", err)
}
Poznámka: výsledkem volání této funkce je pouze hodnota typu error, kterou pochopitelně můžeme v jednotkových testech dále zpracovat.

Pokud testovaná funkce nevrátila chybu, ještě to vůbec neznamená, že pracuje s SQL korektně. To zjistíme až otestováním, zda mockované SQL přijalo očekávaný dotaz (a nikoli dotaz jiný či žádný):

err = mock.ExpectationsWereMet()
if err != nil {
        t.Errorf("there were unfulfilled expectations: %s", err)
}

Výsledná podoba funkce s jednotkovým testem bude vypadat následovně:

func TestSelect1(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{})
 
        mock.ExpectQuery("SELECT id, name, surname FROM persons").WillReturnRows(rows)
 
        err = main.DisplayAllRecords(connection)
        if err != nil {
                t.Errorf("error was not expected while updating stats: %s", err)
        }
 
        err = mock.ExpectationsWereMet()
        if err != nil {
                t.Errorf("there were unfulfilled expectations: %s", err)
        }
}

15. Přidání řádků, které SQL mock vrátí testované funkci jako výsledek SQL dotazu

Mockovaný driver SQL databáze dokáže z SQL dotazu (typicky SELECT) vrátit libovolná v jednotkových testech specifikovaná data. Postačuje pouze tato data reprezentovat formou datové struktury typu sqlmock.Rows. Podívejme se nyní na celý postup, který není složitý.

Strukturu sqlmock.Rows zkonstruujeme s využitím konstruktoru sqlmock.NewRows; přitom je nutné specifikovat jména sloupců výsledku (povšimněte si, že se neřeší struktura zdrojových tabulek, ale pouze výsledek dotazu):

rows := sqlmock.NewRows([]string{"id", "name", "surname"})

Do této struktury se nové řádky přidávají metodou nazvanou AddRow. Opět si povšimněte, že lze relativně bez problémů pracovat s různými datovými typy prvků (poněkud problematická je práce například s časovými razítky, o čemž se zmíníme příště):

rows.AddRow(1, "foo", "bar")
rows.AddRow(2, "x", "y")
rows.AddRow(3, "a", "b")

Předání dat do mockovaného driveru:

mock.ExpectQuery("SELECT id, name, surname FROM persons").WillReturnRows(rows)

A to je vše – upravený test tedy může vypadat následovně:

func TestSelect2(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(2, "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.Errorf("error was not expected while updating stats: %s", err)
        }
 
        err = mock.ExpectationsWereMet()
        if err != nil {
                t.Errorf("there were unfulfilled expectations: %s", err)
        }
}

Nyní po spuštění jednotkových testů můžeme na standardním výstupu vidět, že funkce displayAllRecords skutečně získá trojici řádků, jejichž obsah vypíše na obrazovku:

$ go test
 
{"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-16T15:05:10+01:00","message":"Record"}
{"level":"info","ID":2,"name":"x","surname":"y","time":"2021-03-16T15:05:10+01:00","message":"Record"}
{"level":"info","ID":3,"name":"a","surname":"b","time":"2021-03-16T15:05:10+01:00","message":"Record"}

16. Test funkce vracející data načtená z databáze

Pokusme se nyní upravit naši aplikaci takovým způsobem, aby obsahovala funkci, která načte záznamy z databáze a vrátí tato data formou řezu záznamů. Bude se tedy jednat o poměrně snadno testovatelnou funkci (a z hlediska návrhu aplikace lepší funkci s jasně definovaným jediným úkolem).

Nejprve si připravíme strukturu reprezentující každý záznam přečtený z databáze:

// datová struktura odpovídající struktuře záznamu v databázi
type Record struct {
        Id      int
        Name    string
        Surname string
}

Samotná funkce pro přečtení záznamů z databáze (se všemi potřebnými testy na případné chyby) může vypadat následovně:

// 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
}
Poznámka: povšimněte si, jak se v Go získávají ukazatele na prvky záznamu (struktury).

Před úpravou testů musíme do souboru export_test.go přidat jmenný alias existující funkce, ovšem s velkým písmenem na začátku:

package main
 
var (
        DisplayAllRecords   = displayAllRecords
        ReadAllRecords      = readAllRecords
)

Do souboru s jednotkovými testy nyní můžeme přidat nový test:

func TestSelect3(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(2, "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.Errorf("error was not expected while updating stats: %s", err)
        }
 
        err = mock.ExpectationsWereMet()
        if err != nil {
                t.Errorf("there were unfulfilled expectations: %s", err)
        }
 
        if len(results) != 3 {
                t.Errorf("different number of results read from database: %d instead of 3", len(results))
                return
        }
 
        expected := main.Record{1, "foo", "bar"}
        if results[0] != expected {
                t.Errorf("first result is different: %+v versus %+v", results[0], expected)
        }
}
Poznámka: za zmínku stojí fakt, že můžeme velmi snadno otestovat, zda funkce ReadRecordsWithName (resp. readRecordsWithName) skutečně vrátila očekávaná data. Prozatím jsem schválně přidal pouze kontrolu na počet vrácených záznamů a taktéž kontrolu, zda první vrácený záznam odpovídá očekávané hodnotě. Mimochodem: v Go lze skutečně porovnávat dvě struktury pomocí operátorů == a !=.

Pro zajímavost se podívejme na výsledek testů a kontrolu pokrytí zdrojového kódu testy:

$ go test -timeout 2m -coverprofile coverage.out
 
{"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-16T15:05:10+01:00","message":"Record"}
{"level":"info","ID":2,"name":"x","surname":"y","time":"2021-03-16T15:05:10+01:00","message":"Record"}
{"level":"info","ID":3,"name":"a","surname":"b","time":"2021-03-16T15:05:10+01:00","message":"Record"}
PASS
coverage: 11.0% of statements
ok      db-test 0.003s
 
$ 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:127:   readAllRecords          0.0%
db-test/db_operations.go:162:   insertRecord            0.0%
db-test/db_operations.go:183:   deleteByName            0.0%
db-test/db_operations.go:203:   main                    0.0%
total:                          (statements)            11.0%

Obrázek 2: Vizuální znázornění pokrytí zdrojového kódu jednotkovými testy (stále se používají standardní nástroje jazyka Go).

17. Funkce se složitějším SQL dotazem s klauzulí WHERE

V dalším kroku do naší testované aplikace přidáme další funkci. I tato funkce bude volat SQL dotaz a zpracovávat výsledky tohoto dotazu, ovšem nyní bude dotaz obsahovat parametr, konkrétně jméno, které mají vyhledávané záznamy obsahovat (ostatní kód je podobný předchozí funkci, ale pro větší přehlednost prozatím neprovedeme refaktoring):

// 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
}

18. Otestování nové funkce

Otestování nové funkce přidané v rámci sedmnácté kapitoly bude triviální. Nejdříve jméno této funkce přidáme do souboru export_test.go:

package main
 
var (
        DisplayAllRecords   = displayAllRecords
        ReadAllRecords      = readAllRecords
        ReadRecordsWithName = readRecordsWithName
)

Dále vytvoříme jednotkový test. Zde si povšimněte způsobu zápisu příkazu, který je očekáván (tedy který má testovaná funkce zavolat). V příkazu se před parametr zadává zpětné lomítko, což ovšem nemusí být na první pohled zřejmé:

mock.ExpectQuery("SELECT id, name, surname FROM persons WHERE name=\\$1").WillReturnRows(rows)

Samotná implementace jednotkového testu:

func TestSelect4(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(2, "Eda", "Vodopád")
 
        mock.ExpectQuery("SELECT id, name, surname FROM persons WHERE name=\\$1").WillReturnRows(rows)
 
        results, err := main.ReadRecordsWithName(connection, "Eda")
        if err != nil {
                t.Errorf("error was not expected while updating stats: %s", err)
        }
 
        err = mock.ExpectationsWereMet()
        if err != nil {
                t.Errorf("there were unfulfilled expectations: %s", err)
        }
 
        if len(results) != 1 {
                t.Errorf("different number of results read from database: %d instead of 1", len(results))
                return
        }
 
        expected := main.Record{2, "Eda", "Vodopád"}
        if results[0] != expected {
                t.Errorf("first result is different: %+v versus %+v", results[0], expected)
        }
}
Poznámka: opět se zde nabízí velký prostor pro refaktoring, tentokrát refaktoring jednotkových testů.

Ve skutečnosti je však možné očekávaný dotaz zapsat ve formě regulárního výrazu:

mock.ExpectQuery("SELECT id, name, surname FROM persons WHERE name=.*").WillReturnRows(rows)

Výsledný test:

func TestSelect5(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(2, "Eda", "Vodopád")
 
        mock.ExpectQuery("SELECT id, name, surname FROM persons WHERE name=.*").WillReturnRows(rows)
 
        results, err := main.ReadRecordsWithName(connection, "Eda")
        if err != nil {
                t.Errorf("error was not expected while updating stats: %s", err)
        }
 
        err = mock.ExpectationsWereMet()
        if err != nil {
                t.Errorf("there were unfulfilled expectations: %s", err)
        }
 
        if len(results) != 1 {
                t.Errorf("different number of results read from database: %d instead of 1", len(results))
                return
        }
 
        expected := main.Record{2, "Eda", "Vodopád"}
        if results[0] != expected {
                t.Errorf("first result is different: %+v versus %+v", results[0], expected)
        }
}

Výsledek spuštění jednotkových testů s výpočtem pokrytí kódu testy:

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:239:   main                    0.0%
total:                          (statements)            28.6%
{"level":"info","ID":1,"name":"foo","surname":"bar","time":"2021-03-16T15:47:35+01:00","message":"Record"}
{"level":"info","ID":2,"name":"x","surname":"y","time":"2021-03-16T15:47:35+01:00","message":"Record"}
{"level":"info","ID":3,"name":"a","surname":"b","time":"2021-03-16T15:47:35+01:00","message":"Record"}
PASS
coverage: 28.6% of statements
ok      db-test 0.003s

Dalším krokem tedy bude otestování zbylého kódu (což jsou většinou kontroly chyb) a taktéž CRUD příkazů INSERT a DELETE. To je ovšem téma, kterému se budeme věnovat příště.

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/article72/go.mod
2 go.sum závislosti vytvářeného projektu https://github.com/tisnik/go-root/blob/master/article72/go.sum
3 db_operations.go testovaná aplikace s několika databázovými operacemi https://github.com/tisnik/go-root/blob/master/article72/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/article72/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/article72/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.