diff --git a/.releaser-ci.yml b/.releaser-ci.yml index 090dfe0..d45aa02 100644 --- a/.releaser-ci.yml +++ b/.releaser-ci.yml @@ -1,9 +1,10 @@ check: image: eu.gcr.io/shad-ts/grader/go-build script: + - golangci-lint run --build-tags private,solution ./... + - rm -rf lectures/ # do not run tests from lecture examples - go test -v -tags private,solution ./... - go test -v -race -tags private,solution ./... - - golangci-lint run --build-tags private,solution ./... rebuild-base-image: only: diff --git a/lectures/04-testing/echo/echo.go b/lectures/04-testing/echo/echo.go new file mode 100644 index 0000000..930caf9 --- /dev/null +++ b/lectures/04-testing/echo/echo.go @@ -0,0 +1,38 @@ +// Copyright © 2016 Alan A. A. Donovan & Brian W. Kernighan. +// License: https://creativecommons.org/licenses/by-nc-sa/4.0/ + +// Echo prints its command-line arguments. +package main + +import ( + "flag" + "fmt" + "io" + "os" + "strings" +) + +var ( + n = flag.Bool("n", false, "omit trailing newline") + s = flag.String("s", " ", "separator") +) + +var out io.Writer = os.Stdout // modified during testing + +func main() { + flag.Parse() + if err := echo(!*n, *s, flag.Args()); err != nil { + fmt.Fprintf(os.Stderr, "echo: %v\n", err) + os.Exit(1) + } +} + +func echo(newline bool, sep string, args []string) error { + fmt.Fprint(out, strings.Join(args, sep)) + if newline { + fmt.Fprintln(out) + } + return nil +} + +// OMIT diff --git a/lectures/04-testing/echo/echo_test.go b/lectures/04-testing/echo/echo_test.go new file mode 100644 index 0000000..3710e14 --- /dev/null +++ b/lectures/04-testing/echo/echo_test.go @@ -0,0 +1,39 @@ +// Copyright © 2016 Alan A. A. Donovan & Brian W. Kernighan. +// License: https://creativecommons.org/licenses/by-nc-sa/4.0/ + +// Test of echo command. Run with: go test gopl.io/ch11/echo + +//!+ +package main + +import ( + "bytes" + "fmt" + "testing" +) + +func TestEcho(t *testing.T) { + var tests = []struct { + newline bool + sep string + args []string + want string + }{ + {true, "", []string{}, "\n"}, + {false, "", []string{}, ""}, + {true, "\t", []string{"one", "two", "three"}, "one\ttwo\tthree\n"}, + {true, ",", []string{"a", "b", "c"}, "a,b,c\n"}, + } + for _, test := range tests { + descr := fmt.Sprintf("echo(%v, %q, %q)", test.newline, test.sep, test.args) + out = new(bytes.Buffer) // captured output + if err := echo(test.newline, test.sep, test.args); err != nil { + t.Errorf("%s failed: %v", descr, err) + continue + } + got := out.(*bytes.Buffer).String() + if got != test.want { + t.Errorf("%s = %q, want %q", descr, got, test.want) + } + } +} diff --git a/lectures/04-testing/lecture.slide b/lectures/04-testing/lecture.slide new file mode 100644 index 0000000..8b17ac6 --- /dev/null +++ b/lectures/04-testing/lecture.slide @@ -0,0 +1,448 @@ +Тестирование +Лекция 5 + +Фёдор Короткий + +* go test + +- `*_test.go` файлы не являются частью пакета, а содержат тесты. +- `go`test` создаёт нужный main, компилирует исполняемый файл и запускает его. + +* Тестовые функции + +Тестовые функции должны иметь сигнатуру: + + func TestName(t *testing.T) { + // ... + } + +Параметр `*testing.T` используется, чтобы сообщить о падении теста. + +* Пример Palindrome + +.play word1/word.go /func IsPa/,/^}/ + +.play word1/word_test.go /func TestPa/,/^}/ + +.play word1/word_test.go /func TestNonPa/,/^}/ + +* Пример Palindrome + +.play word1/word_test.go /func TestFrench/,/OMIT/ + + $ go test -v -run="French|Canal" + === RUN TestFrenchPalindrome + --- FAIL: TestFrenchPalindrome (0.00s) + word_test.go:28: IsPalindrome("été") = false + === RUN TestCanalPalindrome + --- FAIL: TestCanalPalindrome (0.00s) + word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false + FAIL + exit status 1 + FAIL gopl.io/ch11/word1 0.014s + +* Пример Palindrome + +.play word2/word.go /func IsPa/,/^}/ + +* Table Driven Test + +.play word2/word_test.go /func Test/,/^}/ + +* Пример Echo + +.play echo/echo.go /var/,/OMIT/ + +* Пример Echo + +.play echo/echo_test.go /func TestEcho/,/^}/ + +* External Tests + + $ go list -f={{.GoFiles}} fmt + [doc.go format.go print.go scan.go] + + $ go list -f={{.TestGoFiles}} fmt + [export_test.go] + + $ go list -f={{.XTestGoFiles}} fmt + [fmt_test.go scan_test.go stringer_test.go] + +Package test + + package fmt + + func TestXXX(t *testing.T) {} + +External test + + package fmt_test + + func TestYYY(t *testing.T) {} + +* fmt isSpace example + + // export_test.go + package fmt + + var IsSpace = isSpace + +- fmt не зависит от unicode, и содержит упрощённую реализацию isSpace. + +- External тесты fmt проверяют, что fmt.isSpace и unicode.IsSpace не +отличаются в поведении. + +* Writing Effective tests + +Bad example + + import ( + "fmt" + "strings" + "testing" + ) + + // A poor assertion function. + func assertEqual(x, y int) { + if x != y { + panic(fmt.Sprintf("%d != %d", x, y)) + } + } + + func TestSplit(t *testing.T) { + words := strings.Split("a:b:c", ":") + assertEqual(len(words), 4) + // ... + } + +Тест упадёт с сообщением `3 != 4` после страниц стектрейсов. + +* Writing Effective tests + +Good example + + func TestSplit(t *testing.T) { + s, sep := "a:b:c", ":" + words := strings.Split(s, sep) + if got, want := len(words), 3; got != want { + t.Errorf("Split(%q, %q) returned %d words, want %d", + s, sep, got, want) + } + // ... + } + +* Завершение теста + + type Banana struct { + Color string + Tasty bool + } + + func TestBanana(t *testing.T) { + banana, err := GetBanana() + if err != nil { + t.Fatalf("GetBanana() failed: %v", err) + } + + if banana.Color != "yellow" { + t.Errorf("banana colors is %s, want yellow", banana.Color) + } + if !banana.Tasty { + t.Errorf("banana is not tasty") + } + } + +* Завершение теста + +- Иногда тест нужно завершить преждевременно. + + t.Fatal("gcc not found in PATH") + t.Fatalf("request failed: %v", err) + t.FailNow() + +- Иногда тест нужно продолжать, чтобы узнать больше информации + + t.Error("i got a bad feeling about this") + t.Errorf("%d documents found, want %d", 2, 3) + t.Fail() + +- Иногда нужно просто залогировать информацию на будущее + + t.Logf("using go from %s", path) + +* Тестовые проверки в других горутинах + + func TestGo(t *testing.T) { + var wg sync.WaitGroup + wg.Add(2) + + go func () { + defer wg.Done() + // This is OK + t.Error("1 != 2") + }() + + go func () { + defer wg.Done() + // This is INVALID + t.Fatal("1 != 2") + }() + + wg.Wait() + } + +* Горутины и завершение теста + + func TestGo(t *testing.T) { + go func() { + for { + time.Sleep(time.Second) + t.Logf("tick") // This will panic + } + }() + } + +* Правильное завершение + + func TestGo(t *testing.T) { + var wg sync.WaitGroup + defer wg.Wait() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-time.After(time.Second): + case <-ctx.Done(): + return + } + + t.Logf("tick") + } + }() + } + +* testify + + func TestSum(t *testing.T) { + if got, want := Sum(1, 2), 4; got != want { + t.Errorf("Sum(%d, %d) = %d, want %d", 1, 2, got, want) + } + } + + === RUN TestSum + --- FAIL: TestSum (0.00s) + example_test.go:11: Sum(1, 2) = 3, want 4 + FAIL + +* testify + + func TestSum0(t *testing.T) { + assert.Equalf(t, 4, Sum(1, 2), "Sum(%d, %d)", 1, 2) + } + + === RUN TestSum0 + --- FAIL: TestSum0 (0.00s) + example_test.go:20: + Error Trace: example_test.go:20 + Error: Not equal: + expected: 4 + actual : 3 + Test: TestSum0 + Messages: Sum(1, 2) + +- Функции из пакета `assert` работают как `t.Errorf`. +- Функции из пакета `require` работают как `t.Fatalf`. + +* testify + + func TestCall(t *testing.T) { + res, err := Call() + require.NoError(t, err) + + assert.Equal(t, 42, res) + } + +- Для проверок ошибок используйте `require.Error` и `require.NoError`. + +* Coverage + +- Пример в goland. + +* Benchmark Functions + + func BenchmarkIsPalindrome(b *testing.B) { + for i := 0; i < b.N; i++ { + IsPalindrome("A man, a plan, a canal: Panama") + } + } + + $ go test -bench=. + PASS + BenchmarkIsPalindrome-8 1000000 1035 ns/op + ok gopl.io/ch11/word2 2.179s + +- `b.ReportAllocs()` включает подсчёт аллокаций +- `-benchmem` включает подсчёт аллокаций глобально + +* Benchmark & Test Parameters + + func benchmark(b *testing.B, size int) { /* ... */ } + func Benchmark10(b *testing.B) { benchmark(b, 10) } + func Benchmark100(b *testing.B) { benchmark(b, 100) } + func Benchmark1000(b *testing.B) { benchmark(b, 1000) } + +Или через под-тесты + + func benchmark(b *testing.B, size int) { /* ... */ } + + func BenchmarkN(b *testing.B) { + for _, n := range []int{10, 100, 1000} { + b.Run(fmt.Sprint(n), func(b *testing.B) { + benchmark(b, n) + }) + } + } + +* Parallel tests + + func TestA(t *testing.T) { + t.Parallel() + // ... + } + + func TestB(t *testing.T) { + t.Parallel() + // ... + } + + func TestC(t *testing.T) { + // ... + } + +* Example Tests + + func ExampleIsPalindrome() { + fmt.Println(IsPalindrome("A man, a plan, a canal: Panama")) + fmt.Println(IsPalindrome("palindrome")) + // Output: + // true + // false + } + +Если `Output` нет, то `Example` слушит только для документации. + + func ExampleAPI() { + var c *Client // skip initialization + + rsp, err := c.Call(&Request{}) + _ = err + _ = rsp + } + +* TestMain + +Иногда нужно сделать глобальную инициализацию. + + func TestMain(m *testing.M) { + if os.Getenv("INSIDE_DOCKER") == "" { + os.Exit(runSelfInDocker()) + } + + os.Exit(m.Run()) + } + +* t.Helper() + + func assertGood(t *testing.T, i int) { + if i != 0 { + t.Errorf("i (= %d) != 0", i) + } + } + + func TestA(t *testing.T) { + // which one failed? + assertGood(t, 0) + assertGood(t, 1) + } + + === RUN TestA + --- FAIL: TestA (0.00s) + example_test.go:25: i (= 1) != 0 + FAIL + +* t.Helper() + + func assertGood(t *testing.T, i int) { + if i != 0 { + t.Errorf("i (= %d) != 0", i) + } + } + + func TestA(t *testing.T) { + assertGood(t, 0) + assertGood(t, 1) // line 32 + } + + === RUN TestA + --- FAIL: TestA (0.00s) + example_test.go:32: i (1) != 0 + FAIL + +* t.Skip() + + func TestingDB(t *testing.T) { + dbConn := os.Getenv("DB") + if dbConn == "off" { + t.Skipf("DB=off is set; disabling tests relying on database") + } + } + +Иногда полезно пропускать тесты, которые используют внешние зависимости. + +* Test Fixtures + + type env struct { + Client *s3.Client + DB *sql.Conn + } + + func newEnv(t *testing.T) (*env, func()) { + // ... + } + + func TestA(t *testing.T) { + env, stop := newEnv(t) + defer stop() + + // ... + } + + func TestB(t *testing.T) { + env, stop := newEnv(t) + defer stop() + + // ... + } + +* Fixture Composition + + type MyFixture struct { + other.Fixture + third.Fixture + } + + func newFixture(t *testing.T) (*MyFixture, func()) { + other, stopOther := other.NewFixture(t) + third, stopThird := third.NewFixture(t) + + return &MyFixture{other, third}, func() { + stopOther() + stopThird() + } + } + diff --git a/lectures/04-testing/testify/example_test.go b/lectures/04-testing/testify/example_test.go new file mode 100644 index 0000000..b1a013b --- /dev/null +++ b/lectures/04-testing/testify/example_test.go @@ -0,0 +1,33 @@ +package testify + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Sum(a, b int) int { + return a + b +} + +func TestSum(t *testing.T) { + if got, want := Sum(1, 2), 4; got != want { + t.Errorf("Sum(%d, %d) = %d, want %d", 1, 2, got, want) + } +} + +func TestSum0(t *testing.T) { + assert.Equalf(t, 4, Sum(1, 2), "Sum(%d, %d)", 1, 2) +} + +func assertGood(t *testing.T, i int) { + t.Helper() + if i != 0 { + t.Errorf("i (%d) != 0", i) + } +} + +func TestA(t *testing.T) { + assertGood(t, 0) + assertGood(t, 1) +} diff --git a/lectures/04-testing/word1/word.go b/lectures/04-testing/word1/word.go new file mode 100644 index 0000000..048abc9 --- /dev/null +++ b/lectures/04-testing/word1/word.go @@ -0,0 +1,21 @@ +// Copyright © 2016 Alan A. A. Donovan & Brian W. Kernighan. +// License: https://creativecommons.org/licenses/by-nc-sa/4.0/ + +// See page 303. +//!+ + +// Package word provides utilities for word games. +package word + +// IsPalindrome reports whether s reads the same forward and backward. +// (Our first attempt.) +func IsPalindrome(s string) bool { + for i := range s { + if s[i] != s[len(s)-1-i] { + return false + } + } + return true +} + +//!- diff --git a/lectures/04-testing/word1/word_test.go b/lectures/04-testing/word1/word_test.go new file mode 100644 index 0000000..08f323f --- /dev/null +++ b/lectures/04-testing/word1/word_test.go @@ -0,0 +1,40 @@ +// Copyright © 2016 Alan A. A. Donovan & Brian W. Kernighan. +// License: https://creativecommons.org/licenses/by-nc-sa/4.0/ + +//!+test +package word + +import "testing" + +func TestPalindrome(t *testing.T) { + if !IsPalindrome("detartrated") { + t.Error(`IsPalindrome("detartrated") = false`) + } + if !IsPalindrome("kayak") { + t.Error(`IsPalindrome("kayak") = false`) + } +} + +func TestNonPalindrome(t *testing.T) { + if IsPalindrome("palindrome") { + t.Error(`IsPalindrome("palindrome") = true`) + } +} + +// The tests below are expected to fail. +// See package gopl.io/ch11/word2 for the fix. + +func TestFrenchPalindrome(t *testing.T) { + if !IsPalindrome("été") { + t.Error(`IsPalindrome("été") = false`) + } +} + +func TestCanalPalindrome(t *testing.T) { + input := "A man, a plan, a canal: Panama" + if !IsPalindrome(input) { + t.Errorf(`IsPalindrome(%q) = false`, input) + } +} + +// OMIT diff --git a/lectures/04-testing/word2/word.go b/lectures/04-testing/word2/word.go new file mode 100644 index 0000000..846c8d2 --- /dev/null +++ b/lectures/04-testing/word2/word.go @@ -0,0 +1,29 @@ +// Copyright © 2016 Alan A. A. Donovan & Brian W. Kernighan. +// License: https://creativecommons.org/licenses/by-nc-sa/4.0/ + +// See page 305. +//!+ + +// Package word provides utilities for word games. +package word + +import "unicode" + +// IsPalindrome reports whether s reads the same forward and backward. +// Letter case is ignored, as are non-letters. +func IsPalindrome(s string) bool { + var letters []rune + for _, r := range s { + if unicode.IsLetter(r) { + letters = append(letters, unicode.ToLower(r)) + } + } + for i := range letters { + if letters[i] != letters[len(letters)-1-i] { + return false + } + } + return true +} + +//!- diff --git a/lectures/04-testing/word2/word_test.go b/lectures/04-testing/word2/word_test.go new file mode 100644 index 0000000..9f16309 --- /dev/null +++ b/lectures/04-testing/word2/word_test.go @@ -0,0 +1,146 @@ +// Copyright © 2016 Alan A. A. Donovan & Brian W. Kernighan. +// License: https://creativecommons.org/licenses/by-nc-sa/4.0/ + +package word + +import ( + "fmt" + "math/rand" + "testing" + "time" +) + +//!+bench + +//!-bench + +//!+test +func TestIsPalindrome(t *testing.T) { + var tests = []struct { + input string + want bool + }{ + {"", true}, + {"a", true}, + {"aa", true}, + {"ab", false}, + {"kayak", true}, + {"detartrated", true}, + {"A man, a plan, a canal: Panama", true}, + {"Able was I ere I saw Elba", true}, + {"été", true}, + {"Et se resservir, ivresse reste.", true}, + {"palindrome", false}, // non-palindrome + {"desserts", false}, // semi-palindrome + } + for _, test := range tests { + if got := IsPalindrome(test.input); got != test.want { + t.Errorf("IsPalindrome(%q) = %v", test.input, got) + } + } +} + +//!-test + +//!+bench +func BenchmarkIsPalindrome(b *testing.B) { + for i := 0; i < b.N; i++ { + IsPalindrome("A man, a plan, a canal: Panama") + } +} + +//!-bench + +//!+example + +func ExampleIsPalindrome() { + fmt.Println(IsPalindrome("A man, a plan, a canal: Panama")) + fmt.Println(IsPalindrome("palindrome")) + // Output: + // true + // false +} + +//!-example + +/* +//!+random +import "math/rand" + +//!-random +*/ + +//!+random +// randomPalindrome returns a palindrome whose length and contents +// are derived from the pseudo-random number generator rng. +func randomPalindrome(rng *rand.Rand) string { + n := rng.Intn(25) // random length up to 24 + runes := make([]rune, n) + for i := 0; i < (n+1)/2; i++ { + r := rune(rng.Intn(0x1000)) // random rune up to '\u0999' + runes[i] = r + runes[n-1-i] = r + } + return string(runes) +} + +func TestRandomPalindromes(t *testing.T) { + // Initialize a pseudo-random number generator. + seed := time.Now().UTC().UnixNano() + t.Logf("Random seed: %d", seed) + rng := rand.New(rand.NewSource(seed)) + + for i := 0; i < 1000; i++ { + p := randomPalindrome(rng) + if !IsPalindrome(p) { + t.Errorf("IsPalindrome(%q) = false", p) + } + } +} + +//!-random + +/* +// Answer for Exercicse 11.1: Modify randomPalindrome to exercise +// IsPalindrome's handling of punctuation and spaces. + +// WARNING: the conversion r -> upper -> lower doesn't preserve +// the value of r in some cases, e.g., µ Μ, ſ S, ı I + +// randomPalindrome returns a palindrome whose length and contents +// are derived from the pseudo-random number generator rng. +func randomNoisyPalindrome(rng *rand.Rand) string { + n := rng.Intn(25) // random length up to 24 + runes := make([]rune, n) + for i := 0; i < (n+1)/2; i++ { + r := rune(rng.Intn(0x200)) // random rune up to \u99 + runes[i] = r + r1 := r + if unicode.IsLetter(r) && unicode.IsLower(r) { + r = unicode.ToUpper(r) + if unicode.ToLower(r) != r1 { + fmt.Printf("cap? %c %c\n", r1, r) + } + } + runes[n-1-i] = r + } + return "?" + string(runes) + "!" +} + +func TestRandomNoisyPalindromes(t *testing.T) { + // Initialize a pseudo-random number generator. + seed := time.Now().UTC().UnixNano() + t.Logf("Random seed: %d", seed) + rng := rand.New(rand.NewSource(seed)) + + n := 0 + for i := 0; i < 1000; i++ { + p := randomNoisyPalindrome(rng) + if !IsPalindrome(p) { + t.Errorf("IsNoisyPalindrome(%q) = false", p) + n++ + } + } + fmt.Fprintf(os.Stderr, "fail = %d\n", n) +} +*/