.Net單元測試業務實踐
業務簡述
-
關鍵字段:邀請碼最大使用次數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介紹
單元測試的藝術
.Net單元測試業務實踐