Go 앱 테스트: 올바른 방법으로 시작하기

게시 됨: 2022-03-11

새로운 것을 배울 때는 새로운 마음가짐을 갖는 것이 중요합니다.

Go를 처음 접하고 JavaScript 또는 Ruby와 같은 언어를 사용하는 경우 조롱, 주장 및 기타 테스트 마법사를 수행하는 데 도움이 되는 기존 프레임워크를 사용하는 데 익숙할 것입니다.

이제 외부 의존성이나 프레임워크에 의존한다는 생각을 근절하십시오! 테스트는 내가 사용할 수 있는 리소스가 훨씬 적었던 몇 년 전 이 놀라운 프로그래밍 언어를 배울 때 내가 처음으로 마주한 장애물이었습니다.

이제 Go에서의 테스트 성공이란 Go의 모든 것과 마찬가지로 종속성을 가볍게 보고, 외부 라이브러리에 최소한으로 의존하고, 재사용 가능한 좋은 코드를 작성하는 것을 의미한다는 것을 압니다. 제3자 테스트 라이브러리에 도전한 Blake Mizerany의 경험에 대한 이 프레젠테이션은 사고 방식을 조정하는 좋은 출발점입니다. 외부 라이브러리 및 프레임워크를 사용하는 것과 "이동 방법"을 사용하는 것에 대한 몇 가지 좋은 주장을 볼 수 있습니다.

바둑을 배우고 싶습니까? Golang 입문 튜토리얼을 확인하세요.

자신의 테스트 프레임워크와 모의 개념을 구축하는 것이 직관적이지 않은 것처럼 보일 수 있지만 생각보다 쉽고 언어 학습을 위한 좋은 출발점이 됩니다. 또한 내가 학습할 때와 달리 이 기사에서는 일반적인 테스트 시나리오를 안내하고 코드를 효율적으로 테스트하고 깨끗하게 유지하기 위한 모범 사례로 간주하는 기술을 소개합니다.

"Go Way"를 수행하고 외부 프레임워크에 대한 종속성을 근절합니다.
트위터

Go에서 테이블 테스트

'단위 테스트'로 유명한 기본 테스트 단위는 입력을 받고 출력을 반환하는 가장 단순한 형태의 프로그램 구성 요소일 수 있습니다. 테스트를 작성하려는 간단한 함수를 살펴보겠습니다. 완벽하거나 완전하지는 않지만 데모용으로는 충분합니다.

avg.go

 func Avg(nos ...int) int { sum := 0 for _, n := range nos { sum += n } if sum == 0 { return 0 } return sum / len(nos) }

위의 함수 func Avg(nos ...int) 는 0 또는 주어진 일련의 숫자의 정수 평균을 반환합니다. 이제 테스트를 작성해 보겠습니다.

Go에서는 테스트 중인 코드가 포함된 파일과 이름이 같은 테스트 파일의 이름을 지정하고 접미사 _test 를 추가하는 것이 모범 사례로 간주됩니다. 예를 들어 위의 코드는 avg.go 라는 파일에 있으므로 테스트 파일의 이름은 avg_test.go 가 됩니다.

단순성을 위해 패키지 정의 및 가져오기를 생략했으므로 이러한 예제는 실제 파일의 일부일 뿐입니다.

다음은 Avg 함수에 대한 테스트입니다.

avg__test.go

 func TestAvg(t *testing.T) { for _, tt := range []struct { Nos []int Result int }{ {Nos: []int{2, 4}, Result: 3}, {Nos: []int{1, 2, 5}, Result: 2}, {Nos: []int{1}, Result: 1}, {Nos: []int{}, Result: 0}, {Nos: []int{2, -2}, Result: 0}, } { if avg := Average(tt.Nos...); avg != tt.Result { t.Fatalf("expected average of %v to be %d, got %d\n", tt.Nos, tt.Result, avg) } } }

함수 정의에 대해 주의해야 할 몇 가지 사항이 있습니다.

  • 먼저 테스트 함수 이름에 'Test'라는 접두어를 붙입니다. 이것은 도구가 유효한 테스트로 선택하기 위해 필요합니다.
  • 함수 이름의 뒷부분은 일반적으로 테스트 중인 함수 또는 메서드의 이름입니다(이 경우 Avg ).
  • 또한 테스트 흐름을 제어할 수 있는 testing.T 라는 테스트 구조를 전달해야 합니다. 이 API에 대한 자세한 내용은 설명서 페이지를 참조하십시오.

이제 예제가 작성된 형식에 대해 이야기해 보겠습니다. 테스트 모음(일련의 테스트)은 Avg() 함수를 통해 실행되고 있으며 각 테스트에는 특정 입력과 예상 출력이 포함되어 있습니다. 우리의 경우 각 테스트는 정수 조각( Nos )을 보내고 특정 반환 값( Result )을 예상합니다.

테이블 테스트는 입력 변수와 예상 출력 변수라는 두 개의 열이 있는 테이블로 쉽게 표현되는 구조에서 이름을 얻습니다.

Golang 인터페이스 조롱

Go 언어가 제공해야 하는 가장 강력하고 강력한 기능 중 하나는 인터페이스입니다. 프로그램을 설계할 때 인터페이스를 통해 얻을 수 있는 기능과 유연성 외에도 인터페이스는 구성 요소를 분리하고 회의 지점에서 철저하게 테스트할 수 있는 놀라운 기회를 제공합니다.

인터페이스는 메서드의 명명된 컬렉션이지만 변수 유형이기도 합니다.

io.Reader에서 처음 N 바이트를 읽고 문자열로 반환해야 하는 가상의 시나리오를 가정해 보겠습니다. 다음과 같이 보일 것입니다.

readn.go

 // readN reads at most n bytes from r and returns them as a string. func readN(r io.Reader, n int) (string, error) { buf := make([]byte, n) m, err := r.Read(buf) if err != nil { return "", err } return string(buf[:m]), nil }

분명히 테스트해야 할 주요 사항은 다양한 입력이 제공될 때 함수 readN 이 올바른 출력을 반환한다는 것입니다. 이것은 테이블 테스트로 수행할 수 있습니다. 그러나 우리가 다루어야 할 두 가지 중요하지 않은 측면이 있습니다.

  • r.Read 는 크기가 n인 버퍼로 호출됩니다.
  • r.Read 는 오류가 발생하면 오류를 반환합니다.

r.Read 에 전달되는 버퍼의 크기를 알고 반환되는 오류를 제어하려면 readN 에 전달되는 r 을 조롱해야 합니다. Reader 유형에 대한 Go 문서를 보면 io.Reader 가 어떻게 생겼는지 알 수 있습니다.

 type Reader interface { Read(p []byte) (n int, err error) }

그것은 오히려 쉬운 것 같습니다. io.Reader 를 만족시키기 위해 우리가 해야 할 일은 우리의 모의 Read 메소드를 갖는 것입니다. 따라서 ReaderMock 은 다음과 같을 수 있습니다.

 type ReaderMock struct { ReadMock func([]byte) (int, error) } func (m ReaderMock) Read(p []byte) (int, error) { return m.ReadMock(p) }

위의 코드를 잠시 분석해 보겠습니다. ReaderMock 의 모든 인스턴스는 필요한 Read 메서드를 구현하기 때문에 io.Reader 인터페이스를 명확하게 충족합니다. 우리의 모의에는 또한 ReadMock 필드가 포함되어 있어 모의 메소드의 정확한 동작을 설정할 수 있으므로 필요한 모든 것을 동적으로 인스턴스화하는 것이 매우 쉽습니다.

인터페이스가 런타임에 충족되도록 하는 훌륭한 메모리 프리 트릭은 코드에 다음을 삽입하는 것입니다.

 var _ io.Reader = (*MockReader)(nil)

이것은 어설션을 확인하지만 아무 것도 할당하지 않으므로 프로그램이 실제로 인터페이스를 사용하는 기능으로 실행 되기 전에 컴파일 시간에 인터페이스가 올바르게 구현되었는지 확인할 수 있습니다. 선택적인 트릭이지만 도움이 됩니다.

계속해서 r.Readn 크기의 버퍼로 호출되는 첫 번째 테스트를 작성해 보겠습니다. 이를 위해 다음과 같이 ReaderMock 을 사용합니다.

 func TestReadN_bufSize(t *testing.T) { total := 0 mr := &MockReader{func(b []byte) (int, error) { total = len(b) return 0, nil }} readN(mr, 5) if total != 5 { t.Fatalf("expected 5, got %d", total) } }

위에서 볼 수 있듯이 "가짜" io.ReaderRead 기능에 대한 동작을 범위 변수로 정의했으며 나중에 테스트의 유효성을 주장하는 데 사용할 수 있습니다. 충분히 쉽습니다.

테스트해야 하는 두 번째 시나리오를 살펴보겠습니다. 이 시나리오에서는 Read 를 조롱하여 오류를 반환해야 합니다.

 func TestReadN_error(t *testing.T) { expect := errors.New("some non-nil error") mr := &MockReader{func(b []byte) (int, error) { return 0, expect }} _, err := readN(mr, 5) if err != expect { t.Fatal("expected error") } }

위의 테스트에서 mr.Read (우리의 조롱된 Reader)에 대한 호출은 정의된 오류를 반환하므로 readN 의 올바른 기능이 동일한 작업을 수행할 것이라고 가정하는 것이 안전합니다.

Go를 사용한 함수 조롱

구조와 인터페이스를 대신 사용하는 경향이 있기 때문에 함수를 조롱해야 하는 경우는 많지 않습니다. 이것들은 제어하기가 더 쉽지만 때때로 우리는 이 필요성에 부딪힐 수 있으며 주제와 관련하여 혼란을 자주 봅니다. 어떤 사람들은 log.Println 과 같은 것을 조롱하는 방법을 묻기도 했습니다. log.Println 에 제공된 입력을 테스트해야 하는 경우는 드물지만 이 기회를 사용하여 시연합니다.

n 값에 따라 출력을 기록하는 아래의 간단한 if 문을 고려하십시오.

 func printSize(n int) { if n < 10 { log.Println("SMALL") } else { log.Println("LARGE") } }

위의 예에서 우리는 log.Println 이 올바른 값으로 호출되었는지 구체적으로 테스트하는 우스꽝스러운 시나리오를 가정합니다. 이 함수를 모의하려면 먼저 우리 자신의 내부에 래핑해야 합니다.

 var show = func(v ...interface{}) { log.Println(v...) }

이런 식으로 함수를 변수로 선언하면 테스트에서 덮어쓰고 원하는 동작을 할당할 수 있습니다. 암시적으로 log.Println 을 참조하는 행은 show 로 대체되므로 프로그램은 다음과 같이 됩니다.

 func printSize(n int) { if n < 10 { show("SMALL") } else { show("LARGE") } }

이제 다음을 테스트할 수 있습니다.

 func TestPrintSize(t *testing.T) { var got string oldShow := show show = func(v ...interface{}) { if len(v) != 1 { t.Fatalf("expected show to be called with 1 param, got %d", len(v)) } var ok bool got, ok = v[0].(string) if !ok { t.Fatal("expected show to be called with a string") } } for _, tt := range []struct{ N int Out string }{ {2, "SMALL"}, {3, "SMALL"}, {9, "SMALL"}, {10, "LARGE"}, {11, "LARGE"}, {100, "LARGE"}, } { got = "" printSize(tt.N) if got != tt.Out { t.Fatalf("on %d, expected '%s', got '%s'\n", tt.N, tt.Out, got) } } // careful though, we must not forget to restore it to its original value // before finishing the test, or it might interfere with other tests in our // suite, giving us unexpected and hard to trace behavior. show = oldShow }

우리의 테이크 아웃은 'mock log.Println '이되어서는 안되지만 합법적 인 이유로 패키지 수준 기능을 조롱해야 할 때 매우 가끔 시나리오에서 그렇게하는 유일한 방법은 (내가 아는 한) 값을 제어할 수 있도록 패키지 수준 변수로 선언합니다.

그러나 log.Println 과 같은 것을 조롱해야 하는 경우 사용자 지정 로거를 사용하는 경우 훨씬 더 우아한 솔루션을 작성할 수 있습니다.

Go 템플릿 렌더링 테스트

상당히 일반적인 또 다른 시나리오는 렌더링된 템플릿의 출력이 예상과 일치하는지 테스트하는 것입니다. 다음 본문을 반환하는 http://localhost:3999/welcome?name=Frank 에 대한 GET 요청을 고려해 보겠습니다.

 <html> <head><title>Welcome page</title></head> <body> <h1 class="header-name"> Welcome <span class="name">Frank</span>! </h1> </body> </html>

지금까지 명확하지 않은 경우 쿼리 매개 변수 name 이 "이름"으로 분류된 span 의 내용과 일치하는 것은 우연이 아닙니다. 이 경우 명백한 테스트는 여러 출력에서 ​​매번 올바르게 발생하는지 확인하는 것입니다. 여기에서 GoQuery 라이브러리가 매우 유용하다는 것을 알았습니다.

GoQuery는 jQuery와 유사한 API를 사용하여 프로그램의 마크업 출력 유효성을 테스트하는 데 필수적인 HTML 구조를 쿼리합니다.

이제 다음과 같은 방식으로 테스트를 작성할 수 있습니다.

welcome__test.go

 func TestWelcome_name(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { t.Fatal(err) } if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" { t.Fatalf("expected markup to contain 'Frank', got '%s'", v) } }

먼저 진행하기 전에 응답 코드가 200/OK인지 확인합니다.

위의 나머지 코드 조각이 자명하다고 가정하는 것이 너무 무리가 아니라고 생각합니다. http 패키지를 사용하여 URL을 검색하고 응답에서 새로운 goquery 호환 문서를 생성한 다음 쿼리에 사용합니다. 반환된 DOM입니다. h1.header-name 내부의 span.name 이 'Frank'라는 텍스트를 캡슐화하는지 확인합니다.

JSON API 테스트

Go는 일종의 API를 작성하는 데 자주 사용되므로 마지막으로 중요한 것은 JSON API를 테스트하는 몇 가지 높은 수준의 방법을 살펴보겠습니다.

엔드포인트가 이전에 HTML 대신 JSON을 반환했다면 http://localhost:3999/welcome.json?name=Frank 에서 응답 본문이 다음과 같을 것으로 예상합니다.

 {"Salutation": "Hello Frank!"}

이미 추측했을 수 있는 JSON 응답을 어설션하는 것은 외부 라이브러리나 종속성이 필요하지 않다는 점을 제외하고 템플릿 응답을 어설션하는 것과 크게 다르지 않습니다. Go의 표준 라이브러리로 충분합니다. 다음은 주어진 매개변수에 대해 올바른 JSON이 반환되는지 확인하는 테스트입니다.

welcome__test.go

 func TestWelcome_name_JSON(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome.json?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } var dst struct{ Salutation string } if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil { t.Fatal(err) } if dst.Salutation != "Hello Frank!" { t.Fatalf("expected 'Hello Frank!', got '%s'", dst.Salutation) } }

디코딩 대상이 아닌 다른 구조가 반환되면 json.NewDecoder 는 대신 오류를 반환하고 테스트는 실패합니다. 응답이 구조에 대해 성공적으로 디코딩된다는 점을 고려하면 필드의 내용이 예상대로인지 확인합니다(이 경우 "Hello Frank!").

설정 및 분해

Go로 테스트하는 것은 쉽지만 위의 JSON 테스트와 그 이전의 템플릿 렌더링 테스트에는 한 가지 문제가 있습니다. 둘 다 서버가 실행 중이라고 가정하고 이로 인해 신뢰할 수 없는 종속성이 생성됩니다. 또한 "라이브" 서버에 반대하는 것은 좋은 생각이 아닙니다.

"라이브" 프로덕션 서버에서 "라이브" 데이터에 대해 테스트하는 것은 좋은 생각이 아닙니다. 로컬 또는 개발 복사본을 스핀업하여 끔찍하게 잘못된 작업으로 인한 손상을 방지합니다.

운 좋게도 Go는 테스트 서버를 생성하기 위한 httptest 패키지를 제공합니다. 테스트는 우리의 메인 서버와 독립적인 별도의 자체 서버를 작동시키므로 테스트가 프로덕션을 방해하지 않습니다.

이러한 경우 실행 중인 서버가 필요한 모든 테스트에서 호출할 일반 setupteardown 기능을 만드는 것이 이상적입니다. 이 새롭고 안전한 패턴을 따르면 테스트 결과는 다음과 같습니다.

 func setup() *httptest.Server { return httptest.NewServer(app.Handler()) } func teardown(s *httptest.Server) { s.Close() } func TestWelcome_name(t *testing.T) { srv := setup() url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL) resp, err := http.Get(url) // verify errors & run assertions as usual teardown(srv) }

app.Handler() 참조를 확인하세요. 이것은 프로덕션 서버 또는 테스트 서버를 인스턴스화할 수 있는 애플리케이션의 http.Handler를 반환하는 모범 사례 함수입니다.

결론

Go에서 테스트하는 것은 프로그램의 외부 관점을 가정하고 방문자 또는 대부분의 경우 API 사용자의 입장이 될 수 있는 좋은 기회입니다. 좋은 코드와 양질의 경험을 제공할 수 있는 좋은 기회를 제공합니다.

코드의 더 복잡한 기능에 대해 확신이 없을 때마다 테스트는 안심할 수 있는 방법으로 유용하며 더 큰 시스템의 일부를 수정할 때 조각이 계속 잘 작동하도록 보장합니다.

이 기사가 도움이 되었기를 바라며, 다른 테스트 트릭을 알고 있다면 언제든지 댓글을 남겨주세요.