Go语言编程笔记18:软件测试

Go语言编程笔记18:软件测试

image-20211108153040805

图源:wallpapercave.com

软件测试也是软件开发的重要组成部分,本篇文章将探讨如何使用Go的标准库和第三方库对程序进行测试。

testing

Go的标准库提供一个简单的包testing用于构建测试用例,这里来看一个简单的程序:

package main

import "fmt"

func Add(a int, b int) int {
	return a + b
}

func main() {
	fmt.Printf("%d+%d=%d\n", 1, 2, Add(1, 2))
}

虽然可以在main函数中对Add方法进行一些简单的测试,但是并非总可以这么做,比如当前程序文件是入口文件,那么main函数就是有意义的,无法用于测试。此外,将测试代码和正常代码混合在一起也不是个好主意,会产生一些额外问题。

要使用testing包构建对应的测试用例也很简单,只要在对应代码的同一个目录下创建xxx_test.go文件。在这个例子中,对应的测试用例代码如下:

package main

import "testing"

func TestAdd(t *testing.T) {
	a := 1
	b := 2
	result := Add(a, b)
	if result != (a + b) {
		t.Error("result is not a + b")
	}
}

测试用例以函数的形式定义,且以TestXXX作为函数名。也就是说执行测试时,xxx_test.go文件中所有以TestXXX命名的函数都会被执行。

测试用例所在的函数会接收一个testing.T类型的指针,可以通过该指针报告测试过程中产生的错误。就像上边示例中的t.Error所作的那样。

testing包本身并没有其它语言测试框架中常会采用的断言,所以只是使用简单的比较语句判断函数执行结果是否正确。

最后可以在代码所在目录下使用go test命令执行测试:

go test
PASS
ok      github.com/icexmoon/go-notebook/ch18/sample     0.286s

但这样的测试结果信息太少,可以使用-v参数输出详细信息:

go test -v
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      github.com/icexmoon/go-notebook/ch18/sample     0.040s

除了命令行的方式以外,VSCode中可以识别并直接执行测试用例:

image-20220102142652919

点击测试用例左侧的小三角就可以执行相应的用例,并在下方输出结果。并且执行成功后左侧的小三角会变成√。

在一个用例中可以使用多组参数对目标函数进行验证:

package main

import "testing"

func TestAdd(t *testing.T) {
	a := 1
	b := 2
	result := Add(a, b)
	if result != 3 {
		t.Error("1 + 2 is not 3")
	}
	a = -1
	b = -3
	result = Add(a, b)
	if result != -4 {
		t.Error("-1 + -3 is not -4")
	}
}

为了验证结果,这里将Add函数故意改错:

...
func Add(a int, b int) int {
	return a * b
}
...

测试结果:

go test -v
=== RUN   TestAdd
    main_test.go:10: 1 + 2 is not 3
    main_test.go:16: -1 + -3 is not -4
--- FAIL: TestAdd (0.00s)
FAIL
exit status 1
FAIL    github.com/icexmoon/go-notebook/ch18/sample2    0.286s

可以看到用例中所有的参数/返回值都得到了验证,如果要让用例在某个检测未通过后直接结束运行,可以使用Fatal报告错误而不是Error

package main

import "testing"

func TestAdd(t *testing.T) {
	a := 1
	b := 2
	result := Add(a, b)
	if result != 3 {
		t.Fatal("1 + 2 is not 3")
	}
	a = -1
	b = -3
	result = Add(a, b)
	if result != -4 {
		t.Fatal("-1 + -3 is not -4")
	}
}

用例执行结果:

go test -v
=== RUN   TestAdd
    main_test.go:10: 1 + 2 is not 3
--- FAIL: TestAdd (0.00s)
FAIL
exit status 1
FAIL    github.com/icexmoon/go-notebook/ch18/sample3    0.270s

跳过测试用例

有时候我们要编写测试用例的目标代码中可能包含还未完成编写的代码,比如:

...
func Mul(a int, b int) int {
	//is developing...
	return 0
}
...

此时如果编写对应的用例就会让用例执行结果出现Fail,当然这种结果也可能是你需要的,这具体取决于项目对测试用例和版本控制的管理,但如果你不想要这种结果,可以使用Skip方法跳过:

...
func TestMul(t *testing.T) {
	t.Skip("is not finished yet.")
}

用例执行结果:

go test -v
=== RUN   TestAdd
    main_test.go:10: 1 + 2 is not 3
--- FAIL: TestAdd (0.00s)
=== RUN   TestMul
    main_test.go:21: is not finished yet.
--- SKIP: TestMul (0.00s)
FAIL
exit status 1
FAIL    github.com/icexmoon/go-notebook/ch18/sample4    0.303s

除了使用Skip函数跳过整个用例以外,还可以结合go test的"short模式"来精确控制,以实现在"short"模式下才跳过某些用例。

比如说测试目标代码如下:

...
func LongTimeAdd(a int, b int) int {
	time.Sleep(3 * time.Second)
	return a + b
}
...

用例:

package main

import "testing"

func TestLongTimeAdd(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping LongTime func test in short mode")
	}
	if LongTimeAdd(1, 5) != 6 {
		t.Error("result of LongTimeAdd(1, 5) is not 6")
	}
}

用例中的testing.Short()函数在测试时使用“short模式”时返回为true,所以可以用于判断是否处于“short模式”。

用一般模式执行用例:

go test -v
=== RUN   TestLongTimeAdd
--- PASS: TestLongTimeAdd (3.01s)
PASS
ok      github.com/icexmoon/go-notebook/ch18/short      3.326s

“short模式”:

go test -v -short
=== RUN   TestLongTimeAdd
    main_test.go:7: skipping LongTime func test in short mode
--- SKIP: TestLongTimeAdd (0.00s)
PASS
ok      github.com/icexmoon/go-notebook/ch18/short      0.049s

覆盖率

此外,执行用例时可以使用-cover参数让结果输出用例的覆盖率:

go test -v -cover
=== RUN   TestAdd
    main_test.go:10: 1 + 2 is not 3
--- FAIL: TestAdd (0.00s)
=== RUN   TestMul
    main_test.go:21: is not finished yet.
--- SKIP: TestMul (0.00s)
FAIL
coverage: 33.3% of statements
exit status 1
FAIL    github.com/icexmoon/go-notebook/ch18/sample4    0.285s

并发

普通情况下,用例是顺序执行的,比如如果对下面的程序进行测试:

package main

import (
	"time"
)

func LangTimeAdd1(a int, b int) int {
	time.Sleep(1 * time.Second)
	return a + b
}

func LangTimeAdd2(a int, b int) int {
	time.Sleep(2 * time.Second)
	return a + b
}

func LangTimeAdd3(a int, b int) int {
	time.Sleep(3 * time.Second)
	return a + b
}

func main() {
}

用例:

package main

import "testing"

func TestLangTimeAdd1(t *testing.T) {
	if LangTimeAdd1(1, 2) != 3 {
		t.Error("result of 1+2 is not 3")
	}
}

func TestLangTimeAdd2(t *testing.T) {
	if LangTimeAdd2(1, 2) != 3 {
		t.Error("result of 1+2 is not 3")
	}
}

func TestLangTimeAdd3(t *testing.T) {
	if LangTimeAdd3(1, 2) != 3 {
		t.Error("result of 1+2 is not 3")
	}
}

执行结果:

go test -v
=== RUN   TestLangTimeAdd1
--- PASS: TestLangTimeAdd1 (1.01s)
=== RUN   TestLangTimeAdd2
--- PASS: TestLangTimeAdd2 (2.01s)
=== RUN   TestLangTimeAdd3
--- PASS: TestLangTimeAdd3 (3.00s)
PASS
ok      github.com/icexmoon/go-notebook/ch18/sample5    6.306s

三个用例顺序执行,执行时间分别是1、2、3秒,所以总共运行了6秒。

如同在Go语言编程笔记7:goroutine和通道 - 魔芋红茶’s blog (icexmoon.xyz)中说过的,Go对并发有着很好的支持,而大多数用例显然也是不会互相影响,可以并行执行的。所以在testing包中也可以使用并发来提高用例的执行效率。

要使用并发执行用例,也很简单,只要在用例中添加t.Parallel()调用即可:

package main

import "testing"

func TestLangTimeAdd1(t *testing.T) {
	t.Parallel()
	if LangTimeAdd1(1, 2) != 3 {
		t.Error("result of 1+2 is not 3")
	}
}

func TestLangTimeAdd2(t *testing.T) {
	t.Parallel()
	if LangTimeAdd2(1, 2) != 3 {
		t.Error("result of 1+2 is not 3")
	}
}

func TestLangTimeAdd3(t *testing.T) {
	t.Parallel()
	if LangTimeAdd3(1, 2) != 3 {
		t.Error("result of 1+2 is not 3")
	}
}

执行用例时需要添加--parallel参数:

go test -v -parallel 3
=== RUN   TestLangTimeAdd1
=== PAUSE TestLangTimeAdd1
=== RUN   TestLangTimeAdd2
=== PAUSE TestLangTimeAdd2
=== RUN   TestLangTimeAdd3
=== PAUSE TestLangTimeAdd3
=== CONT  TestLangTimeAdd1
=== CONT  TestLangTimeAdd3
=== CONT  TestLangTimeAdd2
--- PASS: TestLangTimeAdd1 (1.01s)
--- PASS: TestLangTimeAdd2 (2.01s)
--- PASS: TestLangTimeAdd3 (3.00s)
PASS
ok      github.com/icexmoon/go-notebook/ch18/sample6    3.293s

该参数用于指定启用的goroutine数目。

Parallel这个单词的意思是“并行、平行”。

基准测试

上面的测试用例实际上进行的都是功能测试(functional testing),实际上使用testing包还可以实现基准测试(benchmarking),或者说性能测试。

这里使用之前在Go语言编程笔记17:Web Service - 魔芋红茶’s blog (icexmoon.xyz)中介绍过的JSON解析的示例来说明,假如测试目标代码如下:

package main

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"os"
	"strings"
)

type Article struct {
	Id       int       `json:"id"`
	Content  string    `json:"content"`
	Comments []Comment `json:"contents"`
	Uid      int       `json:"uid"`
}

func (a *Article) String() string {
	var comments []string
	for _, c := range a.Comments {
		comments = append(comments, c.String())
	}
	scs := strings.Join(comments, ",")
	return fmt.Sprintf("Article(Id:%d,Content:'%s',Comments:[%s],Uid:%d)", a.Id, a.Content, scs, a.Uid)
}

type Comment struct {
	Id      int    `json:"id"`
	Content string `json:"content"`
	Uid     int    `json:"uid"`
}

func (c *Comment) String() string {
	return fmt.Sprintf("Comment(Id:%d,Content:'%s',Uid:%d)", c.Id, c.Content, c.Uid)
}

func StreamDecode(fileName string) Article {
	fopen, err := os.Open(fileName)
	if err != nil {
		panic(err)
	}
	defer fopen.Close()
	decoder := json.NewDecoder(fopen)
	art := Article{}
	err = decoder.Decode(&art)
	if err != nil {
		panic(err)
	}
	return art
}

func MemoryDecode(fileName string) Article {
	fopen, err := os.Open(fileName)
	if err != nil {
		panic(err)
	}
	defer fopen.Close()
	content, err := ioutil.ReadAll(fopen)
	if err != nil {
		panic(err)
	}
	art := Article{}
	err = json.Unmarshal(content, &art)
	if err != nil {
		panic(err)
	}
	return art
}

func main() {
}

需要对其进行性能测试,可以编写如下的测试用例:

package main

import "testing"

func BenchmarkStreamDecode(b *testing.B) {
	for i := 0; i < b.N; i++ {
		StreamDecode("art.json")
	}
}

func BenchmarkMemoryDecode(b *testing.B) {
	for i := 0; i < b.N; i++ {
		MemoryDecode("art.json")
	}
}

可以看到,性能测试的用例以BenchmarkXXX进行命名,且接收的是testing.B类型的指针。

内容并不复杂,使用for循环执行目标函数,循环次数由b.N这个变量决定。该变量由go test工具根据测试目标和平台情况自行决定具体数值,无法由用户直接指定。

测试执行结果:

go test -v -bench .
goos: windows
goarch: amd64
pkg: github.com/icexmoon/go-notebook/ch18/benchmark
cpu: Intel(R) Core(TM) i5-7200U CPU @ 2.50GHz
BenchmarkStreamDecode
BenchmarkStreamDecode-4             8784            131952 ns/op
BenchmarkMemoryDecode
BenchmarkMemoryDecode-4             8478            138802 ns/op
PASS
ok      github.com/icexmoon/go-notebook/ch18/benchmark  4.106s

执行基准测试需要使用-bench参数,并指定一个正则表达式来过滤当前目录下需要执行基准测试的文件,.表示执行所有测试代码。

两者执行结果相似,流处理的方式使用了131952纳秒,全部读入内存解析的方式使用了138802纳秒。

一般情况下测试文件中不仅包含基准测试用例,还会包含功能测试用例,比如:

...
func TestStreamDecode(t *testing.T) {
	art := StreamDecode("art.json")
	art2 := Article{
		Id:      1,
		Content: "this is a art's content.",
		Comments: []Comment{
			{Id: 1, Content: "first comment content.", Uid: 1},
			{Id: 2, Content: "second comment content.", Uid: 1},
			{Id: 3, Content: "third comment content.", Uid: 2},
		},
		Uid: 1,
	}
	if !reflect.DeepEqual(art, art2) {
		t.Error("decode result is not ok")
		t.Log(art.String())
		t.Log(art2.String())
	}
}

func TestMemoryDecode(t *testing.T) {
	art := MemoryDecode("art.json")
	art2 := Article{
		Id:      1,
		Content: "this is a art's content.",
		Comments: []Comment{
			{Id: 1, Content: "first comment content.", Uid: 1},
			{Id: 2, Content: "second comment content.", Uid: 1},
			{Id: 3, Content: "third comment content.", Uid: 2},
		},
		Uid: 1,
	}
	if !reflect.DeepEqual(art, art2) {
		t.Error("decode result is not ok")
	}
}

此时执行基准测试的同时会执行功能测试:

go test -v -bench .
=== RUN   TestStreamDecode
--- PASS: TestStreamDecode (0.00s)
=== RUN   TestMemoryDecode
--- PASS: TestMemoryDecode (0.00s)
goos: windows
goarch: amd64
pkg: github.com/icexmoon/go-notebook/ch18/benchmark2
cpu: Intel(R) Core(TM) i5-7200U CPU @ 2.50GHz
BenchmarkStreamDecode
BenchmarkStreamDecode-4             9204            127897 ns/op
BenchmarkMemoryDecode
BenchmarkMemoryDecode-4             8547            134553 ns/op
PASS
ok      github.com/icexmoon/go-notebook/ch18/benchmark2 3.614s

如果不希望一同执行功能测试,可以使用-run参数指定一个不存在的文件名,这样就不会执行功能测试:

go test -v -bench . -run x
goos: windows
goarch: amd64
pkg: github.com/icexmoon/go-notebook/ch18/benchmark2
cpu: Intel(R) Core(TM) i5-7200U CPU @ 2.50GHz
BenchmarkStreamDecode
BenchmarkStreamDecode-4             9080            129010 ns/op
BenchmarkMemoryDecode
BenchmarkMemoryDecode-4             9211            136069 ns/op
PASS
ok      github.com/icexmoon/go-notebook/ch18/benchmark2 3.246s

我们可以使用基准测试结果来对比两种采用不同方式实现的功能孰优孰劣。

Web测试

上边说的都是对普通应用进行的测试,对于Web应用,其核心是URL请求,所以测试方式有些不太一样。

这里使用Go语言编程笔记17:Web Service - 魔芋红茶’s blog (icexmoon.xyz)中构建的Web Service进行测试说明。

这里针对登录接口创建测试用例:

package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/icexmoon/go-notebook/ch18/web-test/api"
	"github.com/julienschmidt/httprouter"
)

func TestApiLogin(t *testing.T) {
	router := httprouter.New()
	router.POST("/api/login", api.ApiLogin)
	recorder := httptest.NewRecorder()
	reader := strings.NewReader(`
	{
		"data": {
			"name": "111",
			"password": "111"
		}
	}
	`)
	r, err := http.NewRequest("POST", "/api/login", reader)
	if err != nil {
		t.Fatal(err)
	}
	router.ServeHTTP(recorder, r)
	if recorder.Code != 200 {
		t.Fatal("http status is not 200")
	}
	data := struct {
		Data struct {
			Token string `json:"token"`
		} `json:"data"`
	}{}
	err = json.Unmarshal(recorder.Body.Bytes(), &data)
	if err != nil {
		t.Fatal(err)
	}
	if len(data.Data.Token) == 0 {
		t.Fatal("the token returned is empty")
	}
	t.Logf("the token returned is %s\n", data.Data.Token)
}

func TestLoginFail(t *testing.T) {
	router := httprouter.New()
	router.POST("/api/login", api.ApiLogin)
	recorder := httptest.NewRecorder()
	reader := strings.NewReader(`
	{
		"data": {
			"name": "111",
			"password": "222"
		}
	}
	`)
	r, err := http.NewRequest("POST", "/api/login", reader)
	if err != nil {
		t.Fatal(err)
	}
	router.ServeHTTP(recorder, r)
	if recorder.Code == 200 {
		t.Fatal("http status is 200")
	}
	rs := recorder.Result().Status
	t.Logf("the status message is %s\n", rs)
}

其中一个是正向测试用例,一个是反向测试用例。

执行情况如下:

go test -v
=== RUN   TestApiLogin
    main_test.go:46: the token returned is eyJpZCI6MSwiZXhwaXJlIjoiMjAyMi0wMS0wM1QxNzowMzozNi44NDQ1ODE2KzA4OjAwIiwic2NvZGUiOiI5MGU1Y2JkYmU2NDFiMTk0ZDI5YTY0NWZlNWUwOGNkZCJ9
--- PASS: TestApiLogin (0.04s)
=== RUN   TestLoginFail
    main_test.go:70: the status message is 500 Internal Server Error
--- PASS: TestLoginFail (0.00s)
PASS
ok      github.com/icexmoon/go-notebook/ch18/web-test   0.889s

可以看到在正常登录时,应用正确返回了访问令牌,密码错误时,返回的响应报文状态码是500。这和我们设计的接口是一致的。

这里并没有像正式代码中那样创建一个Server,并执行ListenAndServe方法对主机的端口监听。实际上这里仅创建了一个多路复用器,并绑定相应的处理器,然后直接调用多路复用器的ServeHttp方法来转发HTTP请求。并直接从转发时传入的httptest.ResponseRecorder结构获取结果,该结构和http.ResponseWriter是相似的。

httptest包是在testing基础上发展来的,提供一些用于Web测试的额外组件。

一般情形下的Web应用信息流转可以用下图表示:

image-20220102173234236

但对于测试用例来说,其实是这样:

image-20220102173655282

也就是说测试用例直接“生造”了一个http.Request结构,通过多路复用器转发给具体的处理器,然后通过Recorder结构获取处理结果,这个过程中并不存在真正的网络报文发送和接收。但Web编程的关键环节都可以得到充分测试,所以这样做是没有问题的。

TestMain

testing包允许用户给测试代码添加一个TestMain函数,通过这个函数可以在所有用例的执行前后执行一些额外处理:

...
func TestMain(m *testing.M) {
	setup()
	code := m.Run()
	tearDown()
	os.Exit(code)
}

func setup() {}

func tearDown() {}

m.Run的作用就是执行该代码文件所包含的全部测试用例。

我们可以利用这种特性来将用例中的一些重复性的准备工作集中在setUp函数中完成:

...
var router *httprouter.Router
var recorder *httptest.ResponseRecorder

func setup() {
	router = httprouter.New()
	recorder = httptest.NewRecorder()
	router.POST("/api/login", api.ApiLogin)
}
...

Gocheck

Gocheck是在testing包基础扩展而来的一个第三方测试框架,其提供更丰富的功能:

  • 以套件(suite)为单位对测试用例进行分组。
  • 可以为测试用例或套件设置夹具。
  • 支持断言。
  • 更多的报错辅助函数。
  • 兼容testing包。

安装Gocheck

go get gopkg.in/check.v1

这里使用之前简单的Add函数来构建测试用例:

package main

import (
	"testing"

	. "gopkg.in/check.v1"
)

type MainTestSuite struct{}

func init() {
	Suite(&MainTestSuite{})
}

func Test(t *testing.T) {
	TestingT(t)
}

func (s *MainTestSuite) TestAdd(c *C) {
	c.Check(Add(1, 5), Equals, 6)
	c.Check(Add(-1, -2), Equals, -3)
}

使用Gocheck需要经过以下几个步骤:

  1. 创建测试套件结构,习惯以XXXTestSuite命名。
  2. init方法中调用Suite函数绑定结构体到Gocheck。
  3. 用任意一个Test开头的函数调用TestingT()函数以启用Gocheck。这样做是为了用go test来调用Gocheck
  4. 用测试套件结构的方法来创建测试用例。

为了说明Web测试中如何使用Gocheck,这里使用Gocheck构建Web Service登录相关的测试用例:

package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/icexmoon/go-notebook/ch18/web-check/api"
	"github.com/julienschmidt/httprouter"
	. "gopkg.in/check.v1"
)

type LoginTestSuite struct {
	Recoder *httptest.ResponseRecorder
	Router  *httprouter.Router
}

func (s *LoginTestSuite) SetUpTest(c *C) {
	s.Router = httprouter.New()
	s.Recoder = httptest.NewRecorder()
	s.Router.POST("/api/login", api.ApiLogin)
}

func (s *LoginTestSuite) TestLogin(c *C) {
	reader := strings.NewReader(`
	{
		"data": {
			"name": "111",
			"password": "111"
		}
	}
	`)
	r, err := http.NewRequest("POST", "/api/login", reader)
	c.Check(err, Equals, nil)
	s.Router.ServeHTTP(s.Recoder, r)
	c.Check(s.Recoder.Code, Equals, 200)
	data := struct {
		Data struct {
			Token string `json:"token"`
		} `json:"data"`
	}{}
	err = json.Unmarshal(s.Recoder.Body.Bytes(), &data)
	c.Check(err, Equals, nil)
	c.Check(len(data.Data.Token) > 0, Equals, true)
}

func (s *LoginTestSuite) TestLoginFail(c *C) {
	reader := strings.NewReader(`
	{
		"data": {
			"name": "111",
			"password": "222"
		}
	}
	`)
	r, err := http.NewRequest("POST", "/api/login", reader)
	c.Check(err, Equals, nil)
	s.Router.ServeHTTP(s.Recoder, r)
	c.Check(s.Recoder.Code, Not(Equals), 200)
	// c.Log(s.Recoder.Result().Status)
}

func init() {
	Suite(&LoginTestSuite{})
}

func Test(t *testing.T) {
	TestingT(t)
}

测试套件的SetUpTest方法会在每个测试用例执行前进行调用。相应的,SetUpSuite方法会在整个测试套件执行前调用。

比较奇怪的是,这里只能使用SetUpTest而不能使用SetUpSuite,否则第二个测试用例的c.Check(s.Recoder.Code, Not(Equals), 200)检查会失败,原因我还不清楚。

执行结果:

go test '-check.vv'
START: main_test.go:26: LoginTestSuite.TestLogin
START: main_test.go:20: LoginTestSuite.SetUpTest
PASS: main_test.go:20: LoginTestSuite.SetUpTest 0.001s

PASS: main_test.go:26: LoginTestSuite.TestLogin 0.134s

START: main_test.go:49: LoginTestSuite.TestLoginFail
START: main_test.go:20: LoginTestSuite.SetUpTest
PASS: main_test.go:20: LoginTestSuite.SetUpTest 0.001s

PASS: main_test.go:49: LoginTestSuite.TestLoginFail     0.001s

OK: 2 passed
PASS
ok      github.com/icexmoon/go-notebook/ch18/web-check  2.803s

要观察详细输出需要添加-check.vv参数,但因为.在Windows命令行下会被认为是参数分隔符,所以需要用引号将整个参数包裹。

《Go Web编程》还介绍了测试框架Ginkgo,但我不打算继续写下去了,就这样吧。

本篇文章的所有代码见:

谢谢阅读。

参考资料

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值