当前位置: 首页 > news >正文

用python做的大型网站黑客收徒网站建设

用python做的大型网站,黑客收徒网站建设,信金在线制作网站,wordpress安全部署单元测试 基本用法 Go语言测试 常用reflect.DeepEqual()对slice进行比较 跳过某些测试用例 func TestTimeConsuming(t *testing.T) {if testing.Short() {t.Skip(short模式下会跳过该测试用例)}... }当执行go test -short时就不会执行上面的TestTimeConsuming测…单元测试 基本用法 Go语言测试 常用reflect.DeepEqual()对slice进行比较 跳过某些测试用例 func TestTimeConsuming(t *testing.T) {if testing.Short() {t.Skip(short模式下会跳过该测试用例)}... }当执行go test -short时就不会执行上面的TestTimeConsuming测试用例。 子测试常单元测试中需要多组测试数据保证测试的效果。Go1.7中新增了子测试支持在测试函数中使用t.Run执行一组测试用例这样就不需要为不同的测试数据定义多个测试函数了。 func TestXXX(t *testing.T){t.Run(case1, func(t *testing.T){...})t.Run(case2, func(t *testing.T){...})t.Run(case3, func(t *testing.T){...}) }表格驱动测试 介绍 表格驱动测试不是工具、包或其他任何东西它只是编写更清晰测试的一种方式和视角。表格驱动测试可以涵盖很多方面表格里的每一个条目都是一个完整的测试用例包含输入和预期结果有时还包含测试名称等附加信息以使测试输出易于阅读。使用表格驱动测试能够很方便的维护多个测试用例避免在编写单元测试时频繁的复制粘贴。表格驱动测试的步骤通常是定义一个测试用例表格然后遍历表格并使用t.Run对每个条目执行必要的测试。 var flagtests []struct {in stringout string }{{%a, [%a]},{%-a, [%-a]},{%a, [%a]},{%#a, [%#a]},{% a, [% a]},{%0a, [%0a]},{%1.2a, [%1.2a]},{%-1.2a, [%-1.2a]},{%1.2a, [%1.2a]},{%-1.2a, [%-1.2a]},{%-1.2abc, [%-1.2a]bc},{%-1.2abc, [%-1.2a]bc}, } func TestFlagParser(t *testing.T) {var flagprinter flagPrinterfor _, tt : range flagtests {t.Run(tt.in, func(t *testing.T) {s : Sprintf(tt.in, flagprinter)if s ! tt.out {t.Errorf(got %q, want %q, s, tt.out)}})} }var flagtests []struct {in stringout string }{{%a, [%a]},{%-a, [%-a]},{%a, [%a]},{%#a, [%#a]},{% a, [% a]},{%0a, [%0a]},{%1.2a, [%1.2a]},{%-1.2a, [%-1.2a]},{%1.2a, [%1.2a]},{%-1.2a, [%-1.2a]},{%-1.2abc, [%-1.2a]bc},{%-1.2abc, [%-1.2a]bc}, } func TestFlagParser(t *testing.T) {var flagprinter flagPrinterfor _, tt : range flagtests {t.Run(tt.in, func(t *testing.T) {s : Sprintf(tt.in, flagprinter)if s ! tt.out {t.Errorf(got %q, want %q, s, tt.out)}})} }通常表格是匿名结构体切片可以定义结构体或使用已经存在的结构进行结构体数组声明。name属性用来描述特定的测试用例。 并行测试 表格驱动测试中通常会定义比较多的测试用例而Go语言又天生支持并发所以很容易发挥自身并发优势将表格驱动测试并行化。 想要在单元测试过程中使用并行测试可以像下面的代码示例中那样通过添加t.Parallel()来实现。 func TestSplitAll(t *testing.T) {t.Parallel() // 将 TLog 标记为能够与其他测试并行运行// 定义测试表格// 这里使用匿名结构体定义了若干个测试用例// 并且为每个测试用例设置了一个名称tests : []struct {name stringinput stringsep stringwant []string}{{base case, a:b:c, :, []string{a, b, c}},{wrong sep, a:b:c, ,, []string{a:b:c}},{more sep, abcd, bc, []string{a, d}},{leading sep, 沙河有沙又有河, 沙, []string{, 河有, 又有河}},}// 遍历测试用例for _, tt : range tests {tt : tt // 注意这里重新声明tt变量避免多个goroutine中使用了相同的变量t.Run(tt.name, func(t *testing.T) { // 使用t.Run()执行子测试t.Parallel() // 将每个测试用例标记为能够彼此并行运行got : Split(tt.input, tt.sep)if !reflect.DeepEqual(got, tt.want) {t.Errorf(expected:%#v, got:%#v, tt.want, got)}})} }测试覆盖率 Go提供内置功能来检查你的代码覆盖率即使用go test -cover来查看测试覆盖率。 Go还提供了一个额外的-coverprofile参数用来将覆盖率相关的记录信息输出到一个文件。例如 go test -cover -coverprofilec.out上面的命令会将覆盖率相关的信息输出到当前文件夹下面的c.out文件中。 然后我们执行go tool cover -htmlc.out使用cover工具来处理生成的记录信息该命令会打开本地的浏览器窗口生成一个HTML报告。(用绿色标记的语句块表示被覆盖了而红色的表示没有被覆盖。) testify/asserttestify 是一个社区非常流行的Go单元测试工具包其中使用最多的功能就是它提供的断言工具——testify/assert或testify/require。 1.安装 go get github.com/stretchr/testifyTestSplit测试函数中就使用了reflect.DeepEqual来判断期望结果与实际结果是否一致。 t.Run(tt.name, func(t *testing.T) { // 使用t.Run()执行子测试got : Split(tt.input, tt.sep)if !reflect.DeepEqual(got, tt.want) {t.Errorf(expected:%#v, got:%#v, tt.want, got)} })使用testify/assert之后就能将上述判断过程简化如下 t.Run(tt.name, func(t *testing.T) { // 使用t.Run()执行子测试got : Split(tt.input, tt.sep)assert.Equal(t, got, tt.want) // 使用assert提供的断言函数 })有多个断言语句时还可以使用assert : assert.New(t)创建一个assert对象它拥有前面所有的断言方法只是不需要再传入Testing.T参数了。 func TestSomething(t *testing.T) {assert : assert.New(t)// assert equalityassert.Equal(123, 123, they should be equal)// assert inequalityassert.NotEqual(123, 456, they should not be equal)// assert for nil (good for errors)assert.Nil(object)// assert for not nil (good when you expect something)if assert.NotNil(object) {// now we know that object isnt nil, we are safe to make// further assertions without causing any errorsassert.Equal(Something, object.Value)}testify/require拥有testify/assert所有断言函数它们的唯一区别就是——testify/require遇到失败的用例会立即终止本次测试。 此外testify包还提供了mock、http等其他测试工具. testify官方文档 网络测试 在Web开发场景下的单元测试如果涉及到HTTP请求推荐使用Go标准库 net/http/httptest 进行测试能够显著提高测试效率。 gin框架为例演示如何为http server编写单元测试 假设我们的业务逻辑是搭建一个http server端对外提供HTTP服务。我们编写了一个helloHandler函数用来处理用户请求。 // gin.go package httptest_demoimport (fmtnet/httpgithub.com/gin-gonic/gin )// Param 请求参数 type Param struct {Name string json:name }// helloHandler /hello请求处理函数 func helloHandler(c *gin.Context) {var p Paramif err : c.ShouldBindJSON(p); err ! nil {c.JSON(http.StatusOK, gin.H{msg: we need a name,})return}c.JSON(http.StatusOK, gin.H{msg: fmt.Sprintf(hello %s, p.Name),}) }// SetupRouter 路由 func SetupRouter() *gin.Engine {router : gin.Default()router.POST(/hello, helloHandler)return router } 为helloHandler函数编写单元测试这种情况下我们就可以使用httptest这个工具mock一个HTTP请求和响应记录器让我们的server端接收并处理我们mock的HTTP请求同时使用响应记录器来记录server端返回的响应内容。 // gin_test.go package httptest_demoimport (encoding/jsonnet/httpnet/http/httpteststringstestinggithub.com/stretchr/testify/assert )func Test_helloHandler(t *testing.T) {// 定义两个测试用例tests : []struct {name stringparam stringexpect string}{{base case, {name: liwenzhou}, hello liwenzhou},{bad case, , we need a name},}r : SetupRouter()for _, tt : range tests {t.Run(tt.name, func(t *testing.T) {// mock一个HTTP请求req : httptest.NewRequest(POST, // 请求方法/hello, // 请求URLstrings.NewReader(tt.param), // 请求参数)// mock一个响应记录器w : httptest.NewRecorder()// 让server端处理mock请求并记录返回的响应内容r.ServeHTTP(w, req)// 校验状态码是否符合预期assert.Equal(t, http.StatusOK, w.Code)// 解析并检验响应内容是否复合预期var resp map[string]stringerr : json.Unmarshal([]byte(w.Body.String()), resp)assert.Nil(t, err)assert.Equal(t, tt.expect, resp[msg])})} }介绍了如何在HTTP Server服务类场景下为请求处理函数编写单元测试那么如果我们是在代码中请求外部API的场景比如通过API调用其他服务获取返回值又该怎么编写单元测试呢 例如我们有以下业务逻辑代码依赖外部APIhttp://your-api.com/post提供的数据。 // api.go// ReqParam API请求参数 type ReqParam struct {X int json:x }// Result API返回结果 type Result struct {Value int json:value }func GetResultByAPI(x, y int) int {p : ReqParam{X: x}b, _ : json.Marshal(p)// 调用其他服务的APIresp, err : http.Post(http://your-api.com/post,application/json,bytes.NewBuffer(b),)if err ! nil {return -1}body, _ : ioutil.ReadAll(resp.Body)var ret Resultif err : json.Unmarshal(body, ret); err ! nil {return -1}// 这里是对API返回的数据做一些逻辑处理return ret.Value y }在对类似上述这类业务代码编写单元测试的时候如果不想在测试过程中真正去发送请求或者依赖的外部接口还没有开发完成时我们可以在单元测试中对依赖的API进行mock. 推荐使用gock这个库 gock 安装 go get -u gopkg.in/h2non/gock.v1// api_test.go package gock_demoimport (testinggithub.com/stretchr/testify/assertgopkg.in/h2non/gock.v1 )func TestGetResultByAPI(t *testing.T) {defer gock.Off() // 测试执行后刷新挂起的mock// mock 请求外部api时传参x1返回100gock.New(http://your-api.com).Post(/post).MatchType(json).JSON(map[string]int{x: 1}).Reply(200).JSON(map[string]int{value: 100})// 调用我们的业务函数res : GetResultByAPI(1, 1)// 校验返回结果是否符合预期assert.Equal(t, res, 101)// mock 请求外部api时传参x2返回200gock.New(http://your-api.com).Post(/post).MatchType(json).JSON(map[string]int{x: 2}).Reply(200).JSON(map[string]int{value: 200})// 调用我们的业务函数res GetResultByAPI(2, 2)// 校验返回结果是否符合预期assert.Equal(t, res, 202)assert.True(t, gock.IsDone()) // 断言mock被触发 }Go单测 MySQL和Redis测试 1. go-sqlmock sqlmock是一个实现 sql/driver 的mock库。它不需要建立真正的数据库连接就可以在测试中模拟任何 sql 驱动程序的行为。使用它可以很方便的在编写单元测试的时候mock sql语句的执行结果。 安装go get github.com/DATA-DOG/go-sqlmock使用示例 用的是go-sqlmock官方文档中提供的基础示例代码;实现了一个recordStats函数用来记录用户浏览商品时产生的相关数据。具体实现的功能是在一个事务中进行以下两次SQL操作 - 在products表中将当前商品的浏览次数1 - 在product_viewers表中记录浏览当前商品的用户id // app.go package mainimport database/sql// recordStats 记录用户浏览产品信息 func recordStats(db *sql.DB, userID, productID int64) (err error) {// 开启事务// 操作products和product_viewers两张表tx, err : db.Begin()if err ! nil {return}defer func() {switch err {case nil:err tx.Commit()default:tx.Rollback()}}()// 更新products表if _, err tx.Exec(UPDATE products SET views views 1); err ! nil {return}// product_viewers表中插入一条数据if _, err tx.Exec(INSERT INTO product_viewers (user_id, product_id) VALUES (?, ?),userID, productID); err ! nil {return}return }func main() {// 注意测试的过程中并不需要真正的连接db, err : sql.Open(mysql, root/blog)if err ! nil {panic(err)}defer db.Close()// userID为1的用户浏览了productID为5的产品if err recordStats(db, 1 /*some user id*/, 5 /*some product id*/); err ! nil {panic(err)} }为代码中的recordStats函数编写单元测试但是又不想在测试过程中连接真实的数据库进行测试。这个时候我们就可以像下面示例代码中那样使用sqlmock工具去mock数据库操作。 package mainimport (fmttestinggithub.com/DATA-DOG/go-sqlmock )// TestShouldUpdateStats sql执行成功的测试用例 func TestShouldUpdateStats(t *testing.T) {// mock一个*sql.DB对象不需要连接真实的数据库db, mock, err : sqlmock.New()if err ! nil {t.Fatalf(an error %s was not expected when opening a stub database connection, err)}defer db.Close()// mock执行指定SQL语句时的返回结果mock.ExpectBegin()mock.ExpectExec(UPDATE products).WillReturnResult(sqlmock.NewResult(1, 1))mock.ExpectExec(INSERT INTO product_viewers).WithArgs(2, 3).WillReturnResult(sqlmock.NewResult(1, 1))mock.ExpectCommit()// 将mock的DB对象传入我们的函数中if err recordStats(db, 2, 3); err ! nil {t.Errorf(error was not expected while updating stats: %s, err)}// 确保期望的结果都满足if err : mock.ExpectationsWereMet(); err ! nil {t.Errorf(there were unfulfilled expectations: %s, err)} }// TestShouldRollbackStatUpdatesOnFailure sql执行失败回滚的测试用例 func TestShouldRollbackStatUpdatesOnFailure(t *testing.T) {db, mock, err : sqlmock.New()if err ! nil {t.Fatalf(an error %s was not expected when opening a stub database connection, err)}defer db.Close()mock.ExpectBegin()mock.ExpectExec(UPDATE products).WillReturnResult(sqlmock.NewResult(1, 1))mock.ExpectExec(INSERT INTO product_viewers).WithArgs(2, 3).WillReturnError(fmt.Errorf(some error))mock.ExpectRollback()// now we execute our methodif err recordStats(db, 2, 3); err nil {t.Errorf(was expecting an error, but there was none)}// we make sure that all expectations were metif err : mock.ExpectationsWereMet(); err ! nil {t.Errorf(there were unfulfilled expectations: %s, err)} } 上面的代码中定义了一个执行成功的测试用例和一个执行失败回滚的测试用例确保我们代码中的每个逻辑分支都能被测试到提高单元测试覆盖率的同时也保证了代码的健壮性。 在很多使用ORM工具的场景下也可以使用go-sqlmock库mock数据库操作进行测试。 2.miniredis 除了经常用到MySQL外Redis在日常开发中也会经常用到;miniredis是一个纯go实现的用于单元测试的redis server。它是一个简单易用的、基于内存的redis替代品它具有真正的TCP接口你可以把它当成是redis版本的net/http/httptest。 安装go get github.com/alicebob/miniredis/v2使用案例 这里以github.com/go-redis/redis库为例编写了一个包含若干Redis操作的DoSomethingWithRedis函数。 // redis_op.go package miniredis_demoimport (contextgithub.com/go-redis/redis/v8 // 注意导入版本stringstime )const (KeyValidWebsite app:valid:website:list )func DoSomethingWithRedis(rdb *redis.Client, key string) bool {// 这里可以是对redis操作的一些逻辑ctx : context.TODO()if !rdb.SIsMember(ctx, KeyValidWebsite, key).Val() {return false}val, err : rdb.Get(ctx, key).Result()if err ! nil {return false}if !strings.HasPrefix(val, https://) {val https:// val}// 设置 blog key 五秒过期if err : rdb.Set(ctx, blog, val, 5*time.Second).Err(); err ! nil {return false}return true }下面的代码是我使用miniredis库为DoSomethingWithRedis函数编写的单元测试代码其中miniredis不仅支持mock常用的Redis操作还提供了很多实用的帮助函数例如检查key的值是否与预期相等的s.CheckGet()和帮助检查key过期时间的s.FastForward()。 // redis_op_test.gopackage miniredis_demoimport (github.com/alicebob/miniredis/v2github.com/go-redis/redis/v8testingtime )func TestDoSomethingWithRedis(t *testing.T) {// mock一个redis servers, err : miniredis.Run()if err ! nil {panic(err)}defer s.Close()// 准备数据s.Set(q1mi, liwenzhou.com)s.SAdd(KeyValidWebsite, q1mi)// 连接mock的redis serverrdb : redis.NewClient(redis.Options{Addr: s.Addr(), // mock redis server的地址})// 调用函数ok : DoSomethingWithRedis(rdb, q1mi)if !ok {t.Fatal()}// 可以手动检查redis中的值是否复合预期if got, err : s.Get(blog); err ! nil || got ! https://liwenzhou.com {t.Fatalf(blog has the wrong value)}// 也可以使用帮助工具检查s.CheckGet(t, blog, https://liwenzhou.com)// 过期检查s.FastForward(5 * time.Second) // 快进5秒if s.Exists(blog) {t.Fatal(blog should not have existed anymore)} }除了使用miniredis搭建本地redis server这种方法外还可以使用各种打桩工具对具体方法进行打桩。在编写单元测试时具体使用哪种mock方式还是要根据实际情况来决定。 mock接口测试 如何在单元测试中使用gomock和gostub工具mock接口和打桩 1.gomock gomock是Go官方提供的测试框架它在内置的testing包或其他环境中都能够很方便的使用。我们使用它对代码中的那些接口类型进行mock方便编写单元测试。 安装mockgen go install github.com/golang/mock/mockgenv1.6.0 (2023 6月停止维护) go get github.com/uber-go/mock (uber提供的代替)运行mockgen mockgen 有两种操作模式源码source模式和反射reflect模式。 源码模式 源码模式根据源文件mock接口。它是通过使用-source标志启用。在这个模式下可能有用的其他标志是 -imports 和 -aux_files。 example:mockgen -sourcefoo.go [other options]反射模式 反射模式通过构建使用反射来理解接口的程序来mock接口。它是通过传递两个非标志参数来启用的一个导入路径和一个逗号分隔的符号列表。可以使用 ”.”引用当前路径的包。 example:mockgen database/sql/driver Conn,Driver# Convenient for go:generate.mockgen . Conn,Driverflags mockgen 命令用来为给定一个包含要mock的接口的Go源文件生成mock类源代码。它支持以下标志 -source包含要mock的接口的文件。-destination生成的源代码写入的文件。如果不设置此项代码将打印到标准输出。-package用于生成的模拟类源代码的包名。如果不设置此项包名默认在原包名前添加mock_前缀。-imports在生成的源代码中使用的显式导入列表。值为foobar/baz形式的逗号分隔的元素列表其中bar/baz是要导入的包foo是要在生成的源代码中用于包的标识符。-aux_files需要参考以解决的附加文件列表例如在不同文件中定义的嵌入式接口。指定的值应为foobar/baz.go形式的以逗号分隔的元素列表其中bar/baz.go是源文件foo是-source文件使用的文件的包名。-build_flags仅反射模式一字不差地传递标志给go build-mock_names生成的模拟的自定义名称列表。这被指定为一个逗号分隔的元素列表形式为Repository MockSensorRepository,EndpointMockSensorEndpoint其中Repository是接口名称mockSensorrepository是所需的mock名称(mock工厂方法和mock记录器将以mock命名)。如果其中一个接口没有指定自定义名称则将使用默认命名约定。-self_package生成的代码的完整包导入路径。使用此flag的目的是通过尝试包含自己的包来防止生成代码中的循环导入。如果mock的包被设置为它的一个输入(通常是主输入)并且输出是stdio那么mockgen就无法检测到最终的输出包这种情况就会发生。设置此标志将告诉mockgen 排除哪个导入-copyright_file用于将版权标头添加到生成的源代码中的版权文件-debug_parser仅打印解析器结果-exec_only反射模式 如果设置则执行此反射程序-prog_only反射模式只生成反射程序将其写入标准输出并退出。-write_package_comment如果为true则写入包文档注释 (godoc)。默认为true 构建mock 以日常开发中经常用到的数据库操作为例讲解一下如何使用gomock来mock接口的单元测试。 假设有查询MySQL数据库的业务代码如下其中DB是一个自定义的接口类型 // db.go// DB 数据接口 type DB interface {Get(key string)(int, error)Add(key string, value int) error }// GetFromDB 根据key从DB查询数据的函数 func GetFromDB(db DB, key string) int {if v, err : db.Get(key);err nil{return v}return -1 }我们现在要为GetFromDB函数编写单元测试代码可是我们又不能在单元测试过程中连接真实的数据库这个时候就需要mock DB这个接口来方便进行单元测试。 使用上面提到的 mockgen 工具来为生成相应的mock代码。通过执行下面的命令我们就能在当前项目下生成一个mocks文件夹里面存放了一个db_mock.go文件。 mockgen -sourcedb.go -destinationmocks/db_mock.go -packagemocksdb_mock.go文件中的内容就是mock相关接口的代码了。 我们通常不需要编辑它只需要在单元测试中按照规定的方式使用它们就可以了。例如我们编写TestGetFromDB 函数如下 // db_test.gofunc TestGetFromDB(t *testing.T) {// 创建gomock控制器用来记录后续的操作信息ctrl : gomock.NewController(t)// 断言期望的方法都被执行// Go1.14的单测中不再需要手动调用该方法defer ctrl.Finish()// 调用mockgen生成代码中的NewMockDB方法// 这里mocks是我们生成代码时指定的package名称m : mocks.NewMockDB(ctrl)// 打桩stub// 当传入Get函数的参数为liwenzhou.com时返回1和nilm.EXPECT().Get(gomock.Eq(liwenzhou.com)). // 参数Return(1, nil). // 返回值Times(1) // 调用次数// 调用GetFromDB函数时传入上面的mock对象mif v : GetFromDB(m, liwenzhou.com); v ! 1 {t.Fatal()} }打桩stub 软件测试中的打桩是指用一些代码桩stub代替目标代码通常用来屏蔽或补齐业务逻辑中的关键代码方便进行单元测试。 屏蔽不想在单元测试用引入数据库连接等重资源 补齐依赖的上下游函数或方法还未实现上面代码中就用到了打桩当传入Get函数的参数为liwenzhou.com时就返回1, nil的返回值。 gomock支持针对参数、返回值、调用次数、调用顺序等进行打桩操作。 参数 参数相关的用法有 gomock.Eq(value)表示一个等价于value值的参数gomock.Not(value)表示一个非value值的参数gomock.Any()表示任意值的参数gomock.Nil()表示空值的参数SetArg(n, value)设置第n从0开始个参数的值通常用于指针参数或切片 具体示例如下 m.EXPECT().Get(gomock.Not(“q1mi”)).Return(10, nil) m.EXPECT().Get(gomock.Any()).Return(20, nil) m.EXPECT().Get(gomock.Nil()).Return(-1, nil) 单独说一下SetArg的适用场景假设你有一个需要mock的接口如下 type YourInterface {SetValue(arg *int) }此时打桩的时候就可以使用SetArg来修改参数的值。 m.EXPECT().SetValue(gomock.Any()).SetArg(0, 7) // 将SetValue的第一个参数设置为7 }返回值 gomock中跟返回值相关的用法有以下几个 Return()返回指定值Do(func)执行操作忽略返回值DoAndReturn(func)执行并返回指定值 例如 m.EXPECT().Get(gomock.Any()).Return(20, nil) m.EXPECT().Get(gomock.Any()).Do(func(key string) {t.Logf(input key is %v\n, key) }) m.EXPECT().Get(gomock.Any()).DoAndReturn(func(key string)(int, error) {t.Logf(input key is %v\n, key)return 10, nil })调用次数 使用gomock工具mock的方法都会有期望被调用的次数默认每个mock方法只允许被调用一次 m.EXPECT().Get(gomock.Eq(liwenzhou.com)). // 参数Return(1, nil). // 返回值Times(1) // 设置Get方法期望被调用次数为1// 调用GetFromDB函数时传入上面的mock对象m if v : GetFromDB(m, liwenzhou.com); v ! 1 {t.Fatal() } // 再次调用上方mock的Get方法时不满足调用次数为1的期望 if v : GetFromDB(m, liwenzhou.com); v ! 1 {t.Fatal() }gomock为我们提供了如下方法设置期望被调用的次数。 Times() 断言 Mock 方法被调用的次数。MaxTimes() 最大次数。MinTimes() 最小次数。AnyTimes() 任意次数包括 0 次。 调用顺序 gomock还支持使用InOrder方法指定mock方法的调用顺序 // 指定顺序 gomock.InOrder(m.EXPECT().Get(1),m.EXPECT().Get(2),m.EXPECT().Get(3), )// 按顺序调用 GetFromDB(m, 1) GetFromDB(m, 2) GetFromDB(m, 3)此外知名的Go测试库testify目前也提供类似的mock工具—testify/mock和mockery。 GoStub GoStub也是一个单元测试中的打桩工具它支持为全局变量、函数等打桩。 不过我个人感觉它为函数打桩不太方便我一般在单元测试中只会使用它来为全局变量打桩。 安装 go get github.com/prashantv/gostub 使用示例 官方文档中的示例代码演示如何使用gostub为全局变量打桩。 // app.go var (configFile config.jsonmaxNum 10 )func GetConfig() ([]byte, error) {return ioutil.ReadFile(configFile) }func ShowNumber()int{// ...return maxNum }上面代码中定义了两个全局变量和两个使用全局变量的函数我们现在为这两个函数编写单元测试。 // app_test.goimport (github.com/prashantv/gostubtesting )func TestGetConfig(t *testing.T) {// 为全局变量configFile打桩给它赋值一个指定文件stubs : gostub.Stub(configFile, ./test.toml)defer stubs.Reset() // 测试结束后重置// 下面是测试的代码data, err : GetConfig()if err ! nil {t.Fatal()}// 返回的data的内容就是上面/tmp/test.config文件的内容t.Logf(data:%s\n, data) }func TestShowNumber(t *testing.T) {stubs : gostub.Stub(maxNum, 20)defer stubs.Reset()// 下面是一些测试的代码res : ShowNumber()if res ! 20 {t.Fatal()} }从上面的示例中我们可以看到在单元测试中使用gostub可以很方便的对全局变量进行打桩将其mock成我们预期的值从而进行测试。 使用monkey打桩 一个更强大的打桩工具——monkey它支持为任意函数及方法进行打桩。 介绍 monkey是一个Go单元测试中十分常用的打桩工具它在运行时通过汇编语言重写可执行文件将目标函数或方法的实现跳转到桩实现其原理类似于热补丁。 monkey库很强大但是使用时需注意以下事项 monkey不支持内联函数在测试的时候需要通过命令行参数-gcflags-l关闭Go语言的内联优化。monkey 不是线程安全的所以不要把它用到并发的单元测试中。 安装 go get bou.ke/monkey 使用示例 假设你们公司中台提供了一个用户中心的库varys使用这个库可以很方便的根据uid获取用户相关信息。但是当你编写代码的时候这个库还没实现或者这个库要经过内网请求但你现在没这能力这个时候要为MyFunc编写单元测试就需要做一些mock工作。 // func.gofunc MyFunc(uid int64)string{u, err : varys.GetInfoByUID(uid)if err ! nil {return welcome}// 这里是一些逻辑代码...return fmt.Sprintf(hello %s\n, u.Name) }使用monkey库对varys.GetInfoByUID进行打桩。 // func_test.gofunc TestMyFunc(t *testing.T) {// 对 varys.GetInfoByUID 进行打桩// 无论传入的uid是多少都返回 varys.UserInfo{Name: liwenzhou}, nilmonkey.Patch(varys.GetInfoByUID, func(int64)(*varys.UserInfo, error) {return varys.UserInfo{Name: liwenzhou}, nil})ret : MyFunc(123)if !strings.Contains(ret, liwenzhou){t.Fatal()} }执行单元测试 注意这里为防止内联优化添加了-gcflags-l参数。 go test -run TestMyFunc -v -gcflags-l 除了对函数进行mock外monkey也支持对方法进行mock。 // method.gotype User struct {Name stringBirthday string }// CalcAge 计算用户年龄 func (u *User) CalcAge() int {t, err : time.Parse(2006-01-02, u.Birthday)if err ! nil {return -1}return int(time.Now().Sub(t).Hours()/24.0)/365 }// GetInfo 获取用户相关信息 func (u *User) GetInfo()string{age : u.CalcAge()if age 0 {return fmt.Sprintf(%s很神秘我们还不了解ta。, u.Name)}return fmt.Sprintf(%s今年%d岁了ta是我们的朋友。, u.Name, age) }为GetInfo编写单元测试的时候CalcAge方法的功能还未完成这个时候我们可以使用monkey进行打桩。 // method_test.gofunc TestUser_GetInfo(t *testing.T) {var u User{Name: q1mi,Birthday: 1990-12-20,}// 为对象方法打桩monkey.PatchInstanceMethod(reflect.TypeOf(u), CalcAge, func(*User)int {return 18})ret : u.GetInfo() // 内部调用u.CalcAge方法时会返回18if !strings.Contains(ret, 朋友){t.Fatal()} }monkey基本上能满足我们在单元测试中打桩的任何需求。 社区中还有一个参考monkey库实现的gomonkey库原理和使用过程基本相似。除此之外社区里还有一些其他打桩工具如GoStub go-convey 1.安装 需要使用goconvey的Web UI程序请执行下面的命令安装可执行程序 go install github.com/smartystreets/goconveylatest 在项目中引入依赖 go get github.com/smartystreets/goconvey 官网goconvey.co 2.两个关键方法Convey()、So() 例如Convey(备注t *testing.T,func{}) So(testFunc(),ShouldEqual,xx) convey() 、so()方法可以嵌套使用 3.运行测试3.1 go原生测试方法go test go test -v3.2 安装测试覆盖率工具go get code.google.com/p/go.tools/cmd/cover使用GoConvey提供的自动化编译测试goconvey进入浏览器访问地址localhost:8080/查看结果**使用示例** 用goconvey来为最开始的基础示例中的Split函数编写单元测试。Split函数如下 go // split.gofunc Split(s, sep string) (result []string) {result make([]string, 0, strings.Count(s, sep)1)i : strings.Index(s, sep)for i -1 {result append(result, s[:i])s s[ilen(sep):]i strings.Index(s, sep)}result append(result, s)return }单元测试文件内容如下 import (testingc github.com/smartystreets/goconvey/convey // 别名导入 )func TestSplit(t *testing.T) {c.Convey(基础用例, t, func() {var (s a:b:csep :expect []string{a, b, c})got : Split(s, sep)c.So(got, c.ShouldResemble, expect) // 断言})c.Convey(不包含分隔符用例, t, func() {var (s a:b:csep |expect []string{a:b:c})got : Split(s, sep)c.So(got, c.ShouldResemble, expect) // 断言}) }goconvey还支持在单元测试中根据需要嵌套调用比如 func TestSplit(t *testing.T) {// ...// 只需要在顶层的Convey调用时传入tc.Convey(分隔符在开头或结尾用例, t, func() {tt : []struct {name strings stringsep stringexpect []string}{{分隔符在开头, *1*2*3, *, []string{, 1, 2, 3}},{分隔符在结尾, 123, , []string{1, 2, 3, }},}for _, tc : range tt {c.Convey(tc.name, func() { // 嵌套调用Conveygot : Split(tc.s, tc.sep)c.So(got, c.ShouldResemble, tc.expect)})}}) }这样输出最终的测试结果时也会分层级显示。 断言方法 GoConvey为我们提供了很多种类断言方法在So()函数中使用。 一般相等类 So(thing1, ShouldEqual, thing2) So(thing1, ShouldNotEqual, thing2) So(thing1, ShouldResemble, thing2) // 用于数组、切片、map和结构体相等 So(thing1, ShouldNotResemble, thing2) So(thing1, ShouldPointTo, thing2) So(thing1, ShouldNotPointTo, thing2) So(thing1, ShouldBeNil) So(thing1, ShouldNotBeNil) So(thing1, ShouldBeTrue) So(thing1, ShouldBeFalse) So(thing1, ShouldBeZeroValue) 数字数量比较类 So(1, ShouldBeGreaterThan, 0) So(1, ShouldBeGreaterThanOrEqualTo, 0) So(1, ShouldBeLessThan, 2) So(1, ShouldBeLessThanOrEqualTo, 2) So(1.1,ShouldBeBetween, .8, 1.2) So(1.1, ShouldNotBeBetween, 2, 3) So(1.1,ShouldBeBetweenOrEqual, .9, 1.1) So(1.1, ShouldNotBeBetweenOrEqual,1000, 2000) So(1.0, ShouldAlmostEqual, 0.99999999, .0001) //tolerance is optional; default 0.0000000001 So(1.0,ShouldNotAlmostEqual, 0.9, .0001) 包含类 So([]int{2, 4, 6}, ShouldContain, 4) So([]int{2, 4, 6},ShouldNotContain, 5) So(4, ShouldBeIn, …[]int{2, 4, 6}) So(4,ShouldNotBeIn, …[]int{1, 3, 5}) So([]int{}, ShouldBeEmpty) So([]int{1}, ShouldNotBeEmpty) So(map[string]string{“a”: “b”}, ShouldContainKey, “a”) So(map[string]string{“a”: “b”},ShouldNotContainKey, “b”) So(map[string]string{“a”: “b”},ShouldNotBeEmpty) So(map[string]string{}, ShouldBeEmpty) So(map[string]string{“a”: “b”}, ShouldHaveLength, 1) // supports map,slice, chan, and string 字符串类 So(“asdf”, ShouldStartWith, “as”) So(“asdf”, ShouldNotStartWith, “df”) So(“asdf”, ShouldEndWith, “df”) So(“asdf”, ShouldNotEndWith, “df”) So(“asdf”, ShouldContainSubstring, “稍等一下”) // optional ‘expected occurences’ arguments? So(“asdf”, ShouldNotContainSubstring, “er”) So(“adsf”, ShouldBeBlank) So(“asdf”, ShouldNotBeBlank) panic类 So(func(), ShouldPanic) So(func(), ShouldNotPanic) So(func(),ShouldPanicWith, “”) // or errors.New(“something”) So(func(),ShouldNotPanicWith, “”) // or errors.New(“something”) 类型检查类 So(1, ShouldHaveSameTypeAs, 0) So(1, ShouldNotHaveSameTypeAs, “asdf”) 时间和时间间隔类 So(time.Now(), ShouldHappenBefore, time.Now()) So(time.Now(),ShouldHappenOnOrBefore, time.Now()) So(time.Now(), ShouldHappenAfter,time.Now()) So(time.Now(), ShouldHappenOnOrAfter, time.Now()) So(time.Now(), ShouldHappenBetween, time.Now(), time.Now()) So(time.Now(), ShouldHappenOnOrBetween, time.Now(), time.Now()) So(time.Now(), ShouldNotHappenOnOrBetween, time.Now(), time.Now()) So(time.Now(), ShouldHappenWithin, duration, time.Now()) So(time.Now(), ShouldNotHappenWithin, duration, time.Now()) 自定义断言方法 如果上面列出来的断言方法都不能满足你的需要那么你还可以按照下面的格式自定义一个断言方法。 注意中的内容是你需要按照实际需求替换的内容。 func shoulddo-something(actual interface{}, expected ...interface{}) string {if some-important-condition-is-met(actual, expected) {return // 返回空字符串表示断言通过}return 一些描述性消息详细说明断言失败的原因... }编写可测试的代码 编写可测试的代码可能比编写单元测试本身更加重要 剔除干扰因素 假设我们现在有一个根据时间判断报警信息发送速率的模块白天工作时间允许大量发送报警信息而晚上则减小发送速率凌晨不允许发送报警短信。 // judgeRate 报警速率决策函数 func judgeRate() int {now : time.Now()switch hour : now.Hour(); {case hour 8 hour 20:return 10case hour 20 hour 23:return 1}return -1 }这个函数内部使用了time.Now()来获取系统的当前时间作为判断的依据看起来很合理。 但是这个函数现在隐式包含了一个不确定因素——时间。在不同的时刻我们调用这个函数都可能会得到不一样的结果。想象一下我们该如何为这个函数编写单元测试呢 如果不修改系统时间那么我们就无法为这个函数编写单元测试这个函数成了“不可测试的代码”当然可以使用打桩工具对time.Now进行打桩但那不是本文要强调的重点。 接下来我们该如何改造它 // judgeRateByTime 报警速率决策函数 func judgeRateByTime(now time.Time) int {switch hour : now.Hour(); {case hour 8 hour 20:return 10case hour 20 hour 23:return 1}return -1 }这样我们不仅解决了函数与系统时间的紧耦合而且还扩展了函数的功能现在我们可以根据需要获取任意时刻的速率值。为改造后的judgeRateByTime编写单元测试也更方便了。 func Test_judgeRateByTime(t *testing.T) {tests : []struct {name stringarg time.Timewant int}{{name: 工作时间,arg: time.Date(2022, 2, 18, 11, 22, 33, 0, time.UTC),want: 10,},{name: 晚上,arg: time.Date(2022, 2, 18, 22, 22, 33, 0, time.UTC),want: 1,},{name: 凌晨,arg: time.Date(2022, 2, 18, 2, 22, 33, 0, time.UTC),want: -1,},}for _, tt : range tests {t.Run(tt.name, func(t *testing.T) {if got : judgeRateByTime(tt.arg); got ! tt.want {t.Errorf(judgeRateByTime() %v, want %v, got, tt.want)}})} }接口抽象进行解耦 同样是函数中隐式依赖的问题假设我们实现了一个获取店铺客单价的需求它完成的功能就像下面的示例函数。 // GetAveragePricePerStore 每家店的人均价 func GetAveragePricePerStore(storeName string) (int64, error) {res, err : http.Get(https://liwenzhou.com/api/orders?storeName storeName)if err ! nil {return 0, err}defer res.Body.Close()var orders []Orderif err : json.NewDecoder(res.Body).Decode(orders); err ! nil {return 0, err}if len(orders) 0 {return 0, nil}var (p int64n int64)for _, order : range orders {p order.Pricen order.Num}return p / n, nil }之前的章节中我们介绍了如何为上面的代码编写单元测试但是我们如何避免每次单元测试时都发起真实的HTTP请求呢亦或者后续我们改变了获取数据的方式直接读取缓存或改为RPC调用这个函数该怎么兼容呢 我们将函数中获取数据的部分抽象为接口类型来优化我们的程序使其支持模块化的数据源配置 // OrderInfoGetter 订单信息提供者 type OrderInfoGetter interface {GetOrders(string) ([]Order, error) }然后定义一个API类型它拥有一个通过HTTP请求获取订单数据的GetOrders方法正好实现OrderInfoGetter接口。 / HttpApi HTTP API类型 type HttpApi struct{}// GetOrders 通过HTTP请求获取订单数据的方法 func (a HttpApi) GetOrders(storeName string) ([]Order, error) {res, err : http.Get(https://liwenzhou.com/api/orders?storeName storeName)if err ! nil {return nil, err}defer res.Body.Close()var orders []Orderif err : json.NewDecoder(res.Body).Decode(orders); err ! nil {return nil, err}return orders, nil }将原来的 GetAveragePricePerStore 函数修改为以下实现。 // GetAveragePricePerStore 每家店的人均价 func GetAveragePricePerStore(getter OrderInfoGetter, storeName string) (int64, error) {orders, err : getter.GetOrders(storeName)if err ! nil {return 0, err}if len(orders) 0 {return 0, nil}var (p int64n int64)for _, order : range orders {p order.Pricen order.Num}return p / n, nil }经过这番改动之后我们的代码就能很容易地写出单元测试代码。例如对于不方便直接请求的HTTP API, 我们就可以进行 mock 测试。 // Mock 一个mock类型 type Mock struct{}// GetOrders mock获取订单数据的方法 func (m Mock) GetOrders(string) ([]Order, error) {return []Order{{Price: 20300,Num: 2,},{Price: 642,Num: 5,},}, nil }func TestGetAveragePricePerStore(t *testing.T) {type args struct {getter OrderInfoGetterstoreName string}tests : []struct {name stringargs argswant int64wantErr bool}{{name: mock test,args: args{getter: Mock{},storeName: mock,},want: 12062,wantErr: false,},}for _, tt : range tests {t.Run(tt.name, func(t *testing.T) {got, err : GetAveragePricePerStore(tt.args.getter, tt.args.storeName)if (err ! nil) ! tt.wantErr {t.Errorf(GetAveragePricePerStore() error %v, wantErr %v, err, tt.wantErr)return}if got ! tt.want {t.Errorf(GetAveragePricePerStore() got %v, want %v, got, tt.want)}})} }依赖注入代替隐式依赖 在应用程序中使用全局变量的方式引入日志库或数据库连接实例等。 package mainimport (github.com/sirupsen/logrus )var log logrus.New()type App struct{}func (a *App) Start() {log.Info(app start ...) }func (a *app) Start() {a.Logger.Info(app start ...)// ... }func main() {app : App{}app.Start() }上面的代码中 App 中通过引用全局变量的方式将依赖项硬编码到代码中这种情况下我们在编写单元测试时如何 mock log 变量呢 此外这样的代码还存在一个更严重的问题——它与具体的日志库程序强耦合。当我们后续因为某些原因需要更换另一个日志库时我们该如何修改代码呢 我们应该将依赖项解耦出来并且将依赖注入到我们的 App 实例中而不是在其内部隐式调用全局变量。 type App struct {Logger }func (a *App) Start() {a.Logger.Info(app start ...)// ... }// NewApp 构造函数将依赖项注入 func NewApp(lg Logger) *App {return App{Logger: lg, // 使用传入的依赖项完成初始化} }上面的代码就很容易 mock log实例完成单元测试。 依赖注入就是指在创建组件Go 中的 struct的时候接收它的依赖项而不是它的初始化代码中引用外部或自行创建依赖项。 // Config 配置项结构体 type Config struct {// ... }// LoadConfFromFile 从配置文件中加载配置 func LoadConfFromFile(filename string) *Config {return Config{} }// Server server 程序 type Server struct {Config *Config }// NewServer Server 构造函数 func NewServer() *Server {return Server{// 隐式创建依赖项Config: LoadConfFromFile(./config.toml),} }上面的代码片段中就通过在构造函数中隐式创建依赖项这样的代码强耦合、不易扩展也不容易编写单元测试。我们完全可以通过使用依赖注入的方式将构造函数中的依赖作为参数传递给构造函数。 // NewServer Server 构造函数 func NewServer(conf *Config) *Server {return Server{// 隐式创建依赖项Config: conf,} }不要隐式引用外部依赖全局变量、隐式输入等而是通过依赖注入的方式引入依赖。经过这样的修改之后构造函数NewServer 的依赖项就很清晰同时也方便我们编写 mock 测试代码。 使用依赖注入的方式能够让我们的代码看起来更清晰但是过多的构造函数也会让主函数的代码迅速膨胀好在Go 语言提供了一些依赖注入工具例如 wire 可以帮助我们更好的管理依赖注入的代码。 SOLID原则 最后我们补充一个程序设计的SOLID原则我们在程序设计时践行以下几个原则会帮助我们写出可测试的代码。 首字母指代概念S单一职责原则每个类都应该只有一个职责O开闭原则一个软件实体如类、模块和函数应该对扩展开放对修改关闭L里式替换原则认为“程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换的”的概念I-接口隔离原则许多特定于客户端的接口优于一个通用接口D依赖反转原则应该依赖抽象而不是某个具体示例
文章转载自:
http://www.morning.china-cj.com.gov.cn.china-cj.com
http://www.morning.bfgpn.cn.gov.cn.bfgpn.cn
http://www.morning.bxgpy.cn.gov.cn.bxgpy.cn
http://www.morning.zkdmk.cn.gov.cn.zkdmk.cn
http://www.morning.ljxps.cn.gov.cn.ljxps.cn
http://www.morning.nssjy.cn.gov.cn.nssjy.cn
http://www.morning.llxns.cn.gov.cn.llxns.cn
http://www.morning.mszwg.cn.gov.cn.mszwg.cn
http://www.morning.nyzmm.cn.gov.cn.nyzmm.cn
http://www.morning.bfmq.cn.gov.cn.bfmq.cn
http://www.morning.fbjnr.cn.gov.cn.fbjnr.cn
http://www.morning.wanjia-sd.com.gov.cn.wanjia-sd.com
http://www.morning.ztrht.cn.gov.cn.ztrht.cn
http://www.morning.xkgyh.cn.gov.cn.xkgyh.cn
http://www.morning.wkknm.cn.gov.cn.wkknm.cn
http://www.morning.rfhm.cn.gov.cn.rfhm.cn
http://www.morning.jrqbr.cn.gov.cn.jrqbr.cn
http://www.morning.bbjw.cn.gov.cn.bbjw.cn
http://www.morning.bljcb.cn.gov.cn.bljcb.cn
http://www.morning.rzdpd.cn.gov.cn.rzdpd.cn
http://www.morning.gcthj.cn.gov.cn.gcthj.cn
http://www.morning.rwmp.cn.gov.cn.rwmp.cn
http://www.morning.wqbfd.cn.gov.cn.wqbfd.cn
http://www.morning.dpfr.cn.gov.cn.dpfr.cn
http://www.morning.tzpqc.cn.gov.cn.tzpqc.cn
http://www.morning.lgrkr.cn.gov.cn.lgrkr.cn
http://www.morning.dnls.cn.gov.cn.dnls.cn
http://www.morning.fnzbx.cn.gov.cn.fnzbx.cn
http://www.morning.dbqg.cn.gov.cn.dbqg.cn
http://www.morning.wgcng.cn.gov.cn.wgcng.cn
http://www.morning.wmpw.cn.gov.cn.wmpw.cn
http://www.morning.mzmqg.cn.gov.cn.mzmqg.cn
http://www.morning.prgnp.cn.gov.cn.prgnp.cn
http://www.morning.hknk.cn.gov.cn.hknk.cn
http://www.morning.pwggd.cn.gov.cn.pwggd.cn
http://www.morning.qlrtd.cn.gov.cn.qlrtd.cn
http://www.morning.sryhp.cn.gov.cn.sryhp.cn
http://www.morning.wfjrl.cn.gov.cn.wfjrl.cn
http://www.morning.kbdjn.cn.gov.cn.kbdjn.cn
http://www.morning.tjwlp.cn.gov.cn.tjwlp.cn
http://www.morning.xqspn.cn.gov.cn.xqspn.cn
http://www.morning.dphmj.cn.gov.cn.dphmj.cn
http://www.morning.xfxqj.cn.gov.cn.xfxqj.cn
http://www.morning.dwzwm.cn.gov.cn.dwzwm.cn
http://www.morning.qkqhr.cn.gov.cn.qkqhr.cn
http://www.morning.qykxj.cn.gov.cn.qykxj.cn
http://www.morning.ynlbj.cn.gov.cn.ynlbj.cn
http://www.morning.hrhwn.cn.gov.cn.hrhwn.cn
http://www.morning.hxsdh.cn.gov.cn.hxsdh.cn
http://www.morning.fjfjm.cn.gov.cn.fjfjm.cn
http://www.morning.rxlk.cn.gov.cn.rxlk.cn
http://www.morning.ntdzjx.com.gov.cn.ntdzjx.com
http://www.morning.rqnhf.cn.gov.cn.rqnhf.cn
http://www.morning.hrkth.cn.gov.cn.hrkth.cn
http://www.morning.kstgt.cn.gov.cn.kstgt.cn
http://www.morning.mdjzydr.com.gov.cn.mdjzydr.com
http://www.morning.hpxxq.cn.gov.cn.hpxxq.cn
http://www.morning.tzmjc.cn.gov.cn.tzmjc.cn
http://www.morning.wanjia-sd.com.gov.cn.wanjia-sd.com
http://www.morning.hsrch.cn.gov.cn.hsrch.cn
http://www.morning.rngyq.cn.gov.cn.rngyq.cn
http://www.morning.zymgs.cn.gov.cn.zymgs.cn
http://www.morning.nnhfz.cn.gov.cn.nnhfz.cn
http://www.morning.mgbsp.cn.gov.cn.mgbsp.cn
http://www.morning.gwdkg.cn.gov.cn.gwdkg.cn
http://www.morning.zypnt.cn.gov.cn.zypnt.cn
http://www.morning.pgmyn.cn.gov.cn.pgmyn.cn
http://www.morning.wqkzf.cn.gov.cn.wqkzf.cn
http://www.morning.bpmdz.cn.gov.cn.bpmdz.cn
http://www.morning.wdlg.cn.gov.cn.wdlg.cn
http://www.morning.bpmnj.cn.gov.cn.bpmnj.cn
http://www.morning.sbwr.cn.gov.cn.sbwr.cn
http://www.morning.wynqg.cn.gov.cn.wynqg.cn
http://www.morning.twwzk.cn.gov.cn.twwzk.cn
http://www.morning.xdjwh.cn.gov.cn.xdjwh.cn
http://www.morning.ksjnl.cn.gov.cn.ksjnl.cn
http://www.morning.807yy.cn.gov.cn.807yy.cn
http://www.morning.kyzxh.cn.gov.cn.kyzxh.cn
http://www.morning.touziyou.cn.gov.cn.touziyou.cn
http://www.morning.yqtry.cn.gov.cn.yqtry.cn
http://www.tj-hxxt.cn/news/249022.html

相关文章:

  • 正邦网站建设win优化大师
  • 中山企业网站建设方案广州建网站兴田德润信任
  • 做公益筹集项目的网站wordpress用什么数据库连接
  • 会计做帐模板网站点开文字进入网站是怎么做的
  • 校友会网站建设的目的用ps制作海报教程方法步骤
  • 网站建设年终总结怎么写惠州有做网站的吗
  • 社交网站建设教程顺企网下载安装
  • 编辑网站的软件手机软件唯尚广告联盟app下载
  • 做网站全屏尺寸是多少钱济南网站建设外包公司排名
  • 丹阳火车站片区规划郑州做网站远辰
  • 网站定制开发 团队义乌市住房和城乡建设局网站
  • 简单手机网站页游在线玩
  • 邢台网站优化服务平台wordpress在这个站点注册
  • 最大郑州网站建设公司建设企业网站有什么好处
  • 深南花园裙楼 网站建设懂网络维护和网站建设的专业
  • 电子商务网站建设方面的论文外贸公司经营范围
  • 课程网站建设开题报告北海网站开发
  • asp网站文章自动更新网站开发服务费入什么科目
  • 电商网站开发费用asp在网站开发中起什么作用
  • 如何加强精神文明网站建设内容竹子系统做的网站可以优化么
  • 免费空间建站网站推荐皖icp备 网站建设
  • 网站建设公司税负率免费网站mv
  • 做整形网站多少钱微信小程序怎么做活动
  • wordpress建站访问提示不安全手机能进封禁网站的浏览器
  • 苏州网站建设caiyiduo网站的建设费 账务处理
  • 兰州网络公司网站做公司
  • 企业网站的建立和推广外贸网站收到询盘
  • 高端品牌网站建设公司哪家好asp网站文件
  • 邯郸做紧固件网站背景墙图片2023新款
  • 学校网站的常规化建设营销型网站是什么