TDD案例实战

2022-06-30 10:20:33 浏览数 (1)

使用gin框架实现一个简单的手机号密码登录服务step1: 需求分析/任务拆分 案例代码地址: https://github.com/GuoGuiRong/TDD-demo

step1: 需求分析/任务拆分

这个阶段至少要确认以下内容:

  • 使用什么样的协议?
  • 输入/输出参数有哪些?
  • 其他细节
    • 参数验证的规则
    • 接口安全策略(签名规则)

需求分析后我们一般会做任务拆分/分解, 然后产出接口文档, 这个阶段一般需要前后端开发,产品,测试共同讨论:

image.pngimage.png

step2: 编写接口测试用例

这个阶段我们主要是针对之前定义好的接口文档, 编写接口测试用例

image.pngimage.png

step3. 编写单元测试用例

  • 测试用例上的通用信息进行封装
代码语言:go复制
  var (
  	// 正常响应
  	Success = RetResp{0, "success"}
  
  	// 异常请求
  	BadRequest = RetResp{8000004000, "invalid request"}
  
  	// 登录请求参数解析异常
  	LoginParamsParseFailed = RetResp{8000004001, "request params parse failed"}
  
  	// 登录请求参数sign非法
  	LoginSignIllegal = RetResp{8000004002, "request sign illegal"}
  
  	// 登录请求过期
  	LoginTimestampExpire = RetResp{8000004003, "request timestamp expire"}
  
  	// 登录请求参数nonce非法
  	LoginNonceIllegal = RetResp{8000004004, "request nonce illegal"}
  
  	// 登录请求参数phone_number非法
  	LoginPhoneNumberIllegal = RetResp{8000004005, "request phone_number illegal"}
  
  	// 登录请求参数password非法
  	LoginPwdIllegal = RetResp{8000004006, "request password illegal"}
  
  	// 登录请求参数sign错误
  	LoginSignCheckFailed = RetResp{8000004007, "request sign check failed"}
  
  	// 账号phone_number不存在
  	LoginPhoneNumberNotExist = RetResp{8000004008, "request phone_number not exist"}
  
  	// 用户password错误
  	LoginPwdCheckFailed = RetResp{8000004009, "request password incorrect"}
  )
  
  // 通用常量
  var (
  	ExistPhoneNumber = "18018726093"
  	ExistPasswd      = "TesPwdT123"
  	Underline        = "_"
  	LoginPwdMinLen   = 6
  	LoginPwdNonceLen = 8
  	LoginPwdMaxLen   = 20
  	LoginSignLen     = 32
  )
  • 编写测试用例
代码语言:go复制
  // TestLoginPwdService A1: 登陆服务
  func TestLoginPwdService(t *testing.T) {
      tests := []struct {
        name     string
        args     Args
        wantCode int
      }{
        {"Correct Request Params", CorrectRequestParams, pkg.Success.Code},
        {"Bad Request", BadRequestParams, pkg.BadRequest.Code},
        {"Request Expire Time", RequestExpiredTime, pkg.LoginTimestampExpire.Code},
        {"Request PhoneNumber Invalid", RequestPhoneNumberInvalid, pkg.LoginPhoneNumberIllegal.Code},
        {"Request Password too short", RequestPwdTooShort, pkg.LoginPwdIllegal.Code},
        {"Request Password too long", RequestPwdTooLong, pkg.LoginPwdIllegal.Code},
        {"Request Invalid Password Content", RequestInvalidPwdContent, pkg.LoginPwdCheckFailed.Code},
        {"Request Sign Invalid: length not equals 32", RequestSignWithBadLength, pkg.LoginSignIllegal.Code},
        {"Request Invalid Sign Content", RequestSignWithBadContent, pkg.LoginSignCheckFailed.Code},
        {"Request Nonce Invalid", RequestNonceInvalid, pkg.LoginNonceIllegal.Code},
      }
  
      ast := assert.New(t)
      for _, tt := range tests {
        // 准备, mock一个gin.Context, 并把用例数据载入其中
        var ctx *gin.Context
        var w *responseWriter
        if tt.wantCode != pkg.BadRequest.Code {
          ctx, _, w = buildRequest(tt.args.PhoneNumber, tt.args.Password,
            tt.args.Timestamp, tt.args.Nonce, tt.args.Sign)
        } else {
          ctx, _, w = buildBadRequest(tt.args.Timestamp, tt.args.Nonce, tt.args.Sign)
        }
  			
        // 执行用例
        LoginPwdService(ctx)
        resp := LoginPwdResp{}
        err := json.Unmarshal([]byte(w.Buff.String()), &resp)
        
        // 断言
        ast.False(err != nil, tt.name)
        ast.Equal(resp.Code, tt.wantCode, tt.name)
      }
    }

step4. 实现LoginPwdService

代码语言:go复制
// LoginPwdService 密码登陆
func LoginPwdService(c *gin.Context) {

	// step1. 参数解析
	req, err := ParseLoginReqParams(c)
	if err != nil {
		// 退出
		c.JSON(http.StatusBadRequest, gin.H{
			"code":    pkg.BadRequest.Code,
			"message": err.Error(),
		})
		return
	}

	// step2. 参数验证
	code, err := CheckLoginReqParams(req.PhoneNumber, req.Password, req.Timestamp, req.Nonce, req.Sign)
	if err != nil {
		// 退出
		c.JSON(http.StatusBadRequest, gin.H{
			"code":    code,
			"message": err.Error(),
		})
		return
	}

	// step3: 签名验证
	code, err = CheckLoginSignature(req.PhoneNumber, req.Password, req.Timestamp, req.Nonce, req.Sign)
	if err != nil {
		// 退出
		c.JSON(http.StatusBadRequest, gin.H{
			"code":    code,
			"message": err.Error(),
		})
		return
	}

	// step4: 密码确认
	code, err = CheckLoginPwd(req.PhoneNumber, req.Password)
	if err != nil {
		// 退出
		c.JSON(http.StatusBadRequest, gin.H{
			"code":    code,
			"message": err.Error(),
		})
		return
	}

	// step5. 生成token
	token := pkg.MD5(req.Password req.Timestamp, req.PhoneNumber)
	data := map[string]string{
		"token": token,
	}

	// step6. 响应结果验证
	c.JSON(http.StatusBadRequest, gin.H{
		"code":    pkg.Success.Code,
		"message": pkg.Success.Msg,
		"data":    data,
	})
	return
}

遇到子方法需要独立实现咋么办?

当我们编写实现时, 可能发现有些地方需要独立实现, 比如我们需要一个独立的CheckLoginSignature方法, 使用TDD的话, 我们需要为这个方法独立设计测试用例:

代码语言:go复制
// CheckSignature 签名检查
func CheckLoginSignature(phoneNumber, password, reqTimestamp, nonce, sign string) (code int, err error) {
  // 先不做具体实现, 只是定义输入输出
  // 初始化默认使用异常输出
  ....
  
	return pkg.LoginSignCheckFailed.Code, nil
}

// TestCheckSignature A0:测试签名
func TestCheckSignature(t *testing.T) {
	tests := []struct {
		name     string
		args     Args
		wantCode int
		wantErr  bool
	}{
		{"Correct Sign", CorrectRequestParams, pkg.Success.Code, false},
		{" Sign With Bad length ", RequestSignWithBadLength, pkg.LoginSignCheckFailed.Code, true},
		{" Sign Is Illegal ", RequestSignWithBadContent, pkg.LoginSignCheckFailed.Code, true},
	}

	ast := assert.New(t)
	for _, tt := range tests {
		got, err := CheckLoginSignature(tt.args.PhoneNumber, tt.args.Password, tt.args.Timestamp, tt.args.Nonce,
			tt.args.Sign)
		ast.Equal(got, tt.wantCode, tt.name)
		ast.Equal(err != nil, tt.wantErr, tt.name)
	}
}

然后我们为了让测试用例TestCheckSignature通过, 实现CheckLoginSignature方法

代码语言:go复制
// CheckSignature 签名检查
// MD5({phone_number}_{password}_{时间戳},{随机串})
func CheckLoginSignature(phoneNumber, password, reqTimestamp, nonce, sign string) (int, error) {
	sourceStr := phoneNumber   pkg.Underline   password   pkg.Underline   reqTimestamp
	signStr := pkg.MD5(sourceStr, nonce)
	fmt.Sprint("sign:"   signStr)
	if signStr != sign {
		return pkg.LoginSignCheckFailed.Code, errors.New(pkg.LoginSignCheckFailed.Msg)
	}
	return pkg.Success.Code, nil
}

当把内部子方法都使用TDD的方式实现后, 再实现LoginPwdService里面的具体调用, 最后执行TestLoginPwdService完成整个接口的单元测试.

step5. 执行所有单元测试

在项目目录下, 本地执行go test .../, 查看是否有没有覆盖到的地方, 我这里可以看到文件100%覆盖了.

这并不说明我们的代码绝对没有问题, 只能说明我们的代码相对简洁

image.pngimage.png
代码语言:shell复制
# 生成html报告
go test -coverprofile=c.out  ./...
go tool cover -html=c.out -o coverage.html
image.pngimage.png

step6. 一键执行所有的接口测试用例

  • 需要注意浏览器时间和服务机器的时钟是否同步 http://127.0.0.1:3000/api/open/run_auto_test?id=18&token=e701e78e60a655867895a7d1e44f7afeeb94b3b2879d83ce8e4ed5c6589e8d4c&mode=html&email=false&download=false image.pngimage.png

接口测试报告:

image.pngimage.png

0 人点赞