mock으로 만들어진 의존성으로 end-to-end 테스트를 진행하는 것은 굉장히 좋은 일이다. 그러나 실제로 mysql를 사용하여 테스팅한다면 더더욱 좋은 것이다.
이를 위해 우리는 integration test를 mysql database test version에서 동작시킬 수 있다. mysql database test version은 production database를 따라한 것으로 test목적으로만 존재한다.
mysql.UserModel.Get()
메서드가 잘 동작하는 것을 보장하기위해 integration test를 설정할 것이다.
먼저 mysql database test version을 만들도록 하자.
root
user로 terminal에 접속한 뒤, test_user
를 만들고, 다음의 명령어로 test_sinppetbox
database를 새로 만들도록 하자.
CREATE DATABASE test_snippetbox CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'test_web'@'localhost';
GRANT CREATE, DROP, ALTER, INDEX, SELECT, INSERT, UPDATE, DELETE ON test_snippetbox.* TO 'test_web'@'localhost';
ALTER USER 'test_web'@'localhost' IDENTIFIED BY 'pass';
user도 만들었고 database도 만들었다. 이제 두 가지 sql script를 만들도록 하자.
우리는 이 script를 integration test의 시작과 끝에서만 실행할 것이다. 그래서 test database가 매번 완전히 reset될 것이다. 이는 하나의 test가 다른 test에 영향을 주는 것을 막아주고, 격리하는 역할을 한다.
스크립트를 만들기위해서 pkg/models/mysql/testdata
디렉터리를 만들고 다음의 script를 넣도록 하자.
mkdir pkg/models/mysql/testdata
touch pkg/models/mysql/testdata/setup.sql
touch pkg/models/mysql/testdata/teardown.sql
setup.sql
에 다음의 스크립트를 넣어주도록 하자.
pkg/models/mysql/testdata/setup.sql
USE test_snippetbox;
CREATE TABLE snippets (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
created DATETIME NOT NULL,
expires DATETIME NOT NULL
);
CREATE INDEX idx_snippets_created ON snippets(created);
CREATE TABLE users (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
hashed_password CHAR(60) NOT NULL,
created DATETIME NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE
);
ALTER TABLE users ADD CONSTRAINT users_uc_email UNIQUE (email);
INSERT INTO users (name, email, hashed_password, created) VALUES (
'Alice Jones',
'alice@example.com'
'$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6HzGJSWG',
'2018-12-23 17:25:22'
);
다음으로 database를 비워주는 teardown.sql
파일을 채우도록 하자.
pkg/models/mysql/testdata/teardown.sql
USE test_snippetbox;
DROP TABLE users;
DROP TABLE snippets;
참고로 go compiler는 testdata
과 같은 디렉터리는 컴파일할 때 무시한다. 재밌는 것은 directory나 file에 _
나 .
으로 시작하는 이름을 가지면 컴파일할 때 무시한다.
이제 해당 스크립트를 사용하기 쉽게해주는 함수를 하나 만들도록 하자. pkg/models/mysql/testuitls_test.go
파일에 newTestDB()
함수를 추가하도록 하자. 먼저 pkg/models/mysql/testutils_test.go
파일을 만들도록 하자.
touch pkg/models/mysql/testutils_test.go
newTestDB()
함수는 다음과 같은 역할을 한다.
*sql.DB
connection pool을 test database를 위해 만들어준다.setup.sql
스크립트를 실행한다.teardown.sql
스크립트를 실행하고 db connection을 끊기위한 익명함수를 반환한다.pkg/models/mysql/testutils_test.go
...
func newTestDB(t *testing.T) (*sql.DB, func()) {
// Establish a sql.DB connection pool for our test database. Because our
// setup and teardown scripts contains multiple SQL statements, we need
// to use the `multiStatements=true` parameter in our DSN. This instructs
// our MySQL database driver to support executing multiple SQL statements
// in one db.Exec() call.
db, err := sql.Open("mysql", "test_web:pass@/test_snippetbox?parseTime=true&multiStatements=true")
if err != nil {
t.Fatal(err)
}
// Read the setup SQL script from file and execute the statements.
script, err := os.ReadFile("./testdata/setup.sql")
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(string(script))
if err != nil {
t.Fatal(err)
}
// Return the connection pool and an anonymous function which reads and
// executes the teardown script, and closes the connection pool. We can
// assign this anonymous function and call it later once our test has
// completed.
return db, func() {
script, err := os.ReadFile("./testdata/teardown.sql")
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(string(script))
if err != nil {
t.Fatal(err)
}
db.Close()
}
}
...
이제 integration test를 진행할 때 newTestDB
를 사용하여 db를 setup하고 테스트가 종료되면 teardown하도록 할 것이다.
이제 intergration test인 mysql.UserModel.Get()
메서드를 테스트해보도록 하자.
우리의 setup.sql
스크립트는 users
테이블을 만들고 이에 해당하는 record를 하나 만든다. 해당 record는 userID가 1이고 email address가 alice@example.com
이다. 따라서 우리가 원하는 것은 다음과 같다.
mysql.UserModel.Get(1)
은 user에 대한 정보를 가진 models.User
구조체를 반환한다.mysql.UserModel.Get()
으로 1이 아닌 다른 UserId를 호출하면 models.ErrNoRecord
error를 반환하도록 한다.이제 새로운 파일을 만들고 test code를 작성해보자.
touch pkg/models/mysql/users_test.go
pkg/models/mysql/users_test.go
package mysql
import (
"reflect"
"testing"
"time"
"github.com/gyu-young-park/snippetbox/pkg/models"
)
func TestUserModelGet(t *testing.T) {
// Skip the test if the `-short` flag is provided when running the test.
// We'll talk more about this in a moment.
if testing.Short() {
t.Skip("mysql: skipping integration test")
}
// Set up a suite of table-driven tests and expected results.
tests := []struct {
name string
userID int
wantUser *models.User
wantError error
}{
{
name: "Valid ID",
userID: 1,
wantUser: &models.User{
ID: 1,
Name: "Alice Jones2",
Email: "alice2@example.com",
Created: time.Date(2018, 12, 23, 17, 25, 22, 0, time.UTC),
Active: true,
},
wantError: nil,
},
{
name: "Zero ID",
userID: 0,
wantUser: nil,
wantError: models.ErrNoRecord,
},
{
name: "Non-existent ID",
userID: 2,
wantUser: nil,
wantError: models.ErrNoRecord,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Initialize a connection pool to our test database, and defer a
// call to the teardown function, so it is always run immediately
// before this sub-test returns.
db, teardown := newTestDB(t)
defer teardown()
// Create a new instance of the UserModel.
m := UserModel{db}
// Call the UserModel.Get() method and check that the return value
// and error match the expected values for the sub-test.
user, err := m.Get(tt.userID)
if err != tt.wantError {
t.Errorf("want %v; got %s", tt.wantError, err)
}
if !reflect.DeepEqual(user, tt.wantUser) {
t.Errorf("want %v; got %v", tt.wantUser, user)
}
})
}
}
reflect.DeepEqaul()
함수를 사용함으로서 임의의 complex type들을 정확하게 비교할 수 있다.
테스트를 실행시키면 문제없이 구동될 것이다.
go test -v ./pkg/models/mysql/
=== RUN TestUserModelGet
=== RUN TestUserModelGet/Valid_ID
users_test.go:59: testing "Valid ID" for want-user &{1 Alice Jones2 alice2@example.com [] 2018-12-23 17:25:22 +0000 UTC true} and want-error <nil>
=== RUN TestUserModelGet/Non-existent_ID
users_test.go:59: testing "Non-existent ID" for want-user <nil> and want-error models: no matching record found
--- PASS: TestUserModelGet (0.06s)
--- PASS: TestUserModelGet/Valid_ID (0.03s)
--- PASS: TestUserModelGet/Non-existent_ID (0.03s)
PASS
ok github.com/gyu-young-park/snippetbox/pkg/models/mysql (cached)
성공한 것을 확인할 수 있다.
intergration test는 오랜 시간이 걸린다. 따라서, 짧은 테스트를 진행할 때는, 이러한 test를 생략하고 싶을 때가 있다.
그럴 때는 test case에 testing.Short()
함수를 써주고, test를 실행할 대는 -short
flag를 넣어주면 된다.
go test -v -short ./...
=== RUN TestShowSnippet
=== RUN TestShowSnippet/Valid_ID
=== RUN TestShowSnippet/Non-existent_ID
=== RUN TestShowSnippet/Negative_ID
=== RUN TestShowSnippet/Decimal_ID
=== RUN TestShowSnippet/String_ID
=== RUN TestShowSnippet/Empty_ID
=== RUN TestShowSnippet/Trailing_slash
--- PASS: TestShowSnippet (0.07s)
--- PASS: TestShowSnippet/Valid_ID (0.00s)
--- PASS: TestShowSnippet/Non-existent_ID (0.00s)
--- PASS: TestShowSnippet/Negative_ID (0.00s)
--- PASS: TestShowSnippet/Decimal_ID (0.00s)
--- PASS: TestShowSnippet/String_ID (0.00s)
--- PASS: TestShowSnippet/Empty_ID (0.00s)
--- PASS: TestShowSnippet/Trailing_slash (0.00s)
=== RUN TestPing
--- PASS: TestPing (0.00s)
=== RUN TestSignupUser
=== RUN TestSignupUser/Valid_submission
=== RUN TestSignupUser/Empty_name
=== RUN TestSignupUser/Empty_email
=== RUN TestSignupUser/Empty_password
=== RUN TestSignupUser/Invalid_email_(incomplete_domain)
=== RUN TestSignupUser/Invalid_email_(missing_@)
=== RUN TestSignupUser/Invalid_email_(missing_local_part)
=== RUN TestSignupUser/Short_password
=== RUN TestSignupUser/Duplicate_email
=== RUN TestSignupUser/Invalid_CSRF_Token
--- PASS: TestSignupUser (0.01s)
--- PASS: TestSignupUser/Valid_submission (0.00s)
--- PASS: TestSignupUser/Empty_name (0.00s)
--- PASS: TestSignupUser/Empty_email (0.00s)
--- PASS: TestSignupUser/Empty_password (0.00s)
--- PASS: TestSignupUser/Invalid_email_(incomplete_domain) (0.00s)
--- PASS: TestSignupUser/Invalid_email_(missing_@) (0.00s)
--- PASS: TestSignupUser/Invalid_email_(missing_local_part) (0.00s)
--- PASS: TestSignupUser/Short_password (0.00s)
--- PASS: TestSignupUser/Duplicate_email (0.00s)
--- PASS: TestSignupUser/Invalid_CSRF_Token (0.00s)
=== RUN TestSecureHeaders
--- PASS: TestSecureHeaders (0.00s)
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
=== RUN TestHumanDate/Empty
=== RUN TestHumanDate/CET
--- PASS: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
--- PASS: TestHumanDate/Empty (0.00s)
--- PASS: TestHumanDate/CET (0.00s)
PASS
ok github.com/gyu-young-park/snippetbox/cmd/web (cached)
? github.com/gyu-young-park/snippetbox/pkg/forms [no test files]
? github.com/gyu-young-park/snippetbox/pkg/models [no test files]
? github.com/gyu-young-park/snippetbox/pkg/models/mock [no test files]
=== RUN TestUserModelGet
users_test.go:15: mysql: skipping integration test
--- SKIP: TestUserModelGet (0.00s)
PASS
ok github.com/gyu-young-park/snippetbox/pkg/models/mysql 0.001s
우리의 integration test는 생략된 채로 나머지 테스트들이 실행된다.
go test
tool의 좋은 점은 test coverage를 표현하기위해 metrics와 viusalization을 제공한다는 것이다.
go test
에 -cover
flag를 넣으면 coverage를 보여준다.
go test -cover ./...
ok github.com/gyu-young-park/snippetbox/cmd/web 0.013s coverage: 48.3% of statements
? github.com/gyu-young-park/snippetbox/pkg/forms [no test files]
? github.com/gyu-young-park/snippetbox/pkg/models [no test files]
? github.com/gyu-young-park/snippetbox/pkg/models/mock [no test files]
ok github.com/gyu-young-park/snippetbox/pkg/models/mysql 0.067s coverage: 10.4% of statements
각 패키지 별로의 test coverage가 나오게 된다.
더 디테일한 test coverage metrics 정보를 볼 수 있는데, test profile을 만들면 된다.
go test -coverprofile=/tmp/profile.out ./...
-coverprofile
flag를 사용하면 해당 위치에 coverage profile을 만들어준다. 우리는 go tool cover
명령어를 사용하여 coverage profile을 확인할 수 있다.
go tool cover -func=/tmp/profile.out
github.com/gyu-young-park/snippetbox/cmd/web/handlers.go:13: ping 100.0%
github.com/gyu-young-park/snippetbox/cmd/web/handlers.go:17: home 0.0%
github.com/gyu-young-park/snippetbox/cmd/web/handlers.go:32: showSnippet 90.9%
github.com/gyu-young-park/snippetbox/cmd/web/handlers.go:54: createSnippetForm 0.0%
github.com/gyu-young-park/snippetbox/cmd/web/handlers.go:61: createSnippet 0.0%
github.com/gyu-young-park/snippetbox/cmd/web/handlers.go:93: signupUserForm 100.0%
github.com/gyu-young-park/snippetbox/cmd/web/handlers.go:99: signupUser 86.4%
github.com/gyu-young-park/snippetbox/cmd/web/handlers.go:137: loginUserForm 0.0%
github.com/gyu-young-park/snippetbox/cmd/web/handlers.go:143: loginUser 0.0%
github.com/gyu-young-park/snippetbox/cmd/web/handlers.go:169: logoutUser 0.0%
github.com/gyu-young-park/snippetbox/cmd/web/helpers.go:13: serverError 0.0%
github.com/gyu-young-park/snippetbox/cmd/web/helpers.go:20: clientError 100.0%
github.com/gyu-young-park/snippetbox/cmd/web/helpers.go:24: notFound 100.0%
github.com/gyu-young-park/snippetbox/cmd/web/helpers.go:28: addDefaultData 85.7%
github.com/gyu-young-park/snippetbox/cmd/web/helpers.go:39: render 60.0%
github.com/gyu-young-park/snippetbox/cmd/web/helpers.go:58: isAuthenticated 75.0%
github.com/gyu-young-park/snippetbox/cmd/web/main.go:32: main 0.0%
github.com/gyu-young-park/snippetbox/cmd/web/main.go:87: openDB 0.0%
github.com/gyu-young-park/snippetbox/cmd/web/middleware.go:13: recoverPanic 66.7%
github.com/gyu-young-park/snippetbox/cmd/web/middleware.go:32: logRequest 100.0%
github.com/gyu-young-park/snippetbox/cmd/web/middleware.go:39: secureHeaders 100.0%
github.com/gyu-young-park/snippetbox/cmd/web/middleware.go:47: requireAuthentication 16.7%
github.com/gyu-young-park/snippetbox/cmd/web/middleware.go:67: noSurf 100.0%
github.com/gyu-young-park/snippetbox/cmd/web/middleware.go:78: authenticate 26.3%
github.com/gyu-young-park/snippetbox/cmd/web/routes.go:10: routes 100.0%
github.com/gyu-young-park/snippetbox/cmd/web/templates.go:24: humanDate 100.0%
github.com/gyu-young-park/snippetbox/cmd/web/templates.go:40: newTemplateCache 76.5%
github.com/gyu-young-park/snippetbox/pkg/models/mysql/snippets.go:14: Insert 0.0%
github.com/gyu-young-park/snippetbox/pkg/models/mysql/snippets.go:40: Get 0.0%
github.com/gyu-young-park/snippetbox/pkg/models/mysql/snippets.go:75: Latest 0.0%
github.com/gyu-young-park/snippetbox/pkg/models/mysql/users.go:17: Insert 0.0%
github.com/gyu-young-park/snippetbox/pkg/models/mysql/users.go:45: Authenticate 0.0%
github.com/gyu-young-park/snippetbox/pkg/models/mysql/users.go:77: Get 87.5%
total: (statements) 39.1%
-func
으로 보면 함수별로의 coverage를 stdout으로 볼 수 있다. 만약 시각화를 하고싶다면 -html
을 사용하면 된다.
go tool cover -html=/tmp/profile.out
html
파일이 열리고 coverage를 확인할 수 있다. 제일 좋은 점은 어디에 어떤 부분이 cover되지 않았고 cover되었는 지를 확인할 수 있다는 것이다.