Spracovanie chýb a testovanie v Go

Tuesday, Oct 13, 2020 by Nelo
Go (golang) programovanie - spracovanie chyb (error) a unit testovanie

Defekty patria neodmysliteľne k písaniu kódu. V knihe Code Complete Steve McConnell uvádza priemyselný štandard 15-50 defektov na 1000 riadkov kódu (KLOC) v teste a 5 defektov na KLOC v produkcii s nákladmi na testovanie $5 na riadok kódu. Microsoft aplikáciach vstupuje do testov 10-20 defektov na KLOC a do produkcie 0,5 defektov na KLOC. Defekty môžu mať za následok stratu života alebo veľké finančné straty. Koľko defektov je príliš veľa? NASA sa snaží o dosiahnutie 0 defektov na KLOC, avšak za cenu tisícov USD na jeden riadok kódu (podľa reportov 0,004 defektov/KLOC stojí NASA $850 na riadok kódu). Preto je dôležité hustotu defektov vo fáze testovania a v produkcii merať a na začiatku projektu si vzhľadom na dopady stanoviť kritéria, rozpočet a nastaviť si ciele na zlepšovanie.

V tomto článku sa zameriame na dve témy - ako ošetrovať a spracovávať chyby a testovanie pomocou Go toolingu.

Spracovanie chýb

Predstavme si ilustračný kód s funkciou divide, ktorá berie dva vstupné parametre a vráti výsledok delenia. Aký je problém s týmto kódom?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import (
	"fmt"
)

func divide(a, b int) int {
	return a / b
}

func main() {
    fmt.Println(divide(5, 2)) // "2"
}

Ak zavoláme divide(5, 0) tak program v panike skolabuje s hláškou panic: runtime error: integer divide by zero

V iných jazykoch je problém obyčajne riešný vyhodením výnimky (Exception objekt), ktorú treba odchytiť (catch) na vyššej úrovni alebo program rovnako skolabuje. V niektorých jazykoch zo signatúry funkcie nevieme povedať, či hádže výnimky (C++), v iných môžu alebo nemusia byť jasné zo signatúry (unchecked - java.Error, java.RuntimeException). Druhý problém, ktorý nastáva, je že nie každá chyba je výnimka a v situáciach, keď chceme vrátiť prázdnu hodnotu musíme návratovú hodnotu obaliť do objektu a neskôr rozbaliť. To je riešené napríklad Maybe alebo Optional monádou vo funkcionálnom programovaní (ktorého som veľkým fanúšikom), alebo int? v C#, ale nerieši to napríklad návratové chybové kódy a správy.

Go rieši výnimky tak, že ich nepozná. Umožňuje však vrátenie viac ako jedného parametra a pozná rozhranie typu error alebo vo fatálnom prípade panic(message) - napríklad ak došla RAM a program sa nemôže spamätať (pričom z paniky sa stále máme možnosť spamätať prostredníctvom recover v defer bloku - viac info tu). Dôvodom je prehľadnosť kontrolného toku programu a čitateľnosť kódu. Neexistuje neviditeľný neprehľadný tok výnimiek spracovávaných na roznych úrovniach. Buď chybu na mieste spracujeme, alebo nie. Dôvody tohto dizajnu si možete prečítať vo FAQ. Niektorí kritizujú, že tento štýl zväčšuje počet napísaných riadkov kódu, iní to vnímajú ako úľavu. Pamätajme, že kód je niekoľkonásobne viac čítaný ako písaný… Ako by teda náš kód vyzeral po ošetrení?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

import (
	"errors"
	"fmt"
)

func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("divide: cannot divide by zero")
	}
	return a / b, nil
}

func main() {
	fmt.Println(divide(5, 2)) // "2 <nil>"
	fmt.Println(divide(5, 0)) // "0 divide: cannot divide by zero"
}

errors.New vytvorí pri delení nulou typ error s hláškou a vráti. Prvá návratová hodnota musí byť stále typu int preto musíme vrátiť číslo. Iný spôsob ak chceme vrátiť formátovaný error je zmeniť riadok na return 0, fmt.Errorf("divide: cannot divide %v by zero", a).

A čo keď si chceme zadefinovať vlastné chyby s návratovými hodnotami? Tu už ideme trochu hlbšie. Vieme, že chyba v Go je rozhranie typu error. Error rozhranie je deklarované takto: type error interface { Error() string }

To znamená, že akákoľvek dátová štruktúra, ktorá akceptuje funkciu Error() vracajúcu string môže byť automaticky použitá ako error. Takto by vyzeral náš kód ak by sme chceli vracať aj chybový kód:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
	"fmt"
)

// vlastná chybová dátová štruktúra
type appError struct {
	StatusCode int
	Message    string
}

// Error() funkcia vracajúca string (t.j. spĺňajúca error rozhranie) naviazaná na typ appError
func (r appError) Error() string {
	return fmt.Sprintf("error %d: %v", r.StatusCode, r.Message)
}

func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, &appError{StatusCode: 500, Message: fmt.Sprintf("cannot divide %v by zero", a)}
	}
	return a / b, nil
}

func main() {
	fmt.Println(divide(5, 2)) // "2 <nil>"
	fmt.Println(divide(5, 0)) // "0 error 500: cannot divide 5 by zero"
}

Testovanie pomocou Go toolingu

Go obsahuje jednoduchý ale prepracovaný tooling na testovanie. Unit a benchmark testy si môžeme vytvoriť v osobitnom súbore s názvom *_test.go, napríklad pkg_test.go. V tomto súbore testov importneme balíček testing. Každá testovacia funkcia musí mať tvar func TestXXX(t *testing.T), respektíve func BenchmarkXXX(t *testing.B) pre benchmark testy (XXX je ľubovoľný názov testu). Testy spustíme príkazom go test, ktorý podporuje viacero flagov. Kód nižšie obsahuje jeden pozitívny prípad, jeden negatívny a jeden benchmark test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
	"testing"
)

func TestDivide(t *testing.T) {
	output, err := divide(5, 2)
	if err != nil || output != 2 {
		t.Fatal(err)
	}
	t.Logf("%+v", output) // vypíše len ak spustíme s "go test -v"
}

func TestDivideZero(t *testing.T) {
	output, err := divide(5, 0)
	if err == nil {
		t.Fatal(err)
	}
	t.Logf("%+v", output) // vypíše len ak spustíme s "go test -v"
}

// spustíme s "go test -bench ."
func BenchmarkDivide(b *testing.B) {
	for i := 0; i < b.N; i++ {
		divide(5, 2)
	}
}

Ak potrebujeme urobiť setup a teardown testov pred akýmkoľvek testom, tak môžeme použiť funckiu TestMain. Ak testovací súbor obsahuje túto funkciu tak sa nezavolá žiaden test, ale len funkcia TestMain a m.Run() zavolá všetky testy daného súboru. Ak test zlyhal, tak os.Exit vráti nenulovú hodnotu. setup() a shutdown() sú ľubovolné funkcie aké si definujeme.

1
2
3
4
5
6
func TestMain(m *testing.M) {
    setup()
    code := m.Run()
    shutdown()
    os.Exit(code)
}

Ak chceme setup a teardown na úrovni špecifického testu, tak sa odporúča volať setup a teardown funkcie v tele testu.

Nákladnejší setup v benchmark testoch ovplivní performance testu, preto po setupe benchmark testu by sme mali resetovať časovač funkciou b.ResetTimer().

Testovanie je podstatne komplexnejšia oblasť, to čo sme si ukázali sú len základné unit testy. Testy môžeme zjednodušiť a obohatiť o assert, require, mocky a suity prostredníctvom populárneho balíčku Testify.

Výstupy

Spustenie všetkých funkčných testov:

$ go test
PASS
ok

Spustenie všetkých funkčných testov s vypísaním podmienených log správ:

$ go test -v
=== RUN   TestDivide
--- PASS: TestDivide (0.00s)
    pkg_test.go:12: 2
=== RUN   TestDivideZero
--- PASS: TestDivideZero (0.00s)
    pkg_test.go:20: 0
PASS
ok

Spustenie všetkých performance benchmark testov:

$ go test -bench .
goos: windows
goarch: amd64
BenchmarkDivide-24      1000000000               0.464 ns/op
PASS
ok

Referencie

  1. Error handling and Go
  2. Working with Errors in Go 1.13 - wrap/unwrap
  3. Errors are values
  4. Package testing
  5. Improving Your Go Tests and Mocks With Testify
  6. Basic testing patterns in Go

Vaše otázky, návrhy a komentáre

Verím, že vás tento návod inšpiroval a budem vďačný ak dáte spätnú väzbu a pomôže mi zamerať sa na to čo by vás zaujímalo.

TAK ČO HOVORÍŠ ?

Kontaktuj nás ak potrebuješ pomoc, veľmi radi pomôžeme.

Kontaktuj nás