Wednesday, September 21, 2016

Bài 23: Test chương trình Go

Kiểm thử hay test chương trình là yêu cầu bắt buộc để đảm bảo chương trình hoạt động đúng logic. Việc test chương trình gồm nhiều cấp độ: test bộ phận (unit testing), test tích hợp (integration testing), test hệ thống (system testing) và test chấp nhận từ người dùng (user acceptance testing). Giai đoạn cơ bản và quan trọng nhất là test bộ phận, được bắt đầu từ trong quá trình lập trình bởi các lập trình viên. Go cung cấp công cụ go test giúp cho test bộ phận một cách tự động trở nên dễ dàng hơn bao giờ hết.

Việc tạo ra chức năng test tự động trong Go không khác gì so với việc tạo ra các hàm chức năng. Chúng ta sẽ viết hàm mà nội dung của nó là kiểm tra một hoặc một nhóm hàm chức năng nào đó hoạt động đúng hay sai. Cấu trúc dữ liệu nhập, xử lý ngoại lệ là những điều cần lưu ý để tạo ra một hàm test hiệu quả.

Công cụ go test


Go test là công cụ được Go cung cấp để thực hiện việc test bộ phận. Việc chúng ta phải làm là tạo ra các các file có tên kết thúc bằng _test.go trong thư mục chứa mã nguồn. Khi biên dịch ứng dụng với go build, các file này sẽ không được biên dịch nhưng khi dùng go test các file này sẽ được biên dịch và xuất ra các kết quả cho chúng ta biết các hàm cần test có đạt hay không.

Go cung cấp 3 loại hàm để chúng ta sử dụng trong file _test.go phục vụ cho việc test bộ phận tự động: kiểm thử (test), kiểm chuẩn (benchmark) và ví dụ (example): 
- Hàm kiểm thử sẽ có tên bắt đầu bằng Test với mục đích sẽ kiểm tra logic hàm cần test có đúng như mong muốn hay không. go test khi gọi các hàm kiểm thử  này sẽ xuất kết quả PASS (đúng logic) hoặc FAIL (sai logic).
- Hàm kiểm chuẩn sẽ có tên bắt đầu bằng Benchmark với mục đích đo hiệu năng hoạt động của hàm. go test sẽ trả kết quả là thời gian thực hiện trung bình của hàm cần kiểm.
- Hàm ví dụ sẽ có tên bắt đầu bằng Example với mục đích tạo ra ví dụ mẫu khi tạo tài liệu cho hàm chức năng.

go test sẽ quét toàn bộ các hàm thuộc 3 nhóm trên trong các file _test.go, biên dịch tất cả, tạo ra package main giả để gọi thực thi các hàm này, xuất kết quả rồi dọn sạch các dữ liệu và file trung gian tạo ra trong quá trình thực hiện go test.

Hàm kiểm thử


Đầu tiên phải khai báo package sử dụng là testing trước khi tạo các hàm kiểm thử. Các hàm kiểm thử phải tuân theo định dạng sau:
func TestName(t *testing.T) {
    // ...
với Name là tùy chọn tên do chúng ta đặt, nhưng chữ cái đầu phải viết hoa. Ví dụ: TestFibo là tên hàm kiểm thử hợp lệ, còn Testfibo thì không. Tham số t cung cấp phương thức báo cáo kết quả test và xuất ra các thông tin liên quan. 


Chúng ta cùng khảo sát ví dụ thú vị sau để hiểu hơn cách viết hàm kiểm thử. Palindrome là từ, chuỗi, câu hay số mà đọc ngược hay xuôi đều như nhau. Ví dụ: level, 636, iam mai, v.v... Trong ví dụ này, chúng ta sẽ tạo ra hàm kiểm tra một chuỗi liệu có phải là một palindrome hay không ở file palindrome.go trong thư mục gotest: 

package gotest

func IsPalindrome(s string) bool {
    for i := range s {
        if s[i] != s[len(s)-1-i] {
            return false        
       }
    }
    return true
}
Sau đó chúng ta sẽ tạo hàm kiểm thử cho hàm này ở file palindrome_test.go trong cùng thư mục:
package gotest
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`)
    }
}
Để thực hiện kiểm thử, chúng ta chạy lệnh go test. Kết quả nhận được như bên dưới:
PASS
ok gotest 0.077s
Trong trường hợp muốn hiển thị chi tiết quá trình kiểm thử, chúng ta thêm tham số -v: go test -v. Kết quả như sau:
=== RUN TestPalindrome
--- PASS: TestPalindrome (0.00s)
=== RUN TestNonPalindrome
--- PASS: TestNonPalindrome (0.00s)
PASS
ok gotest 0.077s
Để test một số hàm nhất định, chúng ta có thể thêm tham số -run=<chuỗi chứa đoạn tên hàm>. Ví dụ, chỉ muốn test hàm TestNonPalindrome, ta dùng lệnh:  go test -v -run="Non". Kết quả xuất ra:
=== RUN TestNonPalindrome
--- PASS: TestNonPalindrome (0.00s)
PASS
ok gotest 0.077s

Chúng ta có thể tạo ra 1 bảng với nhiều giá trị kiểm thử để đảm bảo tính chính xác của hàm như sau:
func TestPalindrome(t *testing.T) {
    var tests = []struct {
        input string
        want bool
    }{
        {"", true},
        {"a", true},
        {"aa", true},
        {"ab", false},
        {"kayak", true},
        {"detartrated", true},
        {"Able was I ere I saw Elba", true},
        {"été", 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)
        }
    }
}
Kết quả thực thi go test -v cho kết quả như sau:
=== RUN TestPalindrome
--- FAIL: TestPalindrome (0.00s)
palindrome_test.go:23: IsPalindrome("Able was I ere I saw Elba") = false
palindrome_test.go:23: IsPalindrome("été") = false
FAIL
exit status 1
FAIL gotest 0.081s
Nguyên nhân 2 dòng ở trên lỗi là do chữ hoa và chữ thường ở dòng trên và lỗi unicode ở dòng dưới. Do é không phải là ký tự ASCII nên so sánh như hàm IsPalindrome sẽ cho kết quả sai. Muốn có kết quả đúng, hàm này cần dùng rune để so sánh.


Hàm kiểm chuẩn



Hàm kiểm chuẩn giúp đo hiệu năng của một hàm. Ở Go, hàm kiểm chuẩn tương tự như hàm kiểm thử nhưng bắt đầu bằng Benchmark và có tham số là *testing.B. Chúng ta cùng tạo hàm kiểm chuẩn cho palindrome ở trên như sau:

func BenchmarkIsPalindrome(b *testing.B) {
    for i := 0; i < b.N; i++ {
        IsPalindrome("able was I ere I saw elba")
    }
}
Để kiểm chuẩn 1 hàm chúng ta chạy lệnh: go test -bench=<Chuỗi chứa đoạn tên hàm cần kiểm chuẩn>. Ví dụ ở trên, sau khi thực thi: go test -bench="Palin", kết quả nhận được như sau:
BenchmarkIsPalindrome-4 5000000 247 ns/op
PASS
ok gotest 2.569s
Số 4 trong  BenchmarkIsPalindrome-4 nghĩa là số GOMAXPROCS, giúp chúng ta biết số tiểu trình sử dụng khi xử lý đồng thời. Kết quả kiểm chuẩn cho chúng ta biết trong 5 triệu lần thực hiện thì bình quân hàm IsPalindrome mất 247 nano giây thực thi. Dựa trên con số này, chúng ta có thể cải tiến hàm để có được thời gian thực thi nhanh hơn. Ví dụ ở hàm IsPalindrome, chúng ta thấy là chỉ cần lặp đến giữa chuỗi thay vì cả chuỗi như hiện tại mới xác định được có đối xứng hay không. Chúng ta viết lại hàm này như sau:
func IsPalindrome(s string) bool {
    n := len(s) / 2
    for i := 0; i <= n; i++ {
        if s[i] != s[len(s)-1-i] {
            return false
        }
    }
    return true
}
Kết quả kiểm chuẩn mới là: 52.1 nano giây để thực thi, mất khoảng 1/5 so với trước!
BenchmarkIsPalindrome-4 20000000 52.1 ns/op
PASS
ok gotest 1.270s
Để kiểm chuẩn tất cả các hàm kiểm chuẩn đã khai báo, chúng ta thực hiện: go test -bench=. Ngoài ra để xem bộ nhớ sử dụng trong hàm cần test là bao nhiêu ta có thể thêm tham số -benchmem như sau: go test -bench=. -benchmem

Với các công cụ tính thời gian và bộ nhớ sử dụng, Go giúp chúng ta so sánh để xây dựng những hàm hiệu quả hơn. Tuy nhiên để xác định hàm nào cần cải thiện để nâng cao hiệu suất là một vấn đề không đơn giản. Thông thường lập trình viên sẽ chọn cải tiến những hàm "nhạy cảm", sử dụng nhiều CPU hoặc bộ nhớ hoặc cả 2. Go cũng cung cấp công cụ để giúp chúng ta xác định các hàm này thông qua go test và go tool pprof. Go test giúp tạo các hồ sơ thực thi như sau:
- Hồ sơ CPU: Xác định các hàm sử dụng nhiều CPU nhất bằng cách mỗi giây hệ thống lấy thông tin tầm 100 lần, mỗi lần là một mẫu hồ sơ. Cách gọi: go test -cpuprofile=<tên file>
- Hồ sơ bộ nhớ heap: Xác định các hàm chiếm dụng nhiều bộ nhớ nhất. Cách gọi: go test -memprofile=<tên file>
- Hồ sơ khóa goroutines: Xác định các hàm khóa các goroutines lâu nhất. Cách gọi: go test -blockprofile=<tên file>

Khi có các hồ sơ này, chúng ta dùng go tool pprof để phân tích. Do để tiết kiệm bộ nhớ, các hàm được kiểm chuẩn này chỉ lưu địa chỉ của chúng trong hồ sơ mà không có tên. Để có được thông tin tên hàm chúng ta cần cung cấp file thực thi được tạo ra ở lệnh go test bên trên. Thông thường file này bị xóa đi nhưng trường hợp này được giữ lại. Để phân tích các hàm chúng ta dùng: go tool pprof <file thực thi> <file hồ sơ>. Khi thực thi, chúng ta phải nhập thêm lệnh để xem, quan trọng nhất là topN để xem danh sách N hàm. Chi tiết các lệnh gõ help. Với ví dụ trên, việc phân tích hồ sơ hàm được thực hiện như sau:
- go test -bench=. -cpuprofile=cpu.log
- go tool pprof gotest.test cpu.log

Kết quả:
Entering interactive mode (type "help" for commands)
(pprof) top5
1.44s of 1.44s total (  100%)
Showing top 5 nodes out of 16 (cum >= 0.01s)
      flat      flat%     sum%       cum    cum%
   1.20s  83.33%  83.33%      1.20s  83.33%  gotest.IsPalindrome
    0.23s 15.97%  99.31%      1.43s  99.31%  gotest.BenchmarkIsPalindrome
    0.01s   0.69%     100%      0.01s   0.69%   runtime.cgocall
         0         0%     100%      0.01s   0.69%   fmt.Fprintln
         0         0%     100%      0.01s   0.69%   fmt.Println
- Ở đây tôi chọn lệnh top5, lấy 5 hàm sử dụng nhiều CPU nhất. Bản phân tích này cho biết tổng thời gian thực thi là 1.44s và có tất cả 16 hàm thực thi. Ở đây chỉ hiển thị 5 dòng đầu.
- 2 cột đầu là thời gian thực thi của hàm và tỷ lệ thời gian trên toàn thời gian của ứng dụng. Ví dụ hàm IsPalindrome thực thi trong 1.2s trên tổng số 1.44s thực thi của chương trình và chiếm 83.33% thời gian chương trình chạy.
- Cột thứ 3 cộng dồn tỉ lệ thực thi của các hàm tính từ trên xuống. Ví dụ ở dòng 2 là tổng % của cột 2 dòng 1 và 2.
- Cột 4&5 là tổng thời gian của hàm được đưa vào xử lý (gồm thời gian thực thi và các thời gian khác liên quan).
Dựa trên thông tin như thế này, chúng ta có thể biết được phần nào và quyết định nên chỉnh sửa để tăng hiệu năng những hàm nào.

Hàm ví dụ


Hàm ví dụ bắt đầu bằng Example và nó không có tham số. Ví dụ như sau:
func ExampleIsPalindrome() {
    fmt.Println(IsPalindrome("able was I ere I saw elba"))
    fmt.Println(IsPalindrome("palindrome"))
    // Output:
    // true
    // false
}
Hàm ví dụ như trên có 3 chức năng:
- Khi tạo tài liệu bằng go doc, phần ví dụ sẽ được thêm vào cho tài liệu hàm IsPalindrome.
- Khi gọi go test sẽ thực thi hàm này và kiểm tra dữ liệu xuất ra có khớp với dữ liệu mô tả ở Output trong hàm không. Nếu không sẽ báo lỗi FAIL.
- Khi tạo tài liệu thì hàm này được đưa vào Go playground giúp cho người đọc có thể thực thi thực tế trên trình duyệt để xem và có thể sửa lại để kiểm tra kết quả.

Bài tiếp theo chúng ta sẽ bàn về gỡ lỗi ứng dụng.



Tóm tắt:
Kiểm thử là hoạt động thường xuyên để đảm bảo ứng dụng chạy đúng. Go cung cấp kiểm thử ở mức đơn vị:
- Các hàm khai báo trong file _test.go và chỉ được biên dịch với lệnh go test.
- Có 3 nhóm hàm là kiểm thử, kiểm chuẩn và ví dụ.
- Hàm kiểm thử sẽ có tên bắt đầu bằng Test với mục đích sẽ kiểm tra logic hàm cần test có đúng như mong muốn hay không. go test khi gọi các hàm kiểm thử  này sẽ xuất kết quả PASS (đúng logic) hoặc FAIL (sai logic).
- Hàm kiểm chuẩn sẽ có tên bắt đầu bằng Benchmark với mục đích đo hiệu năng hoạt động của hàm. go test sẽ trả kết quả là thời gian thực hiện trung bình của hàm cần kiểm.
- Hàm ví dụ sẽ có tên bắt đầu bằng Example với mục đích tạo ra ví dụ mẫu khi tạo tài liệu cho hàm chức năng.

1 comment: