.Net单元测试业务实践
code[class*=”language-“],pre[class*=”language-“] { color: #333; background: none; font-family: Consolas, “Liberation Mono”, Menlo, Courier, monospace; text-align: left; white-space: pre; word-spacing: normal; line-height: 1.4 }
pre[class*=”language-“] { padding: .8em; overflow: auto; background: #f5f5f5 }
:not(pre)>code[class*=”language-“] { padding: .1em; white-space: normal; background: #f5f5f5 }
.token.comment,.token.blockquote { color: #969896 }
.token.cdata { color: #183691 }
.token.doctype,.token.punctuation,.token.variable,.token.macro.property { color: #333 }
.token.operator,.token.important,.token.keyword,.token.rule,.token.builtin { color: #a71d5d }
.token.string,.token.url,.token.regex,.token.attr-value { color: #183691 }
.token.property,.token.number,.token.boolean,.token.entity,.token.atrule,.token.constant,.token.symbol,.token.command,.token.code { color: #0086b3 }
.token.tag,.token.selector,.token.prolog { color: #63a35c }
.token.function,.token.namespace,.token.pseudo-element,.token.class,.token.class-name,.token.pseudo-class,.token.id,.token.url-reference .token.variable,.token.attr-name { color: #795da3 }
.token.entity { cursor: help }
.token.title,.token.title .token.punctuation { font-weight: bold; color: #1d3e81 }
.token.list { color: #ed6a43 }
.token.inserted { background-color: #eaffea; color: #55a532 }
.token.deleted { background-color: #ffecec; color: #bd2c00 }
.token.bold { font-weight: bold }
.token.italic { font-style: italic }
.language-json .token.property { color: #183691 }
.language-markup .token.tag .token.punctuation { color: #333 }
code.language-css,.language-css .token.function { color: #0086b3 }
.language-yaml .token.atrule { color: #63a35c }
code.language-yaml { color: #183691 }
.language-ruby .token.function { color: #333 }
.language-markdown .token.url { color: #795da3 }
.language-makefile .token.symbol { color: #795da3 }
.language-makefile .token.variable { color: #183691 }
.language-makefile .token.builtin { color: #0086b3 }
.language-bash .token.keyword { color: #0086b3 }
html body { font-family: “Helvetica Neue”, Helvetica, “Segoe UI”, Arial, freesans, sans-serif; font-size: 16px; line-height: 1.6; color: #333; background-color: #fff; overflow: initial }
html body>:first-child { margin-top: 0 }
html body h1,html body h2,html body h3,html body h4,html body h5,html body h6 { line-height: 1.2; margin-top: 1em; margin-bottom: 16px; color: #000 }
html body h1 { font-size: 2.25em; font-weight: 300; padding-bottom: .3em }
html body h2 { font-size: 1.75em; font-weight: 400; padding-bottom: .3em }
html body h3 { font-size: 1.5em; font-weight: 500 }
html body h4 { font-size: 1.25em; font-weight: 600 }
html body h5 { font-size: 1.1em; font-weight: 600 }
html body h6 { font-size: 1em; font-weight: 600 }
html body h1,html body h2,html body h3,html body h4,html body h5 { font-weight: 600 }
html body h5 { font-size: 1em }
html body h6 { color: #5c5c5c }
html body strong { color: #000 }
html body del { color: #5c5c5c }
html body a:not([href]) { color: inherit; text-decoration: none }
html body a { color: #08c; text-decoration: none }
html body a:hover { color: #00a3f5; text-decoration: none }
html body img { max-width: 100% }
html body>p { margin-top: 0; margin-bottom: 16px }
html body>ul,html body>ol { margin-bottom: 16px }
html body ul,html body ol { padding-left: 2em }
html body ul.no-list,html body ol.no-list { padding: 0; list-style-type: none }
html body ul ul,html body ul ol,html body ol ol,html body ol ul { margin-top: 0; margin-bottom: 0 }
html body li { margin-bottom: 0 }
html body li.task-list-item { list-style: none }
html body li>p { margin-top: 0; margin-bottom: 0 }
html body .task-list-item-checkbox { margin: 0 .2em .25em -1.8em; vertical-align: middle }
html body .task-list-item-checkbox:hover { cursor: pointer }
html body blockquote { margin: 16px 0; font-size: inherit; padding: 0 15px; color: #5c5c5c; border-left: 4px solid #d6d6d6 }
html body blockquote>:first-child { margin-top: 0 }
html body blockquote>:last-child { margin-bottom: 0 }
html body hr { height: 4px; margin: 32px 0; background-color: #d6d6d6; border: 0 none }
html body table { margin: 10px 0 15px 0; border-collapse: collapse; border-spacing: 0; display: block; width: 100%; overflow: auto }
html body table th { font-weight: bold; color: #000 }
html body table td,html body table th { border: 1px solid #d6d6d6; padding: 6px 13px }
html body dl { padding: 0 }
html body dl dt { padding: 0; margin-top: 16px; font-size: 1em; font-style: italic; font-weight: bold }
html body dl dd { padding: 0 16px; margin-bottom: 16px }
html body code { font-family: Menlo, Monaco, Consolas, “Courier New”, monospace; font-size: .85em !important; color: #000; background-color: #f0f0f0; padding: .2em 0 }
html body code::before,html body code::after { letter-spacing: -0.2em; content: ” ” }
html body pre>code { padding: 0; margin: 0; font-size: .85em !important; white-space: pre; background: transparent; border: 0 }
html body .highlight { margin-bottom: 16px }
html body .highlight pre,html body pre { padding: 1em; overflow: auto; font-size: .85em !important; line-height: 1.45; border: #d6d6d6 }
html body .highlight pre { margin-bottom: 0 }
html body pre code,html body pre tt { display: inline; max-width: initial; padding: 0; margin: 0; overflow: initial; line-height: inherit; background-color: transparent; border: 0 }
html body pre code::before,html body pre tt::before,html body pre code::after,html body pre tt::after { content: normal }
html body p,html body blockquote,html body ul,html body ol,html body dl,html body pre { margin-top: 0; margin-bottom: 16px }
html body kbd { color: #000; border: 1px solid #d6d6d6; border-bottom: 2px solid #c7c7c7; padding: 2px 4px; background-color: #f0f0f0 }
.markdown-preview { width: 100%; height: 100% }
.markdown-preview .pagebreak,.markdown-preview .newpage { page-break-before: always }
.markdown-preview pre.line-numbers { position: relative; padding-left: 3.8em; counter-reset: linenumber }
.markdown-preview pre.line-numbers>code { position: relative }
.markdown-preview pre.line-numbers .line-numbers-rows { position: absolute; top: 1em; font-size: 100%; left: 0; width: 3em; letter-spacing: -1px; border-right: 1px solid #999 }
.markdown-preview pre.line-numbers .line-numbers-rows>span { display: block; counter-increment: linenumber }
.markdown-preview pre.line-numbers .line-numbers-rows>span::before { content: counter(linenumber); color: #999; display: block; padding-right: .8em; text-align: right }
.markdown-preview .mathjax-exps .MathJax_Display { text-align: center !important }
.markdown-preview:not([for=”preview”]) .code-chunk .btn-group { display: none }
.markdown-preview:not([for=”preview”]) .code-chunk .status { display: none }
.markdown-preview:not([for=”preview”]) .code-chunk .output-div { margin-bottom: 16px }
.scrollbar-style::-webkit-scrollbar { width: 8px }
.scrollbar-style::-webkit-scrollbar-track { background-color: transparent }
.scrollbar-style::-webkit-scrollbar-thumb { background-color: rgba(150,150,150,0.66); border: 4px solid rgba(150,150,150,0.66) }
html body[for=”html-export”]:not([data-presentation-mode]) { position: relative; width: 100%; height: 100%; top: 0; left: 0; margin: 0; padding: 0; overflow: auto }
html body[for=”html-export”]:not([data-presentation-mode]) .markdown-preview { position: relative; top: 0 }
html body[for=”html-export”]:not([data-presentation-mode]) #sidebar-toc-btn { position: fixed; bottom: 8px; left: 8px; font-size: 28px; cursor: pointer; color: inherit; z-index: 99; width: 32px; text-align: center; opacity: .4 }
html body[for=”html-export”]:not([data-presentation-mode])[html-show-sidebar-toc] #sidebar-toc-btn { opacity: 1 }
html body[for=”html-export”]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc { position: fixed; top: 0; left: 0; width: 300px; height: 100%; padding: 32px 0 48px 0; font-size: 14px; overflow: auto; background-color: inherit }
html body[for=”html-export”]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc::-webkit-scrollbar { width: 8px }
html body[for=”html-export”]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc::-webkit-scrollbar-track { background-color: transparent }
html body[for=”html-export”]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc::-webkit-scrollbar-thumb { background-color: rgba(150,150,150,0.66); border: 4px solid rgba(150,150,150,0.66) }
html body[for=”html-export”]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc a { text-decoration: none }
html body[for=”html-export”]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc ul { padding: 0 1.6em; margin-top: .8em }
html body[for=”html-export”]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc li { margin-bottom: .8em }
html body[for=”html-export”]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc ul { list-style-type: none }
html body[for=”html-export”]:not([data-presentation-mode])[html-show-sidebar-toc] .markdown-preview { left: 300px; width: calc(100% – 300px); padding: 2em calc(50% – 457px – 150px); margin: 0 }
html body[for=”html-export”]:not([data-presentation-mode]):not([html-show-sidebar-toc]) .markdown-preview { left: 50% }
html body[for=”html-export”]:not([data-presentation-mode]):not([html-show-sidebar-toc]) .md-sidebar-toc { display: none }
业务简述
-
关键字段:邀请码最大使用次数UseMaxNumber和允许取消次数CancelUseMaxNumber,已使用次数UsedCount,已取消次数CancelUsedCount。
-
提交使用邀请码的订单,占用邀请码使用次数。
在允许取消次数内取消订单,退回邀请码使用次数。
超过允许取消次数取消订单,不退回邀请码使用次数。 -
注意点:临界值。
原核心代码(X.1版)
public ResponseMessage<bool> 示例方法_ProcessCode(X used,YY invitecodedto) { var isoverinvite = false;//已经超过取消次数 var iswilloverinvite = false;//将要超出取消次数 long inviteNum = 0;//本次邀约使用次数 //判断是否已经超过取消次数,或者将要超出取消次数。 if (invitecodedto != null && invitecodedto.IsLimitCancelUse) { if (invitecodedto.CancelUsedCount > invitecodedto.CancelUseMaxNumber) { isoverinvite = true; } else if (invitecodedto.CancelUsedCount + used.InviteNum > invitecodedto.CancelUseMaxNumber) { iswilloverinvite = true; } } ResponseMessage<long> inviteuseres = null; //邀约码不为null,递增取消次数,扣减使用次数。 if (invitecodedto != null) { //递增已取消次数 var cancelcount = _codeService.IncCancelUseCount(invitecodedto.Id, (int)used.InviteNum); if (isoverinvite) { } else if (iswilloverinvite) { inviteNum = invitecodedto.CancelUseMaxNumber > cancelcount.Body ? invitecodedto.CancelUseMaxNumber - cancelcount.Body : cancelcount.Body - invitecodedto.CancelUseMaxNumber; //将要超出的,只退出部分。 inviteuseres = _codeService.IncUseCount(invitecodedto.Id, -(int)(inviteNum)); } else { inviteNum = used.InviteNum; //未超出取消次数的,全数退回。 inviteuseres = _codeService.IncUseCount(invitecodedto.Id, -(int)inviteNum); } } . . . //更新取消日志。 //更新码相关的各种状态。 }
X.1版代码引起问题
-
使用次数为1,允许取消次数为1时,运行正确。
-
使用次数为1,允许取消次数为2时,结果错误。
>>测试流程目标:【每次报名都为1人】报名一次,取消一次,再报名一次,再取消一次后。再报名一次后,后续不能再报名。 >>实际效果:仍然还能报名一次。 >>原因分析:订单第二次取消后。已取消次数为2,允许取消次数为2,这个判断无法命中。 if (invitecodedto.CancelUsedCount > invitecodedto.CancelUseMaxNumber) { isoverinvite = true; }
优化后代码(X.2版)
var isoverinvite = false;//已经超过取消次数 var iswilloverinvite = false;//将要超出取消次数 long inviteNum = 0;//本次邀约使用次数 if (invitecodedto != null && invitecodedto.IsLimitCancelUse) { //这里多加了个=号 if (invitecodedto.CancelUsedCount >= invitecodedto.CancelUseMaxNumber) { isoverinvite = true; }//这里也多加了个=号 else if (invitecodedto.CancelUsedCount + used.InviteNum >= invitecodedto.CancelUseMaxNumber) { iswilloverinvite = true; } }
X.2版代码引起问题
-
X.2版修复了上个问题。但仍有场景覆盖不够。
-
使用次数为2,允许取消次数为2时,结果错误。
>>测试流程目标:报名一次(1人),取消,再报名一次(2人),再取消。预期仍可以继续报名1人。 >>实际效果:无法继续报名。 >>原因分析,第二次取消请求时: >>>根据判断 已取消次数加上邀约人数大于允许取消次数,1+2>2,所以是将要超出允许取消次数。 . . else if (invitecodedto.CancelUsedCount + used.InviteNum > invitecodedto.CancelUseMaxNumber) { iswilloverinvite = true; } . . >>>再来看下扣减使用次数的部分。CancelUseMaxNumber为2,cancelcount.Body为2, >>>所以结果是:2>2?(2-2):(2-2),返回0,意思是没有返回使用次数。 . . else if (iswilloverinvite) { inviteNum = invitecodedto.CancelUseMaxNumber > cancelcount.Body ? invitecodedto.CancelUseMaxNumber - cancelcount.Body : cancelcount.Body - invitecodedto.CancelUseMaxNumber; //将要超出的,只退出部分。 inviteuseres = _codeService.IncUseCount(invitecodedto.Id, -(int)(inviteNum)); } . . >>>正确结果应该是:因为已经取消过一次了,这次报名2人,如按正常应该是总取消3次,但允许取消次数是2次,所以使用次数只能返回一次。 >>>预期结果和实际结果不符。
思考
-
上面问题是由于退回使用次数计算不对引起的。
-
改动后验证流程是很繁琐的,要配置邀请码,要填写报名信息,要重复提交,重复取消订单好几次来验证逻辑。
-
组合条件是千变万化的。
-
这个业务重点是测试取消订单后对于使用次数和允许取消次数的正确性。如全流程走一下,是浪费时间的。
-
所以为保证正确性及方便,这个必须支持单元测试。单元测试才能快速试错。
影响单元测试的几点
-
业务耦合。这个取消邀请方法内有处理邀请码使用次数和取消次数的,也有处理取消记录,维护各个状态等。不符合单一功能原则。
-
数据库依赖,影响mock数据及执行后的结果对比。
-
重复执行后结果的积累。如订单取消后,邀请码的使用次数和允许取消次数都会变,作为下次单元测试的依据。
改进建议
-
对打算单元测试的代码,要保持功能单一,不耦合其他业务。
-
面向接口编程,依赖注入。与具体的实现解耦,方便单元测试。
-
方法体尽量移除仓储部分逻辑或者mock一个仓储对象替代。
-
必须方便批量单元测试。
单元测试前置–Nuget包依赖
-
Xunit:一个开发测试框架,它支持测试驱动开发,具有极其简单和与框架特征对齐的设计目标。
-
xunit.runner.visualstudio: 支持Vs调试,运行测试
-
NSubstitute :一个友好的.net单元测试隔离框架。
-
Autofac: Ioc容器
//单元测试部分 public class GetTicketDiscounts_Test { private IXTaDiscountService discountService = null; private IXTaCodeService codeSub = null; public GetTicketDiscounts_Test() { discountService = XTaContainer.Resolve<IXTaDiscountService>(); codeSub = NSubstitute.Substitute.For<IXTaCodeService>(); } }
//注册部分 public static class XTaContainer { public readonly static IContainer _container; static XTaContainer() { // Create your builder. var builder = new ContainerBuilder(); //自动注册。 var baseType = typeof(IApplication); var assemblys = AppDomain.CurrentDomain.GetAssemblies().ToList(); builder.RegisterAssemblyTypes(assemblys.ToArray()) .Where(t => baseType.IsAssignableFrom(t) && t != baseType) .AsImplementedInterfaces() .InstancePerLifetimeScope(); //Redis builder.Register(n => Substitute.For<ICache>()) .As<ICache>().SingleInstance(); //mongodb builder.Register(n => Substitute.For<IMongoDbProvider>()) .As<IMongoDbProvider>().SingleInstance(); _container = builder.Build(); } public static T Resolve<T>() { return _container.Resolve<T>(); } }
支持单元测试的代码(X.3版-只粘贴相关代码)
//接口 public interface IXTaService : IApplication{ ResponseMessage<long> GetReturnUseNum(long invitediscountNum, XTaCodeDto codedto); }
//实现 public class XTaDiscountService : IXTaDiscountService { private readonly IXTaCodeService _codeService; public XTaDiscountService( IXTaCodeService codeService) { _codeService = codeService; } //将操作使用次数和取消次数的仓储部分挪出去,这里只计算需要退回的使用次数。 public ResponseMessage<long> GetReturnUseNum(long invitediscountNum, XTaCodeDto codedto) { //默认是全部退回使用次数。 long returnNum = invitediscountNum; if (codedto == null) { return ResponseMessage<long>.MakeSucc(0); } //不限制取消的的时候,退回全部使用次数。 if (!codedto.IsLimitCancelUse) { return ResponseMessage<long>.MakeSucc(returnNum); } //已超过的不处理。 if (codedto.CancelUsedCount >= codedto.CancelUseMaxNumber) { return ResponseMessage<long>.MakeSucc(0); } //将要超过的。 if (codedto.CancelUsedCount + invitediscountNum >= codedto.CancelUseMaxNumber) { returnNum = codedto.CancelUsedCount + invitediscountNum - codedto.CancelUseMaxNumber; return ResponseMessage<long>.MakeSucc(returnNum); } return ResponseMessage<long>.MakeSucc(returnNum); } }
>初始化数据 private void 验证取消优惠_初始化数据(ref XTaCodeDto codeDto, int usemax = 0, int cancelmax = 0) { if (codeDto == null) { codeDto = new XTaCodeDto() { Id = "11111", CancelUsedCount = 0, UsedCount = 0, PrivateSetting = new PrivateSetting() { IsLimitCancelUse = true, IsCustomCancelUse = true, CancelUseMaxNumber = 1, IsLimitUse = true, IsCustomUse = true, UseMaxNumber = 1 } }; } if (cancelmax > 0) { codeDto.PrivateSetting.CancelUseMaxNumber = cancelmax; codeDto.CancelUsedCount = 0; } if (usemax > 0) { codeDto.PrivateSetting.UseMaxNumber = usemax; codeDto.UsedCount = 0; } }
> 模拟报名使用邀请码,递增使用次数,方便批量测试。 private void 初始化数据_模拟报名使用邀请码_递增使用次数(int useNum, XTaCodeDto codeDto) { //mock模拟使用邀请码时,递增的邀请码使用次数返回使用次数。 var usercount = codeSub.IncUseCount(codeDto.Id, Arg.Any<int>()).Returns(x => new ResponseMessage<long>() { Body = (int)codeDto.UsedCount + x.Arg<int>() }); codeDto.UsedCount = codeSub.IncUseCount(codeDto.Id, useNum).Body; }
> 模拟取消订单,退回使用次数 private void 验证取消优惠_退回使用次数_V1ForPrivate(long inviteDiscountNum, XTaCodeDto codeDto) { //计算退回使用次数。 var res = discountService.GetReturnUseNum(inviteDiscountNum, codeDto); codeDto.UsedCount -= res.Body; codeDto.CancelUsedCount += inviteDiscountNum; }
>实际测试部分 [Fact] public void 验证取消优惠_退回使用次数_最大使用一次_允许取消一次() { XTaCodeDto codeDto = null; 验证取消优惠_初始化数据(ref codeDto, 1, 1); //第一次报名,取消 验证取消优惠_模拟报名使用邀请码_递增使用次数(1, codeDto); 验证取消优惠_退回使用次数_V1ForPrivate(1, codeDto); //第一次取消会退回使用次数。 Assert.True(codeDto.UsedCount == 0 && codeDto.CancelUsedCount == 1); //第二次报名,取消 验证取消优惠_模拟报名使用邀请码_递增使用次数(1, codeDto); 验证取消优惠_退回使用次数_V1ForPrivate(1, codeDto); //第二次取消后,超出允许取消次数限制,不会退回 Assert.True(codeDto.UsedCount == 1 && codeDto.CancelUsedCount == 2); } [Fact] public void 验证取消优惠_退回使用次数_最大使用2次_允许取消两次() { XTaCodeDto codeDto = null; 验证取消优惠_初始化数据(ref codeDto, 2, 2); 验证取消优惠_模拟报名使用邀请码_递增使用次数(1, codeDto); 验证取消优惠_退回使用次数_V1ForPrivate(1, codeDto); Assert.True(codeDto.UsedCount == 0 && codeDto.CancelUsedCount == 1); 验证取消优惠_模拟报名使用邀请码_递增使用次数(2, codeDto); 验证取消优惠_退回使用次数_V1ForPrivate(2, codeDto); Assert.True(codeDto.UsedCount == 1 && codeDto.CancelUsedCount == 3); 验证取消优惠_模拟报名使用邀请码_递增使用次数(1, codeDto); 验证取消优惠_退回使用次数_V1ForPrivate(1, codeDto); Assert.True(codeDto.UsedCount == 2 && codeDto.CancelUsedCount == 4); }
使用单元测试的好处
-
快速验证结果,不用依赖各种数据库/缓存等环境。
-
代码指责更单一。
-
减少bug
-
方便后期持续集成
可参考连接
使用 dotnet test 和 xUnit 在 .NET Core 中进行 C# 单元测试
nsubstitute 介绍
Autofac介绍
单元测试的艺术