let's go를 배워보자 13일차 - Testing 2

0

lets-go

목록 보기
15/15

10. Integration Testing

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를 만들도록 하자.

  1. setup script는 database table을 만들고, 여기에 우리의 test code에서 동작하는 test data를 넣도록 하자.
  2. teardown script는 database table을 다운시키고 data를 없앤다.

우리는 이 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() 함수는 다음과 같은 역할을 한다.

  1. 새로운 *sql.DB connection pool을 test database를 위해 만들어준다.
  2. database table과 dummy data를 만들기위해 setup.sql 스크립트를 실행한다.
  3. 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하도록 할 것이다.

11. Testing the UserModel.Get Method

이제 intergration test인 mysql.UserModel.Get() 메서드를 테스트해보도록 하자.

우리의 setup.sql 스크립트는 users 테이블을 만들고 이에 해당하는 record를 하나 만든다. 해당 record는 userID가 1이고 email address가 alice@example.com이다. 따라서 우리가 원하는 것은 다음과 같다.

  1. mysql.UserModel.Get(1)은 user에 대한 정보를 가진 models.User구조체를 반환한다.
  2. 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는 생략된 채로 나머지 테스트들이 실행된다.

12. Profiling Test Coverage

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되었는 지를 확인할 수 있다는 것이다.

0개의 댓글