使用gin框架实现一个简单的手机号密码登录服务step1: 需求分析/任务拆分 案例代码地址: https://github.com/GuoGuiRong/TDD-demo
step1: 需求分析/任务拆分
这个阶段至少要确认以下内容:
- 使用什么样的协议?
- 输入/输出参数有哪些?
- 其他细节
- 参数验证的规则
- 接口安全策略(签名规则)
需求分析后我们一般会做任务拆分/分解, 然后产出接口文档, 这个阶段一般需要前后端开发,产品,测试共同讨论:
step2: 编写接口测试用例
这个阶段我们主要是针对之前定义好的接口文档, 编写接口测试用例
step3. 编写单元测试用例
- 测试用例上的通用信息进行封装
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
)
- 编写测试用例
// 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的话, 我们需要为这个方法独立设计测试用例:
// 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
方法
// 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%覆盖了.
这并不说明我们的代码绝对没有问题, 只能说明我们的代码相对简洁
代码语言:shell复制# 生成html报告
go test -coverprofile=c.out ./...
go tool cover -html=c.out -o coverage.html
step6. 一键执行所有的接口测试用例
- 需要注意浏览器时间和服务机器的时钟是否同步 http://127.0.0.1:3000/api/open/run_auto_test?id=18&token=e701e78e60a655867895a7d1e44f7afeeb94b3b2879d83ce8e4ed5c6589e8d4c&mode=html&email=false&download=false image.png
接口测试报告: