英文:
Mock/test basic http.get request
问题
我正在学习编写单元测试,想知道正确的方法来对基本的http.get
请求进行单元测试。
我在网上找到了一个返回虚假数据的API,并编写了一个基本程序,获取一些用户数据并打印出ID:
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
)
type UserData struct {
Meta interface{} `json:"meta"`
Data struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Gender string `json:"gender"`
Status string `json:"status"`
} `json:"data"`
}
func main() {
resp := sendRequest()
body := readBody(resp)
id := unmarshallData(body)
fmt.Println(id)
}
func sendRequest() *http.Response {
resp, err := http.Get("https://gorest.co.in/public/v1/users/1841")
if err != nil {
log.Fatalln(err)
}
return resp
}
func readBody(resp *http.Response) []byte {
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatalln(err)
}
return body
}
func unmarshallData(body []byte) int {
var userData UserData
json.Unmarshal(body, &userData)
return userData.Data.ID
}
这段代码可以正常工作并打印出1841。然后,我想编写一些测试来验证代码的行为是否符合预期,例如,如果返回错误,它是否能正确地失败,返回的数据是否可以解组。我在网上阅读了一些资料并查看了一些示例,但它们都比我想要实现的要复杂得多。
我从以下测试开始,确保传递给unmarshallData
函数的数据可以被解组:
package main
import (
"testing"
)
func Test_unmarshallData(t *testing.T) {
type args struct {
body []byte
}
tests := []struct {
name string
args args
want int
}{
{name: "Unmarshall", args: struct{ body []byte }{body: []byte(`{"meta":null,"data":{"id":1841,"name":"Piya","email":"priya@gmai.com","gender":"female","status":"active"}}`)}, want: 1841},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := unmarshallData(tt.args.body); got != tt.want {
t.Errorf("unmarshallData() = %v, want %v", got, tt.want)
}
})
}
}
希望对接下来的步骤有所帮助。
英文:
I am leaning to write unit tests and I was wondering the correct way to unit test a basic http.get
request.
I found an API online that returns fake data and wrote a basic program that gets some user data and prints out an ID:
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
)
type UserData struct {
Meta interface{} `json:"meta"`
Data struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Gender string `json:"gender"`
Status string `json:"status"`
} `json:"data"`
}
func main() {
resp := sendRequest()
body := readBody(resp)
id := unmarshallData(body)
fmt.Println(id)
}
func sendRequest() *http.Response {
resp, err := http.Get("https://gorest.co.in/public/v1/users/1841")
if err != nil {
log.Fatalln(err)
}
return resp
}
func readBody(resp *http.Response) []byte {
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatalln(err)
}
return body
}
func unmarshallData(body []byte) int {
var userData UserData
json.Unmarshal(body, &userData)
return userData.Data.ID
}
This works and prints out 1841. I then wanted to write some tests that validate that the code is behaving as expected, e.g. that it correctly fails if an error is returned, that the data returned can be unmarshalled. I have been reading online and looking at examples but they are all far more complex that what I feel I am trying to achieve.
I have started with the following test that ensures that the data passed to the unmarshallData
function can be unmarshalled:
package main
import (
"testing"
)
func Test_unmarshallData(t *testing.T) {
type args struct {
body []byte
}
tests := []struct {
name string
args args
want int
}{
{name: "Unmarshall", args: struct{ body []byte }{body: []byte("{\"meta\":null,\"data\":{\"id\":1841,\"name\":\"Piya\",\"email\":\"priya@gmai.com\",\"gender\":\"female\",\"status\":\"active\"}}")}, want: 1841},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := unmarshallData(tt.args.body); got != tt.want {
t.Errorf("unmarshallData() = %v, want %v", got, tt.want)
}
})
}
}
Any advise on where to go from here would be appreciated.
答案1
得分: 1
在进行测试之前,你的代码存在一个严重的问题,如果在将来的编程任务中不加以处理,这将成为一个问题。
https://pkg.go.dev/net/http 查看第二个示例
客户端在使用完响应体后必须关闭它
让我们现在修复这个问题(我们将在后面回到这个主题),有两种可能性。
1/ 在 main
函数中使用 defer
在你使用完响应体后关闭它;
func main() {
resp := sendRequest()
defer body.Close()
body := readBody(resp)
id := unmarshallData(body)
fmt.Println(id)
}
2/ 在 readBody
函数中进行关闭;
func readBody(resp *http.Response) []byte {
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatalln(err)
}
return body
}
使用 defer
是关闭资源的预期方式。它有助于读者确定资源的生命周期,并提高可读性。
注意:我不会使用 表驱动测试模式,但你应该使用,就像你在原始问题中所做的那样。
接下来进入测试部分。
测试可以写在同一个包中,或者在同一个包的测试版本中,后面加上 _test
,例如 [package target]_test
。这有两个方面的影响。
- 使用单独的包,测试将在最终构建中被忽略。这有助于生成较小的二进制文件。
- 使用单独的包,你可以以黑盒方式测试 API,只能访问它明确公开的标识符。
你当前的测试是白盒测试,意味着你可以访问 main
的任何声明,无论是公开的还是非公开的。
关于 sendRequest
,编写一个关于它的测试并不是很有趣,因为它的功能太少,你的测试不应该用来测试标准库。
但是出于演示和好的原因,我们可能不想依赖外部资源来执行测试。
为了实现这一点,我们必须将全局依赖项作为注入的依赖项使用。这样,以后可以替换它所依赖的唯一事物,即 http.Get 方法。
func sendRequest(client interface{Get() (*http.Response, error)}) *http.Response {
resp, err := client.Get("https://gorest.co.in/public/v1/users/1841")
if err != nil {
log.Fatalln(err)
}
return resp
}
这里我使用了内联接口声明 interface{Get() (*http.Response, error)}
。
现在我们可以添加一个新的测试,该测试注入了一段代码,该代码将返回恰好触发我们要测试的行为的值。
type fakeGetter struct {
resp *http.Response
err error
}
func (f fakeGetter) Get(u string) (*http.Response, error) {
return f.resp, f.err
}
func TestSendRequestReturnsNilResponseOnError(t *testing.T) {
c := fakeGetter{
err: fmt.Errorf("whatever error will do"),
}
resp := sendRequest(c)
if resp != nil {
t.Fatal("当出现错误时,它应该返回一个空响应")
}
}
现在运行这个测试并查看结果。这个结果并不具有决定性,因为你的函数包含一个调用 log.Fatal,它又执行了 os.Exit;我们无法测试它。
如果我们试图改变这一点,我们可能会认为我们可以调用 panic,因为我们可以 recover。
我不建议这样做,我认为这是不好的,但它确实存在,所以我们可以考虑。这也是对函数签名的最小可能更改。返回一个错误会更破坏当前的签名。我想在演示中将其最小化。但是,作为一个经验法则,返回一个错误并始终检查它们。
在 sendRequest
函数中,将这个调用 log.Fatalln(err)
替换为 panic(err)
,并更新测试以捕获 panic。
func TestSendRequestReturnsNilResponseOnError(t *testing.T) {
var hasPanicked bool
defer func() {
_ = recover() // 如果你捕获输出值或恢复,你会得到传递给 panic 调用的错误。我们没有用它。
hasPanicked = true
}()
c := fakeGetter{
err: fmt.Errorf("whatever error will do"),
}
resp := sendRequest(c)
if resp != nil {
t.Fatal("当出现错误时,它应该返回一个空响应")
}
if !hasPanicked {
t.Fatal("它应该已经 panic 了")
}
}
现在我们可以继续处理另一条执行路径,即非错误返回。
为此,我们构造了一个我们想要传递给函数的期望的 *http.Response 实例,然后我们将检查其属性,以确定函数的行为是否符合我们的期望。
我们将考虑确保它未被修改:
下面的测试仅设置了两个属性,我将演示如何使用 NopCloser 和 strings.NewReader 设置 Body,因为在 Go 语言中经常需要这样做;
我还使用 reflect.DeepEqual 作为粗暴的相等性检查器,通常你可以更细粒度地进行检查并获得更好的测试。在这种情况下,DeepEqual
可以胜任,但它引入了不必要的复杂性,不值得系统地使用它。
func TestSendRequestReturnsUnmodifiedResponse(t *testing.T) {
c := fakeGetter{
err: nil,
resp: &http.Response{
Status: http.StatusOK,
Body: ioutil.NopCloser(strings.NewReader("some text")),
},
}
resp := sendRequest(c)
if !reflect.DeepEqual(resp, c.resp) {
t.Fatal("响应不应该被修改")
}
}
到这一点,你可能已经意识到这个小函数 sendRequest
不好,如果你没有意识到,我向你保证它不好。它做得太少了,它只是包装了 http.Get
方法,对于业务逻辑的生存来说,它的测试没有太大的意义。
接下来是 readBody
函数。
适用于 sendRequest
的所有备注也适用于这里。
- 它做得太少
- 它调用了
os.Exit
有一件事不适用。由于调用 ioutil.ReadAll
不依赖外部资源,所以没有必要尝试注入该依赖项。我们可以在周围进行测试。
尽管如此,为了演示起见,现在是时候谈论一下缺少的调用 defer resp.Body.Close()
。
让我们假设我们采用了介绍中提出的第二个建议,并对此进行测试。
http.Response
结构体适当地将其 Body
接收者公开为一个接口。
为了确保代码调用了 Close
,我们可以编写一个存根来实现它。该存根将记录是否进行了该调用,测试可以检查并在未进行调用时触发错误。
type closeCallRecorder struct {
hasClosed bool
}
func (c *closeCallRecorder) Close() error {
c.hasClosed = true
return nil
}
func (c *closeCallRecorder) Read(p []byte) (int, error) {
return 0, nil
}
func TestReadBodyCallsClose(t *testing.T) {
body := &closeCallRecorder{}
res := &http.Response{
Body: body,
}
_ = readBody(res)
if !body.hasClosed {
t.Fatal("响应体没有被关闭")
}
}
类似地,出于演示的目的,我们可能希望测试函数是否调用了 Read
。
type readCallRecorder struct {
hasRead bool
}
func (c *readCallRecorder) Read(p []byte) (int, error) {
c.hasRead = true
return 0, nil
}
func TestReadBodyHasReadAnything(t *testing.T) {
body := &readCallRecorder{}
res := &http.Response{
Body: ioutil.NopCloser(body),
}
_ = readBody(res)
if !body.hasRead {
t.Fatal("响应体没有被读取")
}
}
我们还可以验证在两者之间没有修改 body,
func TestReadBodyDidNotModifyTheResponse(t *testing.T) {
want := "this"
res := &http.Response{
Body: ioutil.NopCloser(strings.NewReader(want)),
}
resp := readBody(res)
if got := string(resp); want != got {
t.Fatal("无效的响应,期望=%q,实际=%q", want, got)
}
}
我们几乎完成了,让我们继续处理 unmarshallData
函数。
你已经编写了一个关于它的测试。它还可以,但是我会这样编写它,使其更简洁:
type UserData struct {
Meta interface{} `json:"meta"`
Data Data `json:"data"`
}
type Data struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Gender string `json:"gender"`
Status string `json:"status"`
}
func Test_unmarshallData(t *testing.T) {
type args struct {
body []byte
}
tests := []UserData{
UserData{Data: Data{ID: 1841}},
}
for _, u := range tests {
want := u.ID
b, _ := json.Marshal(u)
t.Run("Unmarshal", func(t *testing.T) {
if got := unmarshallData(b); got != want {
t.Errorf("unmarshallData() = %v, want %v", got, want)
}
})
}
}
然后,应用通常的规则:
- 不要使用
log.Fatal
- 你在测试什么?marshaller?
最后,现在我们已经收集了所有这些部分,我们可以重构代码,编写一个更合理的函数,并重用所有这些部分来帮助我们测试这样的代码。
我不会这样做,但是这是一个入门,它仍然会 panic,我仍然不推荐,但是前面的演示已经展示了测试返回错误的版本所需的一切。
type userFetcher struct {
Requester interface {
Get(u string) (*http.Response, error)
}
}
func (u userFetcher) Fetch() int {
resp, err := u.Requester.Get("https://gorest.co.in/public/v1/users/1841") // 这个字符串是静态的并不重要,使用请求者我们可以模拟响应、它的 body 和错误。
if err != nil {
panic(err)
}
defer resp.Body.Close() // 总是要关闭。
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
panic(err)
}
var userData UserData
err = json.Unmarshal(body, &userData)
if err != nil {
panic(err)
}
return userData.Data.ID
}
英文:
before moving on to the testing, your code has a serious flow, which will become a problem if you don't take care about it in your future programming tasks.
https://pkg.go.dev/net/http See the second example
> The client must close the response body when finished with it
Let's fix that now (we will have to come back on this subject later), two possibilities.
1/ within main
, use defer to Close that resource after you have drained it;
func main() {
resp := sendRequest()
defer body.Close()
body := readBody(resp)
id := unmarshallData(body)
fmt.Println(id)
}
2/ Do that within readBody
func readBody(resp *http.Response) []byte {
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatalln(err)
}
return body
}
Using a defer is the expected manner to close the resource. It helps the reader to identify the lifetime span of the resource and improve readability.
Notes : I will not be using much of the table test driven pattern, but you should, like you did in your OP.
Moving on to the testing part.
Tests can be written under the same package or its fellow version with a trailing _test
, such as [package target]_test
. This has implications in two ways.
- Using a separate package, they will be ignored in the final build. Which will help to produce smaller binaries.
- Using a separate package, you test the API in a black box manner, you can access only the identifiers it explicitly exposes.
Your current tests are white boxed, meaning you can access any declaration of main
, public or not.
About sendRequest
, writing a test around this is not very interesting because it does too little, and your tests should not be written to test the std library.
But for the sake of the demonstration, and for good reasons we might want to not rely on external resources to execute our tests.
In order to achieve that we must make the global dependencies consumed within it, an injected dependency. So that later on, it is possible to replace the one thing it depends on to react, the http.Get method.
func sendRequest(client interface{Get() (*http.Response, error)}) *http.Response {
resp, err := client.Get("https://gorest.co.in/public/v1/users/1841")
if err != nil {
log.Fatalln(err)
}
return resp
}
Here i use an inlined interface declaration interface{Get() (*http.Response, error)}
.
Now we can add a new test which injects a piece of code that will return exactly the values that will trigger the behavior we want to test within our code.
type fakeGetter struct {
resp *http.Response
err error
}
func (f fakeGetter) Get(u string) (*http.Response, error) {
return f.resp, f.err
}
func TestSendRequestReturnsNilResponseOnError(t *testing.T) {
c := fakeGetter{
err: fmt.Errorf("whatever error will do"),
}
resp := sendRequest(c)
if resp != nil {
t.Fatal("it should return a nil response when an error arises")
}
}
Now run this test and see the result. It is not conclusive because your function contains a call to log.Fatal, which in turns executes an os.Exit; We cannot test that.
If we try to change that, we might think we might call for panic instead because we can recover.
I don't recommend doing that, in my opinion, this is smelly and bad, but it exists, so we might consider. This is also the least possible change to the function signature. Returning an error would break even more the current signatures. I want to minimize this for that demonstration. But, as a rule of thumb, return an error and always check them.
In the sendRequest
function, replace this call log.Fatalln(err)
with panic(err)
and update the test to capture the panic.
func TestSendRequestReturnsNilResponseOnError(t *testing.T) {
var hasPanicked bool
defer func() {
_ = recover() // if you capture the output value or recover, you get the error gave to the panic call. We have no use of it.
hasPanicked = true
}()
c := fakeGetter{
err: fmt.Errorf("whatever error will do"),
}
resp := sendRequest(c)
if resp != nil {
t.Fatal("it should return a nil response when an error arises")
}
if !hasPanicked {
t.Fatal("it should have panicked")
}
}
We can now move on to the other execution path, the non error return.
For that we forge the desired *http.Response instance we want to pass into our function, we will then check its properties to figure out if what the function does is inline with what we expect.
We will consider we want to ensure it is returned unmodified : /
Below test only sets two properties, and I will do it to demonstrate how to set the Body with a NopCloser and strings.NewReader as it is often needed later on using the Go language;
I also use reflect.DeepEqual as brute force equality checker, usually you can be more fine grained and get better tests. DeepEqual
does the job in this case but it introduces complexity that does not justify systematic use of it.
func TestSendRequestReturnsUnmodifiedResponse(t *testing.T) {
c := fakeGetter{
err: nil,
resp: &http.Response{
Status: http.StatusOK,
Body: ioutil.NopCloser(strings.NewReader("some text")),
},
}
resp := sendRequest(c)
if !reflect.DeepEqual(resp, c.resp) {
t.Fatal("the response should not have been modified")
}
}
At that point you may have figured that this small function sendRequest
is not good, if you did not I ensure you it is not. It does too little, it merely wraps the http.Get
method and its testing is of little interest for the survival of the business logic.
Moving on to readBody
function.
All remarks that applied for sendRequest
apply here too.
- it does too little
- it
os.Exit
s
One thing does not apply. As the call to ioutil.ReadAll
does not rely on external resources, there is no point in attempting to inject that dependency. We can test around.
Though, for the sake of the demonstration, it is the time to talk about the missing call to defer resp.Body.Close()
.
Let us assume we go for the second proposition made in introduction and test for that.
The http.Response
struct adequately exposes its Body
recipient as an interface.
To ensure the code calls for the `Close, we can write a stub for it.
That stub will record if that call was made, the test can then check for that and trigger an error if it was not.
type closeCallRecorder struct {
hasClosed bool
}
func (c *closeCallRecorder) Close() error {
c.hasClosed = true
return nil
}
func (c *closeCallRecorder) Read(p []byte) (int, error) {
return 0, nil
}
func TestReadBodyCallsClose(t *testing.T) {
body := &closeCallRecorder{}
res := &http.Response{
Body: body,
}
_ = readBody(res)
if !body.hasClosed {
t.Fatal("the response body was not closed")
}
}
Similarly, and for the sake of the demonstration, we might want to test if the function has called for Read
.
type readCallRecorder struct {
hasRead bool
}
func (c *readCallRecorder) Read(p []byte) (int, error) {
c.hasRead = true
return 0, nil
}
func TestReadBodyHasReadAnything(t *testing.T) {
body := &readCallRecorder{}
res := &http.Response{
Body: ioutil.NopCloser(body),
}
_ = readBody(res)
if !body.hasRead {
t.Fatal("the response body was not read")
}
}
We an also verify the body was not modified in betwen,
func TestReadBodyDidNotModifyTheResponse(t *testing.T) {
want := "this"
res := &http.Response{
Body: ioutil.NopCloser(strings.NewReader(want)),
}
resp := readBody(res)
if got := string(resp); want != got {
t.Fatal("invalid response, wanted=%q got %q", want, got)
}
}
We have almost done, lets move one to the unmarshallData
function.
You have already wrote a test about it. It is okish, though, i would write it this way to make it leaner:
type UserData struct {
Meta interface{} `json:"meta"`
Data Data `json:"data"`
}
type Data struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Gender string `json:"gender"`
Status string `json:"status"`
}
func Test_unmarshallData(t *testing.T) {
type args struct {
body []byte
}
tests := []UserData{
UserData{Data: Data{ID: 1841}},
}
for _, u := range tests {
want := u.ID
b, _ := json.Marshal(u)
t.Run("Unmarshal", func(t *testing.T) {
if got := unmarshallData(b); got != want {
t.Errorf("unmarshallData() = %v, want %v", got, want)
}
})
}
}
Then, the usual apply :
- don't log.Fatal
- what are you testing ? the marshaller ?
Finally, now that we have gathered all those pieces, we can refactor to write a more sensible function and re use all those pieces to help us testing such code.
I won't do it, but here is a starter, which still panics, and I still don't recommend, but the previous demonstration has shown everything needed to test a version of it that returns an error.
type userFetcher struct {
Requester interface {
Get(u string) (*http.Response, error)
}
}
func (u userFetcher) Fetch() int {
resp, err := u.Requester.Get("https://gorest.co.in/public/v1/users/1841") // it does not really matter that this string is static, using the requester we can mock the response, its body and the error.
if err != nil {
panic(err)
}
defer resp.Body.Close() //always.
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
panic(err)
}
var userData UserData
err = json.Unmarshal(body, &userData)
if err != nil {
panic(err)
}
return userData.Data.ID
}
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论