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

0

lets-go

목록 보기
14/15

Testing

go test code를 작성하는 것은 하나의 방법만 있는 것이 아니라, 여러가지 방법들이 있다. 그러나 여기에는 convention들이 있고, 패턴이 있으며 좋은 관례가 존재한다.

이번에는 우리의 application에 좋은 test code를 추가하여, 재사용성이 높고 널리 사용되는 test code를 작성해보도록 하자.

1. Unit Testing and Sub-Tests

이번 챕터에서는 우리의 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 코드 틀이다. 짚고 넘어가야 할 점들을 보면 다음과 같다.

  1. 이 test는 go의 가장 기본적인 test code이다. 이는 humanDate라는 함수를 호출하고 우리가 예상한 결과와 매칭하는 것이다.
  2. unit tests은 normal go function인 func(*testing.T) function signature를 가진다.
  3. unit test의 함수들은 항상 Test라는 접두어로 시작해야한다. 일반적으로, 그 뒤에는 테스트할 함수의 이름이나, 메서드 이름, 타입의 이름이 나온다.
  4. 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해보도록 하자. 다음을 체크해보도록 할 것이다.

  1. 만약 humanDate()zero time이 주어진다면 이는 빈 문자열인 ""을 반환해야한다.
  2. 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가 없다면 ?로 나올 것이다.

2. Testing HTTP Handlers

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 응답을 전송하는 함수이다. 우리가 확인해야할 것은 다음과 같다.

  1. 상태 코드로 200이 오는가?
  2. 응답으로 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 동작이 멈추게 된다.

3. Testing Middleware

handler를 테스트하였으니 middleware를 테스트하는 것도 별반 다르지 않다.

secureHeaders middleware를 test하기위해서 TestSecureHeaders test를 만들도록 하자. 우리가 검사해야할 것은 다음과 같다.

  1. middleware은 X-Frame-Options: deny header로 설정해야한다.
  2. middleware은 X-XSS-Protection: 1; mode=block header로 두어야 한다.
  3. middleware은 다음 handler chain을 호출해야한다.

먼저 이를 위해서 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 codebody를 확인하여 잘 작동하는 것을 확인할 수 있다.

만약 특정 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()
	...
}

중요하게 짚을 점은 다음과 같다.

  1. t.Parallel()을 사용하는 test는 병렬적으로 구동될 것이다.
  2. 기본적으로 병렬적으로 실행되는 test의 최대 개수는 GOMAXPROCS수에 비례한다. 이 수를 변경하고 싶다면 -parallel flag를 사용하면 된다.
go test -parallel 4 ./...
  1. 모든 테스트들이 병렬적으로 동작하는게 적절하지 않을 수 있다. 가령, 만약 특정 상태에서 database table을 요구하는 integration test를 진행한다면, 이러한 테스트는 특정 상태(가령, database table)을 조작하는 다른 테스트와 병렬적으로 동작하면 안된다. 즉, serial하게 구동해야 하는 test를 병렬적으로 동작하게되면 가정한 상태가 변경되므로 정확한 test가 진행되지 않을 것이다.

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의 실행 시간을 증가시킨다.

4. End-To-End Testing

위는 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를 가진다.

MethodPatternHandlerAction
............
GET/pingpingReturn 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를 만들어 테스팅을 할 수 있다.

errorLoginfoLog 필드만 설정하고 다른 것은 설정하지 않았다. 이는 logRequestrecoverPanic 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

제대로 실행되었다.

5. Using Test Helpers

우리의 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를 쉽게 사용할 수 있게 되었다.

6. Cookies and Redirections

위에서 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}
}
...

cookiejarnet/http/cookiejar패키지이고, 이를 httptestServerjar 인스턴스를 넣어주면 일련의 요청에 cookie를 전달하고 응답에 저장하는 것을 설정할 수 있다.

CheckRedirect는 3xx 리다이렉트 응답을 client로부터 받으면 http.ErrUseLastResponse error를 반환하여 즉각적으로 받은 응답을 전달한다.

7. Mocking Dependencies

이제 좀 더 특정한 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을 만들어 쓰는 것이 훨씬 더 좋다.

가령, 이전에 우리는 errorLoginfoLog 의존성을 io.Discard에 쓰도록 하였다. 이는 메시지가 os.Stdoutos.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.SnippetModelmysql.UserModel은 mock이 필요한 의존성들이다. database model들에 대한 mock을 만들면 굳이 MySQL database에 대한 test 인스턴스 없이도 동작 테스트가 가능하다.

pkg/models/mock 패키지를 새롭게 만들어 두 가지 새로운 snippets.gousers.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
}

SnippetModelmock 패키지에 따로 타입을 만들고 내부에 기능적으로는 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 구조체와 비슷하게 초기화시켜주었다. 그러나 문제느 snippetsusers의 타입이 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해야할 것은 다음과 같다.

  1. GET /snippet/1에 대한 요청이 성공하면 200 OK 응답과 snippet을 html 응답 body를 통해 받을 수 있다.
  2. 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가 _로 변경된 것이다.

8. Testing HTML Forms

이제 POST /user/signup route에 대한 end-to-end 테스트를 진행해보도록 하자.

route를 테스팅하는 것은 application에서 하고 있는 anti-CSRF check에 의해 다소 복잡하다. 요청에 valid한 CSRF token과 cookie가 없으면, POST /user/signup에 대해 우리가 만든 모든 요청들은 400 Bad Request 응답을 받게 될 것이다. 위 문제를 해결하기 위해 우리는 다음의 workflow로 테스트를 진행할 것이다.

  1. GET /user/signup 요청을 만든다. 이는 응답 헤더에 CSRF cookie를 포함하고, HTML 응답 body에 CSRF token을 추가해준다.
  2. HTML 응답 body에서부터 CSRF token을 가져온다.
  3. step1에서 사용한 같은 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를 할 수 있게된다.

9. Testing POST requests

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
}

이제 testServerform 데이터로 post요청을 보낼 수 있게 되었다. table-driven sub test 방법으로 우리의 POST /user/signup route를 테스트해보도록 하자. 구체적으로 우리가 테스트할 것은 다음과 같다.

  1. 비어있는 form 요청은 This field cannot be blank라는 메시지를 다시 보여준다.
  2. 잘못된 email field 요청은 This field is invalid라는 메시지로 응답한다.
  3. password field가 10개의 문자보다 적다면 This field is too short (minimum is 10 characters)라는 응답을 전달한다.
  4. email로 회원가입하려는 요청에서 이미 email이 사용 중이라면 Address is already in use라는 응답을 전달한다.
  5. signup이 성공하면 303 See Other 응답을 전달한다.
  6. valid한 CSRF token없이 submission이 전달되면 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)

결과는 다음과 같다.

0개의 댓글