go test code를 작성하는 것은 하나의 방법만 있는 것이 아니라, 여러가지 방법들이 있다. 그러나 여기에는 convention들이 있고, 패턴이 있으며 좋은 관례가 존재한다.
이번에는 우리의 application에 좋은 test code를 추가하여, 재사용성이 높고 널리 사용되는 test code를 작성해보도록 하자.
이번 챕터에서는 우리의 humanDate()
함수에 unit test 코드를 추가할 것이다.
humanDate()
함수는 다음과 같다.
cmd/web/templates.go
...
func humanDate(t time.Time) string {
return t.Format("02 Jan 2006 at 15:04")
}
...
굉장히 단순한 함수이므로, test코드를 작성하는 가장 기본적인 방법들에 대해서 배울 수 있을 것이다.
go에서 test code file을 만드는 관계 중 하나는 _test.go
라는 파일을 만들어 test하려는 대상 파일 옆에 두는 것이다. 따라서, 우리가 가장 먼저 해야하는 일은 cmd/web/templates_test.go
라는 새로운 파일을 templates.go
파일 옆에 두도록 하자.
touch cmd/web/templates_test.go
이 다음 humanDate
함수에 대한 unit test 코드를 만들어보도록 하자.
cmd/web/templates_test.go
package main
import (
"testing"
"time"
)
func TestHumanDate(t *testing.T) {
// Initialize a new time.Time object and pass it to the humanDate function.
tm := time.Date(2020, 12, 17, 10, 0, 0, 0, time.UTC)
hd := humanDate(tm)
// Check that the output from the humanDate function is in the format we
// expect. If it isn't what we expect, use the t.Errorf() function to
// indicate that the test has failed and log the expected and actual
// values.
if hd != "17 Dec 2020 at 10:00" {
t.Errorf("want %q; got %q", "17 Dec 2020 at 10:00", hd)
}
}
위의 unit test code의 틀이 대부분의 test 코드 틀이다. 짚고 넘어가야 할 점들을 보면 다음과 같다.
humanDate
라는 함수를 호출하고 우리가 예상한 결과와 매칭하는 것이다.func(*testing.T)
function signature를 가진다.Test
라는 접두어로 시작해야한다. 일반적으로, 그 뒤에는 테스트할 함수의 이름이나, 메서드 이름, 타입의 이름이 나온다.t.Errorf()
함수는 test가 실패했다는 것을 알려주고 명시적인 메시지를 보여준다.이제 파일을 실행해보도록 하자.
go test ./cmd/web
ok
라는 결과가 나왔다면 문제없이 테스트가 완료되었다는 것이다.
좀 더 자세한 결과를 얻고 싶다면 verbose
옵션을 추가해보도록 하자.
go test -v ./cmd/web
=== RUN TestHumanDate
--- PASS: TestHumanDate (0.00s)
PASS
ok github.com/gyu-young-park/snippetbox/cmd/web 0.002s
이제 추가적인 test case들을 cover해보도록 하자. 다음을 체크해보도록 할 것이다.
humanDate()
에 zero time
이 주어진다면 이는 빈 문자열인 ""
을 반환해야한다.humanDate()
함수는 항상 UTC time zone을 사용해야한다.go에서 관용적으로 여러 개의 test case를 구동시키는 방법은 table-driven test
이다.
본질적으로, table-driven test들은 결과값으로 기대되는 input과 output이 담긴 test case table을 만들고 이들을 loop 돈다음 각 test case를 sub-test에서 구동하는 것이다. 여러가지 방법이 있지만 가장 흔히 사용되는 방법은 익명 구조체의 slice를 이용하는 것이다.
cmd/web/templates_test.go
package main
import (
"testing"
"time"
)
func TestHumanDate(t *testing.T) {
// Create a slice of anonymous structs containing the test case name,
// input to our humanDate() function (the tm field), and expected output
// (the want field).
tests := []struct {
name string
tm time.Time
want string
}{
{
name: "UTC",
tm: time.Date(2020, 12, 17, 10, 0, 0, 0, time.UTC),
want: "17 Dec 2020 at 10:00",
},
{
name: "Empty",
tm: time.Time{},
want: "",
},
{
name: "CET",
tm: time.Date(2020, 12, 17, 10, 0, 0, 0, time.FixedZone("CET", 1*60*60)),
want: "17 Dec 2020 at 09:00",
},
}
// Loop over the test cases.
for _, tt := range tests {
// Use the t.Run() function to run a sub-test for each test case. The
// first parameter to this is the name of the test (which is used to
// identify the sub-test in any log output) and the second parameter is
// and anonymous function containing the actual test for each case.
t.Run(tt.name, func(t *testing.T) {
hd := humanDate(tt.tm)
if hd != tt.want {
t.Errorf("want %q; got %q", tt.want, hd)
}
})
}
}
3번째 test case에 우리는 CET(central European Time)을 사용하였는데 이는 UTC보다 한 시간 정도 앞선다. 그래서 우리는 humanDate()
함수의 결과로 09:00
을 넣은 것이다.
우리는 각 test case의 결과를 sub-tests를 통해 확인할 수 있다.
go test -v ./cmd/web
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
=== RUN TestHumanDate/Empty
templates_test.go:35: want ""; got "01 Jan 0001 at 00:00"
=== RUN TestHumanDate/CET
templates_test.go:35: want "17 Dec 2020 at 09:00"; got "17 Dec 2020 at 10:00"
--- FAIL: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
--- FAIL: TestHumanDate/Empty (0.00s)
--- FAIL: TestHumanDate/CET (0.00s)
FAIL
FAIL github.com/gyu-young-park/snippetbox/cmd/web 0.002s
FAIL
예상하는 대로 우리의 첫번째 test-case는 성공하고 두 번째부터는 다 실패했다. t.Errorf()
함수를 사용하면 실패한 test에 표시를 해주고, test에 실패할 지라도 남은 test를 멈추지않고 실행한다.
-failfast
flag를 사용하면 첫번째 실패가 발생하면 test를 멈추게 한다.
go test -v -failfast ./cmd/web
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
=== RUN TestHumanDate/Empty
templates_test.go:42: want ""; got "01 Jan 0001 at 00:00"
--- FAIL: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
--- FAIL: TestHumanDate/Empty (0.00s)
FAIL
FAIL github.com/gyu-young-park/snippetbox/cmd/web 0.002s
FAIL
이제 실패하는 경우도 보았으니 humanData
함수에 돌아가서 문제를 해결해보자.
cmd/web/templates.go
...
func humanDate(t time.Time) string {
// Return the empty string if time has the zero value.
if t.IsZero() {
return ""
}
// Convert the time to UTC before formatting it.
return t.UTC().Format("02 Jan 2006 at 15:04")
}
...
humanDate
함수에 time이 0인지를 확인하고 time을 UTC로 먼저 바꾼다음 Format을 하도록 하였다. 이제 제대로 실행될 것이다.
다시 test를 실행해보도록 하자.
go test -v ./cmd/web
=== 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 0.002s
제대로 성공하였다.
참고로 모든 테스트 코드를 실행하는 방법은 go test ./...
을 사용하면 된다.
go test -v ./...
=== 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/mysql [no test files]
만약 test code가 없다면 ?
로 나올 것이다.
http handler들을 테스트하기위한 몇 가지 기법들에 대해서 알아보자.
먼저, cmd/web/handlers.go
에 간단한 ping
handler를 추가해보도록 하자.
cmd/web/handlers.go
func ping(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
}
ping
handler는 단순히 200 OK
응답을 전송하는 함수이다. 우리가 확인해야할 것은 다음과 같다.
200
이 오는가?OK
라는 메시지가 전송되었는가?HTTP handler를 테스트하기 쉽게하기위해서 go에서는 net/http/httptest
package를 제공해준다.
httptest.ResponseRecorder
type은 http.ResponseWriter
의 구현으로 실제로 HTTP connection에 정보를 쓰는 것 대신에 response status code와 header, body를 저장한다.
이를 활용하여 ping
handler 함수를 테스트해보자.
handlers_test.go
파일을 test할 핸들러 옆에 두도록 하자.
touch cmd/web/handlers_test.go
cmd/web/handlers_test.go
package main
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestPing(t *testing.T) {
// Initialize a new httptest.ResponseRecorder.
rr := httptest.NewRecorder()
// Initialize a new dummy http.Request
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
// Call the ping handler function, passing in the
// httptest.ResponseRecorder and http.Request.
ping(rr, r)
// Call the Result() method on the http.ResponseRecorder to get the
// http.Response generated by the ping handler.
rs := rr.Result()
// We can then examine the http.Response to check that the status code
// written by the ping handler was 200.
if rs.StatusCode != http.StatusOK {
t.Errorf("want %d; got %d", http.StatusOK, rs.StatusCode)
}
// And we can check that the response body written by the ping handler
// equals "OK".
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
if string(body) != "OK" {
t.Errorf("want body to equal %q", "OK")
}
}
httptest.NewRecorder()
를 만들고, 가짜 요청인 http.NewRequest(http.MethodGet, "/", nil)
를 만들어 ping
handler에 넣어주면 httptest.NewRecorder()
의 인스턴스인 rr
에 응답이 기록된다.
rs.StatusCode
로 상태코드를 확인하고 rs.Body
를 통해서 응답으로 무엇이 왔는 지 성공적으로 확인할 수 있었다.
참고로 t.Fatal()
은 예기치못한 에러가 발생하였을 때 test 동작을 stop시켜준다. 즉, 모든 test 동작이 멈추게 된다.
handler를 테스트하였으니 middleware를 테스트하는 것도 별반 다르지 않다.
secureHeaders
middleware를 test하기위해서 TestSecureHeaders
test를 만들도록 하자. 우리가 검사해야할 것은 다음과 같다.
X-Frame-Options: deny
header로 설정해야한다.X-XSS-Protection: 1; mode=block
header로 두어야 한다.먼저 이를 위해서 cmd/web/middleware_test.go
파일을 만들도록 하자.
touch cmd/web/middleware_test.go
touch cmd/web/middleware_test.go
package main
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestSecureHeaders(t *testing.T) {
// Initialize a new httptest.ResponseRecorder and dummy http.Request.
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
// Create a mock HTTP handler that we can pass to our secureHeaders
// middleware, which writes a 200 status code and "OK" response body.
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
// Pass the mock HTTP handler to our secureHeaders middleware. Because
// secureHeaders *returns* a http.Handler we can call its ServeHTTP()
// method, passing in the http.ResponseRecorder and dummy http.Request to
// execute it.
secureHeaders(next).ServeHTTP(rr, r)
// Call the Result() method on the http.ResponseRecorder to get the results
// of the test.
rs := rr.Result()
// Check that the middleware has correctly set the X-Frame-Options header
// on the response.
frameOptions := rs.Header.Get("X-Frame-Options")
if frameOptions != "deny" {
t.Errorf("want %q; got %q", "deny", frameOptions)
}
// Check that the middleware has correctly set the X-XSS-Protection header
// on the response.
xssProtection := rs.Header.Get("X-XSS-Protection")
if xssProtection != "1; mode=block" {
t.Errorf("want %q; got %q", "1; mode=block", xssProtection)
}
// Check that the middleware has correctly called the next handler in line
// and the response status code and body are as expected.
if rs.StatusCode != http.StatusOK {
t.Errorf("want %d; got %d", http.StatusOK, rs.StatusCode)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
if string(body) != "OK" {
t.Errorf("want body to equal %q", "OK")
}
}
rs := rr.Result()
을 통해 http.ResponseWriter
의 결과를 얻어올 수 있다. 이를 통해 header
를 가져올 수 있다.
test를 실행해보도록 하자.
go test -v ./cmd/web/
=== RUN TestPing
--- PASS: TestPing (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 0.002s
status code
와 body
를 확인하여 잘 작동하는 것을 확인할 수 있다.
만약 특정 test를 동작시키고 싶다면 -run
flag를 사용하면 된다. 이는 정규표현식을 허용하는데 정규표현식이 매칭된 이름만이 test가 실행된다.
TestPing
만을 수행하고 싶다면 다음과 같이하면 된다.
go test -v -run="^TestPing$" ./cmd/web/
=== RUN TestPing
--- PASS: TestPing (0.00s)
PASS
ok github.com/gyu-young-park/snippetbox/cmd/web 0.002s
만약 특정 sub test만 실행시키고 싶다면 다음과 같이하면 된다.
go test -v -run="^TestHumanDate$/^UTC|CET$" ./cmd/web
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
--- PASS: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
PASS
ok github.com/gyu-young-park/snippetbox/cmd/web 0.002s
위와 같이 sub test는 /
으로 parent와 sub를 나눌 수 있다.
테스트 코드가 추가지면서 수백, 수천개의 테스트가 진행되면 시간이 굉장히 오래걸릴 것이다. 이러한 시나리오에서 병렬적으로 test를 구동함으로서 runtime을 절약할 수 있다.
t.Parallel()
함수를 사용하면 다른 test들과 concurrently하게 동작시킬 수 있다.
func TestPing(t *testing.T) {
t.Parallel()
...
}
중요하게 짚을 점은 다음과 같다.
t.Parallel()
을 사용하는 test는 병렬적으로 구동될 것이다.GOMAXPROCS
수에 비례한다. 이 수를 변경하고 싶다면 -parallel
flag를 사용하면 된다. go test -parallel 4 ./...
go test
커맨드에 -race
flag를 추가하면 test에 대한 race detector를 구동한다.
만약 테스팅하는 code가 동시성을 활용하거나 또는 test를 병렬적으로 실행하는 경우, -race
flag는 race condition을 찾아주는데 큰 도움을 준다.
go test -race ./cmd/web/
race detector에서 중요한 것은 런타임에 실행된다는 것이다. 이는 정적 분석 툴이 아니라는 것이다. 따라서 이는 우리의 code가 race condition으로부터 자유롭다는 것을 보장하지 않는다.
또한, -race
키워드를 추가하면 race detector
가 실행되어 전반적인 test의 실행 시간을 증가시킨다.
위는 handler
를 unit test를 어떻게 사용하는 지를 보여주었다. 그러나, 대부분 HTTP handler는 격리되어 실행되지 않는다. 따라서, 우리의 handler가 실행되는 하나의 과정인 middleware, routing, handler를 테스트하는 end-to-end test를 하도록 하자. end-to-end testing은 unit test보다 application이 더 정확하게 동작한다는 자신감을 준다.
우리의 TestPing
함수를 end-to-end로 실행하도록 하자. GET /ping
요청을 보장하는 test를 만들어 ping
핸들러에서 결과로 200 OK
code와 OK
응답 body를 받도록 하자.
본질적으로 우리는 우리의 application 다음과 같은 route를 가진다.
Method | Pattern | Handler | Action |
---|---|---|---|
... | ... | ... | ... |
GET | /ping | ping | Return a 200 OK response |
httptest.Server
를 사용하보도록 하자. 우리의 application을 end-to-end testing을 하게해주는 핵심 함수는 httptest.NewTLSServer()
함수이다. 이는 HTTP request를 만드는 httptest.Server
인스턴스를 spin up해준다.
하나하나 설명하기가 어려우므로 코드를 보면서 알아보도록 하자.
handlers_test.go
파일로 돌아가서 TestPing
test를 다음과 같이 변경하도록 하자.
cmd/web/handlers_test.go
package main
import (
"io"
"log"
"net/http"
"net/http/httptest"
"testing"
)
func TestPing(t *testing.T) {
// Create a new instance of our application struct. For now, this just
// contains a couple of mock loggers (which discard anything written to
// them).
app := &application{
errorLog: log.New(io.Discard, "", 0),
infoLog: log.New(io.Discard, "", 0),
}
// We then use the httptest.NewTLSServer() function to create a new test
// server, passing in the value returned by our app.routes() method as the
// handler for the server. This starts up a HTTPS server which listens on a
// randomly-chosen port of your local machine for the duration of the test.
// Notice that we defer a call to ts.Close() to shutdown the server when
// the test finishes.
ts := httptest.NewTLSServer(app.routes())
defer ts.Close()
// The network address that the test server is listening on is contained
// in the ts.URL field. We can use this along with the ts.Client().Get()
// method to make a GET /ping request against the test server. This
// returns a http.Response struct containing the response.
rs, err := ts.Client().Get(ts.URL + "/ping")
if err != nil {
t.Fatal(err)
}
// We can then check the value of the response status code and body using
// the same code as before.
if rs.StatusCode != http.StatusOK {
t.Errorf("want %d; got %d", http.StatusOK, rs.StatusCode)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
if string(body) != "OK" {
t.Errorf("want body to equal %q", "OK")
}
}
httptest.NewTLSServer()
함수는 http.Handler
를 파라미터로 받는다. 그리고 test server에서 HTTPS요청을 받으면 test 이 handler들을 실행한다. 우리의 경우, app.routes()
메서드로 handler를 반환한다. 이를 통해 우리는 실제 application 서버의 routes, middleware, handler들과 같은 로직을 test할 수 있다.
httptest.NewServer()
함수를 사용하여 HTTP( not HTTPS ) server를 만들어 테스팅을 할 수 있다.
errorLog
와 infoLog
필드만 설정하고 다른 것은 설정하지 않았다. 이는 logRequest
와 recoverPanic
middleware에서 사용하고 있기 때문이다. 만약, errorLog
, infoLog
을 설정하지 않았다면 panic이 발생할 것이다.
go test ./cmd/web/
--- FAIL: TestPing (0.00s)
handlers_test.go:38: want 200; got 404
handlers_test.go:48: want body to equal "OK"
FAIL
FAIL github.com/gyu-young-park/snippetbox/cmd/web 0.004s
FAIL
우리는 GET /ping
요청을 현재 404 응답을 받고있다. 이는 우리의 ping
handler를 route에 등록하지 않았기 때문이다.
cmd/web/routes.go
에 가서 ping
handler를 routes에 설정하도록 하자.
cmd/web/routes.go
package main
import (
"net/http"
"github.com/bmizerany/pat"
"github.com/justinas/alice"
)
func (app *application) routes() http.Handler {
standardMiddleware := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
dynamicMiddleware := alice.New(app.session.Enable, noSurf, app.authenticate)
authenticatedMiddleware := dynamicMiddleware.Append(app.requireAuthentication)
mux := pat.New()
mux.Get("/", dynamicMiddleware.ThenFunc(app.home))
mux.Get("/snippet/create", authenticatedMiddleware.ThenFunc(app.createSnippetForm))
mux.Post("/snippet/create", authenticatedMiddleware.ThenFunc(app.createSnippet))
mux.Get("/snippet/:id", dynamicMiddleware.ThenFunc(app.showSnippet))
mux.Get("/user/signup", dynamicMiddleware.ThenFunc(app.signupUserForm))
mux.Post("/user/signup", dynamicMiddleware.ThenFunc(app.signupUser))
mux.Get("/user/login", dynamicMiddleware.ThenFunc(app.loginUserForm))
mux.Post("/user/login", dynamicMiddleware.ThenFunc(app.loginUser))
mux.Post("/user/logout", authenticatedMiddleware.ThenFunc(app.logoutUser))
// Add a new GET /ping route.
mux.Get("/ping", http.HandlerFunc(ping))
fileServer := http.FileServer(http.Dir("./ui/static/"))
mux.Get("/static/", http.StripPrefix("/static", fileServer))
return standardMiddleware.Then(mux)
}
이제 test를 다시 실행해보자.
go test ./cmd/web/
ok github.com/gyu-young-park/snippetbox/cmd/web 0.004s
제대로 실행되었다.
우리의 TestPing
함수는 잘 동작한다. 그러나 일부 코드를 helper function들로 분할 시킬 수 있는 부분이 있다. helper function을 나누면 end-to-end test를 할때 다시 사용할 수 있게 된다.
testutils_test.go
파일을 만들어서 test에 필요한 helper를 추가하도록 하자.
touch cmd/web/testutils_test.go
그리고 다음의 코드를 추가하도록 하자.
cmd/web/testutils_test.go
package main
import (
"io"
"log"
"net/http"
"net/http/httptest"
"testing"
)
// Create a newTestApplication helper which returns an instance of our
// application struct containing mocked dependencies.
func newTestApplication(t *testing.T) *application {
return &application{
errorLog: log.New(io.Discard, "", 0),
infoLog: log.New(io.Discard, "", 0),
}
}
// Define a custom testServer type which anonymously embeds a httptest.Server
// instance.
type testServer struct {
*httptest.Server
}
// Create a newTestServer helper which initalizes and returns a new instance
// of our custom testServer type.
func newTestServer(t *testing.T, h http.Handler) *testServer {
ts := httptest.NewTLSServer(h)
return &testServer{ts}
}
// Implement a get method on our custom testServer type. This makes a GET
// request to a given url path on the test server, and returns the response
// status code, headers and body.
func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, []byte) {
rs, err := ts.Client().Get(ts.URL + urlPath)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
return rs.StatusCode, rs.Header, body
}
우리가 cmd/web/handlers_test.go
파일에서 썼던 httptest
코드를 spin up(나누기)하였다. testServer
타입을 만들고 GET
요청을 보내는 get
메서드를 추가하였다.
TestPing
handler로 돌아가서 우리가 추가한 새로운 helper를 추가하도록 하자.
cmd/web/handlers_test.go
package main
import (
"net/http"
"testing"
)
func TestPing(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
code, _, body := ts.get(t, "/ping")
if code != http.StatusOK {
t.Errorf("want %d; got %d", http.StatusOK, code)
}
if string(body) != "OK" {
t.Errorf("want body to equal %q", "OK")
}
}
이전의 code를 분할하여 helper에 있는 코드로 바꾼 것이라 동작에는 변경이 없다.
go test ./cmd/web/
ok github.com/gyu-young-park/snippetbox/cmd/web 0.005s
helper함수로 test server를 나누었고 routing, middleware, handler가 실행되는 end-to-end test를 쉽게 사용할 수 있게 되었다.
위에서 test server에 request를 만드는 ts.Client().Get()
메서드를 사용하였다. ts.Client()
는 configurable한 http.Client
를 반환하는데, 이를 통해서 약간 다르게 요청들을 변경할 수 있다.
default http.Client
에 일련의 변화들을 적용할 수 있다.
1. 우리는 client가 자동적으로 redirect들을 따르지 않도록 하고싶다. 대신 특정 요청에 대한 응답을 테스트할 수 있도록 서버에서 보낸 첫 번째 HTTPS 응답을 반환한다.
2. client가 HTTPS 응답에 cookie들을 자동적으로 저장하고 싶다. 이는 CSRF 방지 조치를 테스트하기 위해 여러 요청에 걸처 쿠키를 지원해야 할 때 유용할 것이다.
testutils_test.go
파일로가서 newTestServer
함수를 변경하도록 하자.
cmd/web/testutils_test.go
...
func newTestServer(t *testing.T, h http.Handler) *testServer {
ts := httptest.NewTLSServer(h)
// Initialize a new cookie jar.
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
// Add the cookie jar to the client, so that response cookies are stored
// and then sent with subsequent requests.
ts.Client().Jar = jar
// Disable redirect-following for the client. Essentially this function
// is called after a 3xx response is received by the client, and returning
// the http.ErrUseLastResponse error forces it to immediately return the
// received response.
ts.Client().CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
return &testServer{Server: ts}
}
...
cookiejar
은 net/http/cookiejar
패키지이고, 이를 httptest
의 Server
에 jar
인스턴스를 넣어주면 일련의 요청에 cookie를 전달하고 응답에 저장하는 것을 설정할 수 있다.
CheckRedirect
는 3xx 리다이렉트 응답을 client로부터 받으면 http.ErrUseLastResponse
error를 반환하여 즉각적으로 받은 응답을 전달한다.
이제 좀 더 특정한 handler들을 테스트해보도록 하자. showSnippet
핸들러와 GET /snippet/:id
route를 테스트해보도록 하자.
이전에 우리응 dependencies에 대해서 생각해볼 필요가 있다.
우리는 application
구조체를 통해서 의존성을 주입하였다.
type application struct {
errorLog *log.Logger
infoLog *log.Logger
session *sessions.Session
snippets *mysql.SnippetModel
templateCache map[string]*template.Template
users *mysql.UserModel
}
여기서의 의존성들은 실제 production level에서 쓰이는 것이 대신에 mock을 만들어 쓰는 것이 훨씬 더 좋다.
가령, 이전에 우리는 errorLog
와 infoLog
의존성을 io.Discard
에 쓰도록 하였다. 이는 메시지가 os.Stdout
과 os.Stderr
와 같은 stream에 전송하지 않도록 하는 것이다.
func newTestApplication(t *testing.T) *application {
return *application{
errorLog: log.New(io.Discard, "", 0),
infoLog: log.New(io.Discard, "", 0),
}
}
io.Discard
를 사용한 것은 go test -v
로 테스트를 할 때 불필요한 로그 메시지가 output으로 남겨지지 않기를 원하기 때문이다.
사실 mock이든 fake든, stub든 중요하지 않다. 뭐라고 부르던 간에 testing을 목적으로 production level에 쓰이는 interface를 대신 사용하는 객체를 만든다는 것이 중요하다.
mysql.SnippetModel
과 mysql.UserModel
은 mock이 필요한 의존성들이다. database model들에 대한 mock을 만들면 굳이 MySQL database에 대한 test 인스턴스 없이도 동작 테스트가 가능하다.
pkg/models/mock
패키지를 새롭게 만들어 두 가지 새로운 snippets.go
와 users.go
파일을 만들어 database model mock들을 쓰도록 하자.
mkdir pkg/models/mock
touch pkg/models/mock/snippets.go
touch pkg/models/mock/users.go
이제 mysql.SnippetModel
에 대한 mock을 만들어보자. mock은 mysql.SnippetModel
에 대해 같은 메서드를 구현하고 간단한 구조체를 가진다. 그러나 이 메서드들은 고정된 dummy data를 대신 반환할 뿐이다.
pkg/models/mock/snippets.go
package mock
import (
"time"
"github.com/gyu-young-park/snippetbox/pkg/models"
)
var mockSnippet = &models.Snippet{
ID: 1,
Title: "An old silent pond",
Content: "An old silent pond...",
Created: time.Now(),
Expires: time.Now(),
}
type SnippetModel struct{}
func (m *SnippetModel) Insert(title, content, expires string) (int, error) {
return 2, nil
}
func (m *SnippetModel) Get(id int) (*models.Snippet, error) {
switch id {
case 1:
return mockSnippet, nil
default:
return nil, models.ErrNoRecord
}
}
func (m *SnippetModel) Latest() ([]*models.Snippet, error) {
return []*models.Snippet{mockSnippet}, nil
}
SnippetModel
을 mock
패키지에 따로 타입을 만들고 내부에 기능적으로는 mysql.SnippetModel
과 비슷한 기능을 하도록 만들어두었다. 이제 이 mock으로 테스트를 진행하면 된다.
이제 위의 mock과 같이 mysql.UserModel
과 같은 기능을 하는 mock을 하나 더 만들어보자.
pkg/models/users.go
package mock
import (
"time"
"github.com/gyu-young-park/snippetbox/pkg/models"
)
var mockUser = &models.User{
ID: 1,
Name: "Alice",
Email: "alice@example.com",
Created: time.Now(),
Active: true,
}
type UserModel struct{}
func (m *UserModel) Insert(name, email, password string) error {
switch email {
case "dupe@example.com":
return models.ErrDuplicateEmail
default:
return nil
}
}
func (m *UserModel) Authenticate(email, password string) (int, error) {
switch email {
case "alice@example.com":
return 1, nil
default:
return 0, models.ErrInvalidCredentials
}
}
func (m *UserModel) Get(id int) (*models.User, error) {
switch id {
case 1:
return mockUser, nil
default:
return nil, models.ErrNoRecord
}
}
이제 mock들을 다 만들었으니 testutils_test.go
파일로 돌아가서 newTestApplication
함수를 수정하도록 하자. 그리고 application
구조체에 우리가 만든 mock을 의존성으로 넣어주도록 하자.
cmd/web/testutils_test.go
...
func newTestApplication(t *testing.T) *application {
// Create an instance of the template cache.
templateCache, err := newTemplateCache("./../../ui/html/")
if err != nil {
t.Fatal(err)
}
// Create a session manager instance, with the same settings as production.
session := sessions.New([]byte("3dSm5MnygFHh7XidAtbskXrjbwfoJcbJ"))
session.Lifetime = 12 * time.Hour
session.Secure = true
// Initialize the dependencies, using the mocks for the loggers and
// database models.
return &application{
errorLog: log.New(io.Discard, "", 0),
infoLog: log.New(io.Discard, "", 0),
session: session,
snippets: &mock.SnippetModel{},
templateCache: templateCache,
users: &mock.UserModel{},
}
}
...
다음과 같이 기존의 application
구조체와 비슷하게 초기화시켜주었다. 그러나 문제느 snippets
와 users
의 타입이 mysql.
으로 한정되어있기 때문에, 우리가 만든 mock에 할당되지 않는다.
이를 해결하기 위해서는 application
구조체에 interface를 사용하여 우리의 mock도 할당될 수 있도록 바꾸는 것이 좋다.
pkg/models/models.go
type ISnippetMode interface {
Insert(string, string, string) (int, error)
Get(int) (*Snippet, error)
Latest() ([]*Snippet, error)
}
type IUser interface {
Insert(string, string, string) error
Authenticate(string, string) (int, error)
Get(int) (*User, error)
}
다음의 인터페이스 선언을 추가하도록 하자. 이제 해당 인터페이스를 사용하여 application
에 다형성을 지원할 수 있도록 하자.
cmd/web/main.go
...
type application struct {
errorLog *log.Logger
infoLog *log.Logger
session *sessions.Session
snippets models.ISnippetMode
templateCache map[string]*template.Template
users models.IUser
}
...
이제 mock
패키지에서 만든 우리의 mock들도 호환이 될 것이다. 실행시켜보도록 하자.
go test ./cmd/web
ok github.com/gyu-young-park/snippetbox/cmd/web 0.005s
성공한 것을 확인할 수 있다.
이제 mock으로 우리의 configuration들이 설정되었으니, showSnippet
handler를 테스트해보도록 하자. 요청은 GET /snippet/:id
route로 들어온다.
showSnippet
핸들러는 mock.SnippetModel.Get()
메서드를 호출한다. 해당 mock은 만약 snippet id가 1이 아니면 무조건 models.ErrNoRecord
를 반환한다. 만약 snippet id가 1이라면 다음의 데이터를 반환한다.
var mockSnippet = &models.Snippet{
ID: 1,
Title: "An old silent pond",
Content: "An old silent pond...",
Created: time.Now(),
Expires: time.Now(),
}
구체적으로 우리가 test해야할 것은 다음과 같다.
GET /snippet/1
에 대한 요청이 성공하면 200 OK
응답과 snippet을 html 응답 body를 통해 받을 수 있다.GET /snippet/*
에 대한 다른 요청은 우리는 404 Not Found
응답을 보낸다.cmd/web/handlers_test.go
파일을 열고 TestShowSnippet
테스트를 다음과 같이 쓰면된다.
cmd/web/handlers_test.go
func TestShowSnippet(t *testing.T) {
// Create a new instance of our application struct which uses the mocked
// dependencies.
app := newTestApplication(t)
// Establish a new test server for running end-to-end tests.
ts := newTestServer(t, app.routes())
// Set up some table-driven tests to check the responses sent by our
// application for different URLs.
tests := []struct {
name string
urlPath string
wantCode int
wantBody []byte
}{
{"Valid ID", "/snippet/1", http.StatusOK, []byte("An old silent pond...")},
{"Non-existent ID", "/snippet/2", http.StatusNotFound, nil},
{"Negative ID", "/snippet/-1", http.StatusNotFound, nil},
{"Decimal ID", "/snippet/1.23", http.StatusNotFound, nil},
{"String ID", "/snippet/foo", http.StatusNotFound, nil},
{"Empty ID", "/snippet/", http.StatusNotFound, nil},
{"Trailing slash", "/snippet/1/", http.StatusNotFound, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, _, body := ts.get(t, tt.urlPath)
if code != tt.wantCode {
t.Errorf("want %d; got %d", tt.wantCode, code)
}
if !bytes.Contains(body, tt.wantBody) {
t.Errorf("want body to contain %q", tt.wantBody)
}
})
}
}
테스트를 구동시키면 모두 pass될 것이다.
go test -v ./cmd/web/
=== 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.00s)
--- 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 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 0.009s
subtest의 이름을 잘보면 다른 이름으로 canonicalized(변환)되어있음을 확인할 수 있다. 즉, space가 _
로 변경된 것이다.
이제 POST /user/signup
route에 대한 end-to-end 테스트를 진행해보도록 하자.
route를 테스팅하는 것은 application에서 하고 있는 anti-CSRF check에 의해 다소 복잡하다. 요청에 valid한 CSRF token과 cookie가 없으면, POST /user/signup
에 대해 우리가 만든 모든 요청들은 400 Bad Request
응답을 받게 될 것이다. 위 문제를 해결하기 위해 우리는 다음의 workflow로 테스트를 진행할 것이다.
GET /user/signup
요청을 만든다. 이는 응답 헤더에 CSRF cookie를 포함하고, HTML 응답 body에 CSRF token을 추가해준다.http.Client
을 사용하고, CSRF token과 우리가 테스트하고 싶은 POST
data를 포함한 POST /user/signup
요청을 만든다.이제 새로운 helper 함수를 우리의 cmd/web/testutils_test.go
파일에 추가함으로서 CSRF token을 HTML 응답 body로 부터 가져오도록 하자.
cmd/web/testutils_test.go
// Define a regular expression which captures the CSRF token value from the
// HTML for our user signup page.
var csrfTokenRX = regexp.MustCompile(`<input type='hidden' name='csrf_token' value='(.+)'>`)
func extractCSRFToken(t *testing.T, body []byte) string {
// Use the FindSubmatch method to extract the token from the HTML body.
// Note that this returns an array with the entire matched pattern in the
// first position, and the values of any captured data in the subsequent
// positions.
matches := csrfTokenRX.FindSubmatch(body)
if len(matches) < 2 {
t.Fatal("no csrf token found in body")
}
return html.UnescapeString(string(matches[1]))
}
html.UnescapeString()
함수를 사용하는 것에 대해서 의문을 느낄텐데, go의 html/template
package는 자동적으로 모든 동적 rendered data를 escepes시킨다. 따라서 CSRF token도 escape되는데 CSRF token이 base64로 인코딩되어있으므로 잠재적으로 +
을 포함할 수 있으며 $#43
으로 escape될 수 있다. 그래서 token을 HTML로부터 추출한 후에는 html.UnescapeString
을 호출하여 original token value를 얻어오는 것이 좋다.
일단, 이 부부이 완성되면 cmd/web/handlers_test.go
파일로 가서 TestSignupUser
함수를 새로 만든다.
이를 시작하기위해서 우리는 GET /user/signup
요청을 만들고 HTML body로 부터 CSRF Token을 추출하며 print
하도록 하자. 다음과 같다.
cmd/web/handlers_test.go
func TestSignupUser(t *testing.T) {
// Create the application struct containing our mocked dependencies and set
// up the test server for running and end-to-end test.
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
// Make a GET /user/signup request and then extract the CSRF token from the
// response body.
_, _, body := ts.get(t, "/user/signup")
csrfToken := extractCSRFToken(t, body)
// Log the CSRF token value in our test output. To see the output from the
// t.Log() command you need to run `go test` with the -v (verbose) flag
// enabled.
t.Log(csrfToken)
}
위의 test를 구동하면, /user/signup
에서 rendering된 body
를 가져오고 여기서 CSRF token을 추출해준다.
위 테스트를 구동하면 다음과 같은 응답을 받을 수 있다.
go test -v -run="TestSignupUser" ./cmd/web/
=== RUN TestSignupUser
handlers_test.go:22: o4asZu/bedCaQNDdjCugOOMXfQzZZuqkGId2mnF5vWVaXgNfJwkN8WX5LcpUdhF7BGsttCnnJZaCVBUTidSqrA==
--- PASS: TestSignupUser (0.00s)
PASS
ok github.com/gyu-young-park/snippetbox/cmd/web 0.005s
CSRF token을 잘 가져왔다는 것을 볼 수 있다. 이제 CSRF token을 사용하여 POST 요청들에대해서 test를 할 수 있게된다.
cmd/web/testutils_test.go
파일에 가서 새로운 postForm
메서드를 testServer
객체에 만들도록 하자. postForm
메서드는 POST
요청을 보내고 주어진 요청 body를 우리의 test server에 전달한다.
cmd/web/testutils_test.go
// Create a postForm method for sending POST requests to the test server.
// The final parameter to this method is a url.Values object which can contain
// any data that you want to send in the request body.
func (ts *testServer) postForm(t *testing.T, urlPath string, form url.Values) (int, http.Header, []byte) {
rs, err := ts.Client().PostForm(ts.URL+urlPath, form)
if err != nil {
t.Fatal(err)
}
// Read the response body.
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
// Return the response status, headers and body.
return rs.StatusCode, rs.Header, body
}
이제 testServer
로 form
데이터로 post
요청을 보낼 수 있게 되었다. table-driven sub test 방법으로 우리의 POST /user/signup
route를 테스트해보도록 하자. 구체적으로 우리가 테스트할 것은 다음과 같다.
This field cannot be blank
라는 메시지를 다시 보여준다.This field is invalid
라는 메시지로 응답한다.This field is too short (minimum is 10 characters)
라는 응답을 전달한다.Address is already in use
라는 응답을 전달한다.303 See Other
응답을 전달한다.400 Bad Request
응답을 전달한다.TestSignupUser
함수로 가고 수정하도록 하자.
cmd/web/handlers_test.go
...
func TestSignupUser(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
_, _, body := ts.get(t, "/user/signup")
csrfToken := extractCSRFToken(t, body)
tests := []struct {
name string
userName string
userEmail string
userPassword string
csrfToken string
wantCode int
wantBody []byte
}{
{"Valid submission", "Bob", "bob@example.com", "validPa$$word", csrfToken,
http.StatusSeeOther, nil},
{"Empty name", "", "bob@example.com", "validPa$$word", csrfToken, http.StatusOK,
[]byte("This field cannot be blank")},
{"Empty email", "Bob", "", "validPa$$word", csrfToken, http.StatusOK,
[]byte("This field cannot be blank")},
{"Empty password", "Bob", "bob@example.com", "", csrfToken, http.StatusOK,
[]byte("This field cannot be blank")},
{"Invalid email (incomplete domain)", "Bob", "bob@example.", "validPa$$word",
csrfToken, http.StatusOK, []byte("This field is invalid")},
{"Invalid email (missing @)", "Bob", "bobexample.com", "validPa$$word", csrfToken,
http.StatusOK, []byte("This field is invalid")},
{"Invalid email (missing local part)", "Bob", "@example.com", "validPa$$word",
csrfToken, http.StatusOK, []byte("This field is invalid")},
{"Short password", "Bob", "bob@example.com", "pa$$word", csrfToken, http.StatusOK,
[]byte("This field is too short (minimum is 10 characters")},
{"Duplicate email", "Bob", "dupe@example.com", "validPa$$word", csrfToken, http.StatusOK,
[]byte("Address is already in use")},
{"Invalid CSRF Token", "", "", "", "wrongToken", http.StatusBadRequest, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
form := url.Values{}
form.Add("name", tt.userName)
form.Add("email", tt.userEmail)
form.Add("password", tt.userPassword)
form.Add("csrf_token", tt.csrfToken)
code, _, body := ts.postForm(t, "/user/signup", form)
if code != tt.wantCode {
t.Errorf("want %d; got %d", tt.wantCode, code)
}
if !bytes.Contains(body, tt.wantBody) {
t.Errorf("want body %s to contain %q", body, tt.wantBody)
}
})
}
}
...
위의 test를 실행시켜보도록 하자.
go test -v -run="TestSignupUser" ./cmd/web/
=== 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)
PASS
ok github.com/gyu-young-park/snippetbox/cmd/web (cached)
결과는 다음과 같다.