[{"data":1,"prerenderedAt":2350},["ShallowReactive",2],{"article-\u002Farticles\u002Funit-test-aaa-naming":3},{"id":4,"title":5,"body":6,"date":2335,"description":2336,"excerpt":2337,"extension":2338,"meta":2339,"navigation":447,"path":2340,"seo":2341,"stem":2342,"tags":2343,"__hash__":2349},"articles\u002Farticles\u002Funit-test-aaa-naming.md","單元測試怎麼寫才好讀？用 3A 模式組織測試，用「方法_情境_預期結果」命名",{"type":7,"value":8,"toc":2320},"minimark",[9,27,30,46,49,54,57,129,134,141,524,530,553,557,564,993,996,1017,1024,1160,1167,1171,1178,1355,1372,1376,1379,1630,1660,1667,1671,1709,1713,1716,1724,1731,1838,1848,1852,1855,1875,1886,1889,1909,1922,1949,1952,1955,1969,2045,2056,2217,2223,2278,2281,2313,2316],[10,11,12,13,17,18,22,23,26],"p",{},"寫單元測試時，最常遇到的問題通常不是「不會寫」，而是",[14,15,16],"strong",{},"三個月後回來看，看不懂當初在測什麼","。測試失敗時，紅燈只告訴你一個方法名稱；如果名稱是 ",[19,20,21],"code",{},"Test1","、",[19,24,25],{},"CreateOrderTest2","，還得把整段測試讀完、甚至 debug，才知道是哪個業務規則壞了。",[10,28,29],{},"好讀的測試靠兩件事撐起來：",[31,32,33,40],"ol",{},[34,35,36,39],"li",{},[14,37,38],{},"結構","：每個測試都用同樣的骨架寫——這就是 3A 模式。",[34,41,42,45],{},[14,43,44],{},"命名","：方法名稱本身就是一句規格說明——「這個方法，在這個情境下，應該得到這個結果」。",[10,47,48],{},"以下用我在電商專案（Clean Architecture 的訂單服務）裡實際寫的測試當例子。",[50,51,53],"h2",{"id":52},"_3a-模式arrangeactassert","3A 模式：Arrange、Act、Assert",[10,55,56],{},"3A 把一個測試切成三段，每段只做一件事：",[58,59,60,77],"table",{},[61,62,63],"thead",{},[64,65,66,71,74],"tr",{},[67,68,70],"th",{"align":69},"left","階段",[67,72,73],{"align":69},"做什麼",[67,75,76],{"align":69},"常見內容",[78,79,80,94,116],"tbody",{},[64,81,82,88,91],{},[83,84,85],"td",{"align":69},[14,86,87],{},"Arrange（準備）",[83,89,90],{"align":69},"建立被測物件與它需要的環境",[83,92,93],{"align":69},"建立測試資料、設定 Stub 回傳值、固定時間",[64,95,96,101,108],{},[83,97,98],{"align":69},[14,99,100],{},"Act（執行）",[83,102,103,104,107],{"align":69},"呼叫",[14,105,106],{},"一次","被測的方法",[83,109,110,22,113],{"align":69},[19,111,112],{},"sut.HandleAsync(...)",[19,114,115],{},"order.Complete()",[64,117,118,123,126],{},[83,119,120],{"align":69},[14,121,122],{},"Assert（驗證）",[83,124,125],{"align":69},"檢查結果是否符合預期",[83,127,128],{"align":69},"回傳值、物件狀態、是否拋出例外、相依物件是否被呼叫",[130,131,133],"h3",{"id":132},"範例一entity-的狀態變化","範例一：Entity 的狀態變化",[10,135,136,137,140],{},"先看最單純的情況——驗證 ",[19,138,139],{},"Order"," 這個 Entity 完成訂單後狀態是否正確：",[142,143,148],"pre",{"className":144,"code":145,"language":146,"meta":147,"style":147},"language-csharp shiki shiki-themes github-dark","[Fact]\npublic void Complete_訂單已完成_應更新訂單狀態為已完成()\n{\n    \u002F\u002F Arrange\n    var id = OrderId.New(Guid.NewGuid());\n    var memberId = MemberId.New(Guid.NewGuid());\n    var orderItems = new List\u003COrderItem>\n    {\n        new(ProductId.New(Guid.NewGuid()), 1, 100),\n        new(ProductId.New(Guid.NewGuid()), 2, 200),\n    };\n    var timeProvider = new FakeTimeProvider();\n    timeProvider.SetUtcNow(new DateTimeOffset(2023, 11, 01, 0, 0, 0, TimeSpan.Zero));\n    var shipmentAddress = new ShipmentAddress(\"台灣\", \"台北市\", \"大安區敦化南路二段100號\", \"106\");\n\n    var order = Order.New(id, memberId, orderItems, shipmentAddress, timeProvider);\n\n    \u002F\u002F Act\n    order.Complete();\n\n    \u002F\u002F Assert\n    order.Status.Should().Be(Status.Completed);\n}\n","csharp","",[19,149,150,166,182,188,195,222,243,268,274,305,330,336,354,403,442,449,467,472,478,489,494,500,518],{"__ignoreMap":147},[151,152,155,159,163],"span",{"class":153,"line":154},"line",1,[151,156,158],{"class":157},"s95oV","[",[151,160,162],{"class":161},"svObZ","Fact",[151,164,165],{"class":157},"]\n",[151,167,169,173,176,179],{"class":153,"line":168},2,[151,170,172],{"class":171},"snl16","public",[151,174,175],{"class":171}," void",[151,177,178],{"class":161}," Complete_訂單已完成_應更新訂單狀態為已完成",[151,180,181],{"class":157},"()\n",[151,183,185],{"class":153,"line":184},3,[151,186,187],{"class":157},"{\n",[151,189,191],{"class":153,"line":190},4,[151,192,194],{"class":193},"sAwPA","    \u002F\u002F Arrange\n",[151,196,198,201,204,207,210,213,216,219],{"class":153,"line":197},5,[151,199,200],{"class":171},"    var",[151,202,203],{"class":161}," id",[151,205,206],{"class":171}," =",[151,208,209],{"class":157}," OrderId.",[151,211,212],{"class":161},"New",[151,214,215],{"class":157},"(Guid.",[151,217,218],{"class":161},"NewGuid",[151,220,221],{"class":157},"());\n",[151,223,225,227,230,232,235,237,239,241],{"class":153,"line":224},6,[151,226,200],{"class":171},[151,228,229],{"class":161}," memberId",[151,231,206],{"class":171},[151,233,234],{"class":157}," MemberId.",[151,236,212],{"class":161},[151,238,215],{"class":157},[151,240,218],{"class":161},[151,242,221],{"class":157},[151,244,246,248,251,253,256,259,262,265],{"class":153,"line":245},7,[151,247,200],{"class":171},[151,249,250],{"class":161}," orderItems",[151,252,206],{"class":171},[151,254,255],{"class":171}," new",[151,257,258],{"class":161}," List",[151,260,261],{"class":157},"\u003C",[151,263,264],{"class":161},"OrderItem",[151,266,267],{"class":157},">\n",[151,269,271],{"class":153,"line":270},8,[151,272,273],{"class":157},"    {\n",[151,275,277,280,283,285,287,289,292,296,299,302],{"class":153,"line":276},9,[151,278,279],{"class":171},"        new",[151,281,282],{"class":157},"(ProductId.",[151,284,212],{"class":161},[151,286,215],{"class":157},[151,288,218],{"class":161},[151,290,291],{"class":157},"()), ",[151,293,295],{"class":294},"sDLfK","1",[151,297,298],{"class":157},", ",[151,300,301],{"class":294},"100",[151,303,304],{"class":157},"),\n",[151,306,308,310,312,314,316,318,320,323,325,328],{"class":153,"line":307},10,[151,309,279],{"class":171},[151,311,282],{"class":157},[151,313,212],{"class":161},[151,315,215],{"class":157},[151,317,218],{"class":161},[151,319,291],{"class":157},[151,321,322],{"class":294},"2",[151,324,298],{"class":157},[151,326,327],{"class":294},"200",[151,329,304],{"class":157},[151,331,333],{"class":153,"line":332},11,[151,334,335],{"class":157},"    };\n",[151,337,339,341,344,346,348,351],{"class":153,"line":338},12,[151,340,200],{"class":171},[151,342,343],{"class":161}," timeProvider",[151,345,206],{"class":171},[151,347,255],{"class":171},[151,349,350],{"class":161}," FakeTimeProvider",[151,352,353],{"class":157},"();\n",[151,355,357,360,363,366,369,372,374,377,379,382,384,387,389,392,394,396,398,400],{"class":153,"line":356},13,[151,358,359],{"class":157},"    timeProvider.",[151,361,362],{"class":161},"SetUtcNow",[151,364,365],{"class":157},"(",[151,367,368],{"class":171},"new",[151,370,371],{"class":161}," DateTimeOffset",[151,373,365],{"class":157},[151,375,376],{"class":294},"2023",[151,378,298],{"class":157},[151,380,381],{"class":294},"11",[151,383,298],{"class":157},[151,385,386],{"class":294},"01",[151,388,298],{"class":157},[151,390,391],{"class":294},"0",[151,393,298],{"class":157},[151,395,391],{"class":294},[151,397,298],{"class":157},[151,399,391],{"class":294},[151,401,402],{"class":157},", TimeSpan.Zero));\n",[151,404,406,408,411,413,415,418,420,424,426,429,431,434,436,439],{"class":153,"line":405},14,[151,407,200],{"class":171},[151,409,410],{"class":161}," shipmentAddress",[151,412,206],{"class":171},[151,414,255],{"class":171},[151,416,417],{"class":161}," ShipmentAddress",[151,419,365],{"class":157},[151,421,423],{"class":422},"sU2Wk","\"台灣\"",[151,425,298],{"class":157},[151,427,428],{"class":422},"\"台北市\"",[151,430,298],{"class":157},[151,432,433],{"class":422},"\"大安區敦化南路二段100號\"",[151,435,298],{"class":157},[151,437,438],{"class":422},"\"106\"",[151,440,441],{"class":157},");\n",[151,443,445],{"class":153,"line":444},15,[151,446,448],{"emptyLinePlaceholder":447},true,"\n",[151,450,452,454,457,459,462,464],{"class":153,"line":451},16,[151,453,200],{"class":171},[151,455,456],{"class":161}," order",[151,458,206],{"class":171},[151,460,461],{"class":157}," Order.",[151,463,212],{"class":161},[151,465,466],{"class":157},"(id, memberId, orderItems, shipmentAddress, timeProvider);\n",[151,468,470],{"class":153,"line":469},17,[151,471,448],{"emptyLinePlaceholder":447},[151,473,475],{"class":153,"line":474},18,[151,476,477],{"class":193},"    \u002F\u002F Act\n",[151,479,481,484,487],{"class":153,"line":480},19,[151,482,483],{"class":157},"    order.",[151,485,486],{"class":161},"Complete",[151,488,353],{"class":157},[151,490,492],{"class":153,"line":491},20,[151,493,448],{"emptyLinePlaceholder":447},[151,495,497],{"class":153,"line":496},21,[151,498,499],{"class":193},"    \u002F\u002F Assert\n",[151,501,503,506,509,512,515],{"class":153,"line":502},22,[151,504,505],{"class":157},"    order.Status.",[151,507,508],{"class":161},"Should",[151,510,511],{"class":157},"().",[151,513,514],{"class":161},"Be",[151,516,517],{"class":157},"(Status.Completed);\n",[151,519,521],{"class":153,"line":520},23,[151,522,523],{"class":157},"}\n",[10,525,526,527,529],{},"三段的分界非常清楚：Arrange 準備好一張「待付款」的訂單；Act 只有一行 ",[19,528,115],{},"；Assert 只驗證狀態。讀的人不用理解訂單怎麼建立，只要看 Act 跟 Assert 就知道這個測試的重點。",[10,531,532,533,540,541,544,545,548,549,552],{},"這裡有個值得注意的細節：",[14,534,535,536,539],{},"時間用 ",[19,537,538],{},"FakeTimeProvider"," 固定住","。訂單的 ",[19,542,543],{},"CreateTime"," 取決於「現在」，如果直接用 ",[19,546,547],{},"DateTime.Now","，測試結果就會隨執行時間改變。上個月剛發布的 .NET 8 新增了 ",[19,550,551],{},"TimeProvider"," 抽象，讓時間也能像其他相依一樣被注入、被替換。",[130,554,556],{"id":555},"範例二use-case-與相依物件","範例二：Use Case 與相依物件",[10,558,559,560,563],{},"UseCase 層的測試會牽涉到外部相依（Repository、Event Bus、會員服務……），這時 Arrange 的主要工作就變成",[14,561,562],{},"設定替身（Test Double）的行為","：",[142,565,567],{"className":144,"code":566,"language":146,"meta":147,"style":147},"[Fact]\npublic async Task HandleAsyncTest_輸入符合預期的參數_傳回True()\n{\n    \u002F\u002F Arrange\n    _generatorIdOutPort.GenerateAsync().Returns(Guid.NewGuid());\n    _orderOutPort.SaveAsync(Arg.Any\u003COrder>()).Returns(true);\n    _timeProvider.SetUtcNow(new DateTimeOffset(new DateTime(2023, 11, 20)));\n\n    var memberId = MemberId.New(new Guid(\"1AC0AE5E-E07E-4782-9E0B-F391FDFF26B3\"));\n    _memberOutPort.IsExistAsync(memberId).Returns(true);\n\n    var shipmentAddress = new ShipmentAddress(\"台灣\", \"台北市\", \"大安區敦化南路二段100號\", \"106\");\n    var orderItems = new List\u003COrderItem>\n    {\n        new(ProductId.New(new Guid(\"072AD23F-396B-445B-8627-1A560755ADAB\")), 1, 10),\n    };\n\n    var sut = GetSystemUnderTest();\n\n    \u002F\u002F Act\n    var parameter = new CreatOrderParameter\n    {\n        MemberId = memberId,\n        OrderItems = orderItems,\n        ShipmentAddress = shipmentAddress\n    };\n    var actual = await sut.HandleAsync(parameter);\n\n    \u002F\u002F Assert\n    actual.Should().BeTrue();\n    await _domainEventBus.Received(1).DispatchDomainEventsAsync(Arg.Any\u003COrder>());\n}\n",[19,568,569,577,592,596,600,619,649,685,689,716,735,739,769,787,791,822,826,830,844,848,852,866,870,881,892,903,908,930,935,940,955,988],{"__ignoreMap":147},[151,570,571,573,575],{"class":153,"line":154},[151,572,158],{"class":157},[151,574,162],{"class":161},[151,576,165],{"class":157},[151,578,579,581,584,587,590],{"class":153,"line":168},[151,580,172],{"class":171},[151,582,583],{"class":171}," async",[151,585,586],{"class":161}," Task",[151,588,589],{"class":161}," HandleAsyncTest_輸入符合預期的參數_傳回True",[151,591,181],{"class":157},[151,593,594],{"class":153,"line":184},[151,595,187],{"class":157},[151,597,598],{"class":153,"line":190},[151,599,194],{"class":193},[151,601,602,605,608,610,613,615,617],{"class":153,"line":197},[151,603,604],{"class":157},"    _generatorIdOutPort.",[151,606,607],{"class":161},"GenerateAsync",[151,609,511],{"class":157},[151,611,612],{"class":161},"Returns",[151,614,215],{"class":157},[151,616,218],{"class":161},[151,618,221],{"class":157},[151,620,621,624,627,630,633,635,637,640,642,644,647],{"class":153,"line":224},[151,622,623],{"class":157},"    _orderOutPort.",[151,625,626],{"class":161},"SaveAsync",[151,628,629],{"class":157},"(Arg.",[151,631,632],{"class":161},"Any",[151,634,261],{"class":157},[151,636,139],{"class":161},[151,638,639],{"class":157},">()).",[151,641,612],{"class":161},[151,643,365],{"class":157},[151,645,646],{"class":294},"true",[151,648,441],{"class":157},[151,650,651,654,656,658,660,662,664,666,669,671,673,675,677,679,682],{"class":153,"line":245},[151,652,653],{"class":157},"    _timeProvider.",[151,655,362],{"class":161},[151,657,365],{"class":157},[151,659,368],{"class":171},[151,661,371],{"class":161},[151,663,365],{"class":157},[151,665,368],{"class":171},[151,667,668],{"class":161}," DateTime",[151,670,365],{"class":157},[151,672,376],{"class":294},[151,674,298],{"class":157},[151,676,381],{"class":294},[151,678,298],{"class":157},[151,680,681],{"class":294},"20",[151,683,684],{"class":157},")));\n",[151,686,687],{"class":153,"line":270},[151,688,448],{"emptyLinePlaceholder":447},[151,690,691,693,695,697,699,701,703,705,708,710,713],{"class":153,"line":276},[151,692,200],{"class":171},[151,694,229],{"class":161},[151,696,206],{"class":171},[151,698,234],{"class":157},[151,700,212],{"class":161},[151,702,365],{"class":157},[151,704,368],{"class":171},[151,706,707],{"class":161}," Guid",[151,709,365],{"class":157},[151,711,712],{"class":422},"\"1AC0AE5E-E07E-4782-9E0B-F391FDFF26B3\"",[151,714,715],{"class":157},"));\n",[151,717,718,721,724,727,729,731,733],{"class":153,"line":307},[151,719,720],{"class":157},"    _memberOutPort.",[151,722,723],{"class":161},"IsExistAsync",[151,725,726],{"class":157},"(memberId).",[151,728,612],{"class":161},[151,730,365],{"class":157},[151,732,646],{"class":294},[151,734,441],{"class":157},[151,736,737],{"class":153,"line":332},[151,738,448],{"emptyLinePlaceholder":447},[151,740,741,743,745,747,749,751,753,755,757,759,761,763,765,767],{"class":153,"line":338},[151,742,200],{"class":171},[151,744,410],{"class":161},[151,746,206],{"class":171},[151,748,255],{"class":171},[151,750,417],{"class":161},[151,752,365],{"class":157},[151,754,423],{"class":422},[151,756,298],{"class":157},[151,758,428],{"class":422},[151,760,298],{"class":157},[151,762,433],{"class":422},[151,764,298],{"class":157},[151,766,438],{"class":422},[151,768,441],{"class":157},[151,770,771,773,775,777,779,781,783,785],{"class":153,"line":356},[151,772,200],{"class":171},[151,774,250],{"class":161},[151,776,206],{"class":171},[151,778,255],{"class":171},[151,780,258],{"class":161},[151,782,261],{"class":157},[151,784,264],{"class":161},[151,786,267],{"class":157},[151,788,789],{"class":153,"line":405},[151,790,273],{"class":157},[151,792,793,795,797,799,801,803,805,807,810,813,815,817,820],{"class":153,"line":444},[151,794,279],{"class":171},[151,796,282],{"class":157},[151,798,212],{"class":161},[151,800,365],{"class":157},[151,802,368],{"class":171},[151,804,707],{"class":161},[151,806,365],{"class":157},[151,808,809],{"class":422},"\"072AD23F-396B-445B-8627-1A560755ADAB\"",[151,811,812],{"class":157},")), ",[151,814,295],{"class":294},[151,816,298],{"class":157},[151,818,819],{"class":294},"10",[151,821,304],{"class":157},[151,823,824],{"class":153,"line":451},[151,825,335],{"class":157},[151,827,828],{"class":153,"line":469},[151,829,448],{"emptyLinePlaceholder":447},[151,831,832,834,837,839,842],{"class":153,"line":474},[151,833,200],{"class":171},[151,835,836],{"class":161}," sut",[151,838,206],{"class":171},[151,840,841],{"class":161}," GetSystemUnderTest",[151,843,353],{"class":157},[151,845,846],{"class":153,"line":480},[151,847,448],{"emptyLinePlaceholder":447},[151,849,850],{"class":153,"line":491},[151,851,477],{"class":193},[151,853,854,856,859,861,863],{"class":153,"line":496},[151,855,200],{"class":171},[151,857,858],{"class":161}," parameter",[151,860,206],{"class":171},[151,862,255],{"class":171},[151,864,865],{"class":161}," CreatOrderParameter\n",[151,867,868],{"class":153,"line":502},[151,869,273],{"class":157},[151,871,872,875,878],{"class":153,"line":520},[151,873,874],{"class":157},"        MemberId ",[151,876,877],{"class":171},"=",[151,879,880],{"class":157}," memberId,\n",[151,882,884,887,889],{"class":153,"line":883},24,[151,885,886],{"class":157},"        OrderItems ",[151,888,877],{"class":171},[151,890,891],{"class":157}," orderItems,\n",[151,893,895,898,900],{"class":153,"line":894},25,[151,896,897],{"class":157},"        ShipmentAddress ",[151,899,877],{"class":171},[151,901,902],{"class":157}," shipmentAddress\n",[151,904,906],{"class":153,"line":905},26,[151,907,335],{"class":157},[151,909,911,913,916,918,921,924,927],{"class":153,"line":910},27,[151,912,200],{"class":171},[151,914,915],{"class":161}," actual",[151,917,206],{"class":171},[151,919,920],{"class":171}," await",[151,922,923],{"class":157}," sut.",[151,925,926],{"class":161},"HandleAsync",[151,928,929],{"class":157},"(parameter);\n",[151,931,933],{"class":153,"line":932},28,[151,934,448],{"emptyLinePlaceholder":447},[151,936,938],{"class":153,"line":937},29,[151,939,499],{"class":193},[151,941,943,946,948,950,953],{"class":153,"line":942},30,[151,944,945],{"class":157},"    actual.",[151,947,508],{"class":161},[151,949,511],{"class":157},[151,951,952],{"class":161},"BeTrue",[151,954,353],{"class":157},[151,956,958,961,964,967,969,971,974,977,979,981,983,985],{"class":153,"line":957},31,[151,959,960],{"class":171},"    await",[151,962,963],{"class":157}," _domainEventBus.",[151,965,966],{"class":161},"Received",[151,968,365],{"class":157},[151,970,295],{"class":294},[151,972,973],{"class":157},").",[151,975,976],{"class":161},"DispatchDomainEventsAsync",[151,978,629],{"class":157},[151,980,632],{"class":161},[151,982,261],{"class":157},[151,984,139],{"class":161},[151,986,987],{"class":157},">());\n",[151,989,991],{"class":153,"line":990},32,[151,992,523],{"class":157},[10,994,995],{},"Assert 裡驗證了兩件事，分別對應兩種驗證方式：",[997,998,999,1008],"ul",{},[34,1000,1001,563,1004,1007],{},[19,1002,1003],{},"actual.Should().BeTrue()",[14,1005,1006],{},"狀態驗證","——看回傳值。",[34,1009,1010,563,1013,1016],{},[19,1011,1012],{},"_domainEventBus.Received(1)...",[14,1014,1015],{},"行為驗證","——看相依物件有沒有被正確呼叫。訂單建立成功後「應該發出 Domain Event」是業務規則的一部分，只看回傳值驗證不到。",[10,1018,1019,1020,1023],{},"反過來，失敗的情境就驗證「",[14,1021,1022],{},"不應該","發生什麼」：",[142,1025,1027],{"className":144,"code":1026,"language":146,"meta":147,"style":147},"[Fact]\npublic async Task HandleAsyncTest_儲存訂單失敗_應回傳False()\n{\n    \u002F\u002F Arrange\n    _orderOutPort.SaveAsync(Arg.Any\u003COrder>()).Returns(false);\n    \u002F\u002F ...其餘準備省略\n\n    \u002F\u002F Act\n    var actual = await sut.HandleAsync(parameter);\n\n    \u002F\u002F Assert\n    actual.Should().BeFalse();\n    await _domainEventBus.DidNotReceive().DispatchDomainEventsAsync(Arg.Any\u003COrder>());\n}\n",[19,1028,1029,1037,1050,1054,1058,1083,1088,1092,1096,1112,1116,1120,1133,1156],{"__ignoreMap":147},[151,1030,1031,1033,1035],{"class":153,"line":154},[151,1032,158],{"class":157},[151,1034,162],{"class":161},[151,1036,165],{"class":157},[151,1038,1039,1041,1043,1045,1048],{"class":153,"line":168},[151,1040,172],{"class":171},[151,1042,583],{"class":171},[151,1044,586],{"class":161},[151,1046,1047],{"class":161}," HandleAsyncTest_儲存訂單失敗_應回傳False",[151,1049,181],{"class":157},[151,1051,1052],{"class":153,"line":184},[151,1053,187],{"class":157},[151,1055,1056],{"class":153,"line":190},[151,1057,194],{"class":193},[151,1059,1060,1062,1064,1066,1068,1070,1072,1074,1076,1078,1081],{"class":153,"line":197},[151,1061,623],{"class":157},[151,1063,626],{"class":161},[151,1065,629],{"class":157},[151,1067,632],{"class":161},[151,1069,261],{"class":157},[151,1071,139],{"class":161},[151,1073,639],{"class":157},[151,1075,612],{"class":161},[151,1077,365],{"class":157},[151,1079,1080],{"class":294},"false",[151,1082,441],{"class":157},[151,1084,1085],{"class":153,"line":224},[151,1086,1087],{"class":193},"    \u002F\u002F ...其餘準備省略\n",[151,1089,1090],{"class":153,"line":245},[151,1091,448],{"emptyLinePlaceholder":447},[151,1093,1094],{"class":153,"line":270},[151,1095,477],{"class":193},[151,1097,1098,1100,1102,1104,1106,1108,1110],{"class":153,"line":276},[151,1099,200],{"class":171},[151,1101,915],{"class":161},[151,1103,206],{"class":171},[151,1105,920],{"class":171},[151,1107,923],{"class":157},[151,1109,926],{"class":161},[151,1111,929],{"class":157},[151,1113,1114],{"class":153,"line":307},[151,1115,448],{"emptyLinePlaceholder":447},[151,1117,1118],{"class":153,"line":332},[151,1119,499],{"class":193},[151,1121,1122,1124,1126,1128,1131],{"class":153,"line":338},[151,1123,945],{"class":157},[151,1125,508],{"class":161},[151,1127,511],{"class":157},[151,1129,1130],{"class":161},"BeFalse",[151,1132,353],{"class":157},[151,1134,1135,1137,1139,1142,1144,1146,1148,1150,1152,1154],{"class":153,"line":356},[151,1136,960],{"class":171},[151,1138,963],{"class":157},[151,1140,1141],{"class":161},"DidNotReceive",[151,1143,511],{"class":157},[151,1145,976],{"class":161},[151,1147,629],{"class":157},[151,1149,632],{"class":161},[151,1151,261],{"class":157},[151,1153,139],{"class":161},[151,1155,987],{"class":157},[151,1157,1158],{"class":153,"line":405},[151,1159,523],{"class":157},[10,1161,1162,1163,1166],{},"存檔失敗卻還發出「訂單已建立」的事件，下游就會處理一張根本不存在的訂單——",[19,1164,1165],{},"DidNotReceive()"," 守住的正是這種 bug。",[130,1168,1170],{"id":1169},"範例三驗證例外","範例三：驗證例外",[10,1172,1173,1174,1177],{},"預期會拋例外的測試，Act 階段要注意：",[14,1175,1176],{},"不能直接呼叫方法","，否則例外在 Act 就炸掉了，根本走不到 Assert。做法是把呼叫包成委派（delegate），交給 Assert 去執行：",[142,1179,1181],{"className":144,"code":1180,"language":146,"meta":147,"style":147},"[Fact]\npublic async Task HandleAsyncTest_輸入的會員Id不存在_拋出MemberNotFoundException()\n{\n    \u002F\u002F Arrange\n    var memberId = MemberId.New(new Guid(\"1AC0AE5E-E07E-4782-9E0B-F391FDFF26B3\"));\n    _memberOutPort.IsExistAsync(memberId).Returns(false);\n    var sut = GetSystemUnderTest();\n\n    \u002F\u002F Act\n    var actual = () => sut.HandleAsync(new CreatOrderParameter\n    {\n        MemberId = memberId,\n        OrderItems = orderItems,\n    });\n\n    \u002F\u002F Assert\n    await actual.Should().ThrowAsync\u003CMemberNotFoundException>();\n}\n",[19,1182,1183,1191,1204,1208,1212,1236,1252,1264,1268,1272,1296,1300,1308,1316,1321,1325,1329,1351],{"__ignoreMap":147},[151,1184,1185,1187,1189],{"class":153,"line":154},[151,1186,158],{"class":157},[151,1188,162],{"class":161},[151,1190,165],{"class":157},[151,1192,1193,1195,1197,1199,1202],{"class":153,"line":168},[151,1194,172],{"class":171},[151,1196,583],{"class":171},[151,1198,586],{"class":161},[151,1200,1201],{"class":161}," HandleAsyncTest_輸入的會員Id不存在_拋出MemberNotFoundException",[151,1203,181],{"class":157},[151,1205,1206],{"class":153,"line":184},[151,1207,187],{"class":157},[151,1209,1210],{"class":153,"line":190},[151,1211,194],{"class":193},[151,1213,1214,1216,1218,1220,1222,1224,1226,1228,1230,1232,1234],{"class":153,"line":197},[151,1215,200],{"class":171},[151,1217,229],{"class":161},[151,1219,206],{"class":171},[151,1221,234],{"class":157},[151,1223,212],{"class":161},[151,1225,365],{"class":157},[151,1227,368],{"class":171},[151,1229,707],{"class":161},[151,1231,365],{"class":157},[151,1233,712],{"class":422},[151,1235,715],{"class":157},[151,1237,1238,1240,1242,1244,1246,1248,1250],{"class":153,"line":224},[151,1239,720],{"class":157},[151,1241,723],{"class":161},[151,1243,726],{"class":157},[151,1245,612],{"class":161},[151,1247,365],{"class":157},[151,1249,1080],{"class":294},[151,1251,441],{"class":157},[151,1253,1254,1256,1258,1260,1262],{"class":153,"line":245},[151,1255,200],{"class":171},[151,1257,836],{"class":161},[151,1259,206],{"class":171},[151,1261,841],{"class":161},[151,1263,353],{"class":157},[151,1265,1266],{"class":153,"line":270},[151,1267,448],{"emptyLinePlaceholder":447},[151,1269,1270],{"class":153,"line":276},[151,1271,477],{"class":193},[151,1273,1274,1276,1278,1280,1283,1286,1288,1290,1292,1294],{"class":153,"line":307},[151,1275,200],{"class":171},[151,1277,915],{"class":161},[151,1279,206],{"class":171},[151,1281,1282],{"class":157}," () ",[151,1284,1285],{"class":171},"=>",[151,1287,923],{"class":157},[151,1289,926],{"class":161},[151,1291,365],{"class":157},[151,1293,368],{"class":171},[151,1295,865],{"class":161},[151,1297,1298],{"class":153,"line":332},[151,1299,273],{"class":157},[151,1301,1302,1304,1306],{"class":153,"line":338},[151,1303,874],{"class":157},[151,1305,877],{"class":171},[151,1307,880],{"class":157},[151,1309,1310,1312,1314],{"class":153,"line":356},[151,1311,886],{"class":157},[151,1313,877],{"class":171},[151,1315,891],{"class":157},[151,1317,1318],{"class":153,"line":405},[151,1319,1320],{"class":157},"    });\n",[151,1322,1323],{"class":153,"line":444},[151,1324,448],{"emptyLinePlaceholder":447},[151,1326,1327],{"class":153,"line":451},[151,1328,499],{"class":193},[151,1330,1331,1333,1336,1338,1340,1343,1345,1348],{"class":153,"line":469},[151,1332,960],{"class":171},[151,1334,1335],{"class":157}," actual.",[151,1337,508],{"class":161},[151,1339,511],{"class":157},[151,1341,1342],{"class":161},"ThrowAsync",[151,1344,261],{"class":157},[151,1346,1347],{"class":161},"MemberNotFoundException",[151,1349,1350],{"class":157},">();\n",[151,1352,1353],{"class":153,"line":474},[151,1354,523],{"class":157},[10,1356,1357,1358,1361,1362,1364,1365,1368,1369,1371],{},"驗證的是",[14,1359,1360],{},"具體的例外型別","（",[19,1363,1347],{},"），而不是籠統的 ",[19,1366,1367],{},"Exception","。這樣「會員不存在」跟「訂單項目為空」兩條規則才不會互相掩護——如果都只驗 ",[19,1370,1367],{},"，其中一條邏輯被拿掉、改由另一個錯誤拋出，測試依然會綠燈。",[130,1373,1375],{"id":1374},"把-arrange-的重複部分抽出來","把 Arrange 的重複部分抽出來",[10,1377,1378],{},"同一個測試類別裡，每個測試都要建立一樣的 Mock 跟被測物件。我習慣這樣分工：",[142,1380,1382],{"className":144,"code":1381,"language":146,"meta":147,"style":147},"public class CreateOrderServiceTest\n{\n    private readonly IGeneratorIdOutPort _generatorIdOutPort;\n    private readonly IOrderOutPort _orderOutPort;\n    private readonly IMemberOutPort _memberOutPort;\n    private readonly IDomainEventBus _domainEventBus;\n    private readonly FakeTimeProvider _timeProvider;\n\n    \u002F\u002F xUnit 每個測試都會 new 一次測試類別，所以每個測試拿到的都是全新的替身\n    public CreateOrderServiceTest()\n    {\n        _timeProvider = new FakeTimeProvider();\n        _generatorIdOutPort = Substitute.For\u003CIGeneratorIdOutPort>();\n        _orderOutPort = Substitute.For\u003CIOrderOutPort>();\n        _memberOutPort = Substitute.For\u003CIMemberOutPort>();\n        _domainEventBus = Substitute.For\u003CIDomainEventBus>();\n    }\n\n    private ICreateOrderService GetSystemUnderTest()\n    {\n        return new CreateOrderService(\n            _generatorIdOutPort, _timeProvider, _orderOutPort, _domainEventBus, _memberOutPort);\n    }\n}\n",[19,1383,1384,1394,1398,1415,1429,1443,1457,1470,1474,1479,1489,1493,1506,1526,1544,1562,1580,1585,1589,1600,1604,1617,1622,1626],{"__ignoreMap":147},[151,1385,1386,1388,1391],{"class":153,"line":154},[151,1387,172],{"class":171},[151,1389,1390],{"class":171}," class",[151,1392,1393],{"class":161}," CreateOrderServiceTest\n",[151,1395,1396],{"class":153,"line":168},[151,1397,187],{"class":157},[151,1399,1400,1403,1406,1409,1412],{"class":153,"line":184},[151,1401,1402],{"class":171},"    private",[151,1404,1405],{"class":171}," readonly",[151,1407,1408],{"class":161}," IGeneratorIdOutPort",[151,1410,1411],{"class":161}," _generatorIdOutPort",[151,1413,1414],{"class":157},";\n",[151,1416,1417,1419,1421,1424,1427],{"class":153,"line":190},[151,1418,1402],{"class":171},[151,1420,1405],{"class":171},[151,1422,1423],{"class":161}," IOrderOutPort",[151,1425,1426],{"class":161}," _orderOutPort",[151,1428,1414],{"class":157},[151,1430,1431,1433,1435,1438,1441],{"class":153,"line":197},[151,1432,1402],{"class":171},[151,1434,1405],{"class":171},[151,1436,1437],{"class":161}," IMemberOutPort",[151,1439,1440],{"class":161}," _memberOutPort",[151,1442,1414],{"class":157},[151,1444,1445,1447,1449,1452,1455],{"class":153,"line":224},[151,1446,1402],{"class":171},[151,1448,1405],{"class":171},[151,1450,1451],{"class":161}," IDomainEventBus",[151,1453,1454],{"class":161}," _domainEventBus",[151,1456,1414],{"class":157},[151,1458,1459,1461,1463,1465,1468],{"class":153,"line":245},[151,1460,1402],{"class":171},[151,1462,1405],{"class":171},[151,1464,350],{"class":161},[151,1466,1467],{"class":161}," _timeProvider",[151,1469,1414],{"class":157},[151,1471,1472],{"class":153,"line":270},[151,1473,448],{"emptyLinePlaceholder":447},[151,1475,1476],{"class":153,"line":276},[151,1477,1478],{"class":193},"    \u002F\u002F xUnit 每個測試都會 new 一次測試類別，所以每個測試拿到的都是全新的替身\n",[151,1480,1481,1484,1487],{"class":153,"line":307},[151,1482,1483],{"class":171},"    public",[151,1485,1486],{"class":161}," CreateOrderServiceTest",[151,1488,181],{"class":157},[151,1490,1491],{"class":153,"line":332},[151,1492,273],{"class":157},[151,1494,1495,1498,1500,1502,1504],{"class":153,"line":338},[151,1496,1497],{"class":157},"        _timeProvider ",[151,1499,877],{"class":171},[151,1501,255],{"class":171},[151,1503,350],{"class":161},[151,1505,353],{"class":157},[151,1507,1508,1511,1513,1516,1519,1521,1524],{"class":153,"line":356},[151,1509,1510],{"class":157},"        _generatorIdOutPort ",[151,1512,877],{"class":171},[151,1514,1515],{"class":157}," Substitute.",[151,1517,1518],{"class":161},"For",[151,1520,261],{"class":157},[151,1522,1523],{"class":161},"IGeneratorIdOutPort",[151,1525,1350],{"class":157},[151,1527,1528,1531,1533,1535,1537,1539,1542],{"class":153,"line":405},[151,1529,1530],{"class":157},"        _orderOutPort ",[151,1532,877],{"class":171},[151,1534,1515],{"class":157},[151,1536,1518],{"class":161},[151,1538,261],{"class":157},[151,1540,1541],{"class":161},"IOrderOutPort",[151,1543,1350],{"class":157},[151,1545,1546,1549,1551,1553,1555,1557,1560],{"class":153,"line":444},[151,1547,1548],{"class":157},"        _memberOutPort ",[151,1550,877],{"class":171},[151,1552,1515],{"class":157},[151,1554,1518],{"class":161},[151,1556,261],{"class":157},[151,1558,1559],{"class":161},"IMemberOutPort",[151,1561,1350],{"class":157},[151,1563,1564,1567,1569,1571,1573,1575,1578],{"class":153,"line":451},[151,1565,1566],{"class":157},"        _domainEventBus ",[151,1568,877],{"class":171},[151,1570,1515],{"class":157},[151,1572,1518],{"class":161},[151,1574,261],{"class":157},[151,1576,1577],{"class":161},"IDomainEventBus",[151,1579,1350],{"class":157},[151,1581,1582],{"class":153,"line":469},[151,1583,1584],{"class":157},"    }\n",[151,1586,1587],{"class":153,"line":474},[151,1588,448],{"emptyLinePlaceholder":447},[151,1590,1591,1593,1596,1598],{"class":153,"line":480},[151,1592,1402],{"class":171},[151,1594,1595],{"class":161}," ICreateOrderService",[151,1597,841],{"class":161},[151,1599,181],{"class":157},[151,1601,1602],{"class":153,"line":491},[151,1603,273],{"class":157},[151,1605,1606,1609,1611,1614],{"class":153,"line":496},[151,1607,1608],{"class":171},"        return",[151,1610,255],{"class":171},[151,1612,1613],{"class":161}," CreateOrderService",[151,1615,1616],{"class":157},"(\n",[151,1618,1619],{"class":153,"line":502},[151,1620,1621],{"class":157},"            _generatorIdOutPort, _timeProvider, _orderOutPort, _domainEventBus, _memberOutPort);\n",[151,1623,1624],{"class":153,"line":520},[151,1625,1584],{"class":157},[151,1627,1628],{"class":153,"line":883},[151,1629,523],{"class":157},[997,1631,1632,1638,1652],{},[34,1633,1634,1637],{},[14,1635,1636],{},"建構子","：只負責「建立」替身，不設定行為。",[34,1639,1640,1643,1644,1647,1648,1651],{},[14,1641,1642],{},"每個測試的 Arrange","：才設定這個情境需要的回傳值（",[19,1645,1646],{},"Returns(true)"," 或 ",[19,1649,1650],{},"Returns(false)","）。",[34,1653,1654,1659],{},[14,1655,1656],{},[19,1657,1658],{},"GetSystemUnderTest()","：集中建立被測物件。之後建構子多了一個參數，只需要改這一個地方，不用每個測試都改。",[10,1661,1662,1663,1666],{},"「情境差異」留在每個測試裡，讀測試時才看得出這個案例跟其他案例差在哪；如果把 ",[19,1664,1665],{},"Returns(...)"," 也搬進建構子，每個測試看起來都一樣，差異反而被藏起來了。",[130,1668,1670],{"id":1669},"_3a-的幾條守則","3A 的幾條守則",[31,1672,1673,1679,1685,1691],{},[34,1674,1675,1678],{},[14,1676,1677],{},"Act 只有一個動作。"," 如果 Act 裡呼叫了兩個方法，通常代表這其實是兩個測試。",[34,1680,1681,1684],{},[14,1682,1683],{},"Assert 驗證同一件事。"," 可以有多行 Assert（例如同時檢查回傳值與事件），但它們應該都在描述「同一個行為的結果」。",[34,1686,1687,1690],{},[14,1688,1689],{},"三段不交錯。"," 不要 Arrange → Act → Assert → 再 Act → 再 Assert，那是一個流程測試，不是單元測試。",[34,1692,1693,1696,1697,1700,1701,1704,1705,1708],{},[14,1694,1695],{},"變數命名表達角色。"," 被測物件叫 ",[19,1698,1699],{},"sut","（System Under Test），結果叫 ",[19,1702,1703],{},"actual","，預期值叫 ",[19,1706,1707],{},"expected","——讀的人一看就知道哪個是哪個。",[50,1710,1712],{"id":1711},"測試方法的命名方法_情境_預期結果","測試方法的命名：方法_情境_預期結果",[10,1714,1715],{},"我採用的命名格式是：",[142,1717,1722],{"className":1718,"code":1720,"language":1721},[1719],"language-text","被測方法_情境（輸入或前置狀態）_預期結果\n","text",[19,1723,1720],{"__ignoreMap":147},[10,1725,1726,1727,1730],{},"這也是 Roy Osherove 在《The Art of Unit Testing》提出的 ",[19,1728,1729],{},"UnitOfWork_StateUnderTest_ExpectedBehavior"," 格式。套到專案裡的實際例子：",[58,1732,1733,1749],{},[61,1734,1735],{},[64,1736,1737,1740,1743,1746],{},[67,1738,1739],{"align":69},"測試方法名稱",[67,1741,1742],{"align":69},"被測方法",[67,1744,1745],{"align":69},"情境",[67,1747,1748],{"align":69},"預期結果",[78,1750,1751,1769,1786,1804,1821],{},[64,1752,1753,1758,1763,1766],{},[83,1754,1755],{"align":69},[19,1756,1757],{},"OrderCreate_輸入符合預期的參數_傳回訂單",[83,1759,1760],{"align":69},[19,1761,1762],{},"Order.New",[83,1764,1765],{"align":69},"參數正確",[83,1767,1768],{"align":69},"回傳訂單",[64,1770,1771,1776,1780,1783],{},[83,1772,1773],{"align":69},[19,1774,1775],{},"OrderCreate_輸入訂單項目為空_應拋出OrderDomainException",[83,1777,1778],{"align":69},[19,1779,1762],{},[83,1781,1782],{"align":69},"沒有訂單項目",[83,1784,1785],{"align":69},"拋出領域例外",[64,1787,1788,1793,1798,1801],{},[83,1789,1790],{"align":69},[19,1791,1792],{},"ConfirmPayment_訂單已付款_應更新訂單狀態為已付款",[83,1794,1795],{"align":69},[19,1796,1797],{},"ConfirmPayment",[83,1799,1800],{"align":69},"付款確認",[83,1802,1803],{"align":69},"狀態變為已付款",[64,1805,1806,1811,1815,1818],{},[83,1807,1808],{"align":69},[19,1809,1810],{},"HandleAsyncTest_輸入的會員Id不存在_拋出MemberNotFoundException",[83,1812,1813],{"align":69},[19,1814,926],{},[83,1816,1817],{"align":69},"會員不存在",[83,1819,1820],{"align":69},"拋出例外",[64,1822,1823,1828,1832,1835],{},[83,1824,1825],{"align":69},[19,1826,1827],{},"HandleAsyncTest_儲存訂單失敗_應回傳False",[83,1829,1830],{"align":69},[19,1831,926],{},[83,1833,1834],{"align":69},"存檔失敗",[83,1836,1837],{"align":69},"回傳 False",[10,1839,1840,1841,1844,1845,1847],{},"把同一個類別的測試名稱列在一起，其實就是",[14,1842,1843],{},"這個類別的規格書","。在 Test Explorer 裡看到紅燈，例如 ",[19,1846,1827],{}," 失敗，不用打開程式碼就知道：存檔失敗時的處理邏輯被改壞了。",[130,1849,1851],{"id":1850},"為什麼用中文命名","為什麼用中文命名？",[10,1853,1854],{},"C# 的識別字支援 Unicode，測試方法用中文命名完全合法。我在情境與預期結果使用中文，理由是：",[997,1856,1857,1863,1869],{},[34,1858,1859,1862],{},[14,1860,1861],{},"業務規則本來就是用中文討論的。"," 「會員不存在」「訂單項目為空」直接對應需求文件與跟 PM 的對話，不需要在腦中翻譯一次。",[34,1864,1865,1868],{},[14,1866,1867],{},"測試方法不會被其他程式碼呼叫。"," 它不是 API，不用顧慮呼叫端的打字體驗。",[34,1870,1871,1874],{},[14,1872,1873],{},"被測方法名稱保留英文。"," 第一段是程式碼裡的方法名稱，保持原樣，方便搜尋與對照。",[10,1876,1877,1878,1881,1882,1885],{},"如果團隊有非中文使用者，或 CI 環境對 Unicode 支援不佳，改用英文也完全沒問題，例如 ",[19,1879,1880],{},"HandleAsyncTest_MemberNotFound_ThrowsMemberNotFoundException","——重點是",[14,1883,1884],{},"格式一致","，語言是團隊的選擇。",[130,1887,1888],{"id":1888},"命名的幾個細節",[10,1890,1891,1894,1895,22,1898,1901,1902,22,1905,1908],{},[14,1892,1893],{},"1. 預期結果要具體。"," ",[19,1896,1897],{},"傳回True",[19,1899,1900],{},"拋出MemberNotFoundException"," 比 ",[19,1903,1904],{},"成功",[19,1906,1907],{},"失敗"," 好。「失敗」可能是回傳 false、拋例外、還是回傳 null？名稱要能回答這個問題。",[10,1910,1911,1894,1914,1917,1918,1921],{},[14,1912,1913],{},"2. 情境描述「條件」，不是描述「測試步驟」。",[19,1915,1916],{},"輸入的會員Id不存在"," 是條件；",[19,1919,1920],{},"呼叫IsExistAsync回傳false後再呼叫HandleAsync"," 是步驟，後者把實作細節寫進名稱，實作一改名稱就過時。",[10,1923,1924,1894,1927,1930,1931,1934,1935,1930,1937,1940,1941,1944,1945,1948],{},[14,1925,1926],{},"3. 類別名稱對應被測類別。",[19,1928,1929],{},"CreateOrderService"," 的測試放在 ",[19,1932,1933],{},"CreateOrderServiceTest","，",[19,1936,139],{},[19,1938,1939],{},"OrderTest","。專案結構也對稱：",[19,1942,1943],{},"OrderService.UseCase"," 對應 ",[19,1946,1947],{},"OrderService.UseCase.Tests","。找測試的時候不用猜。",[130,1950,1951],{"id":1951},"其他語言的命名慣例",[10,1953,1954],{},"不同語言的測試框架有各自的限制與慣例，但「方法、情境、預期」三個要素是共通的：",[10,1956,1957,1960,1961,1964,1965,1968],{},[14,1958,1959],{},"Go","：測試函式必須以 ",[19,1962,1963],{},"Test"," 開頭，常見寫法是 ",[19,1966,1967],{},"Test方法_情境","，並在註解補充說明：",[142,1970,1974],{"className":1971,"code":1972,"language":1973,"meta":147,"style":147},"language-go shiki shiki-themes github-dark","\u002F\u002F TestCreateRedis_Success 測試成功建立 Redis 申請\nfunc TestCreateRedis_Success(t *testing.T) { ... }\n\nfunc TestCreateProject_ProjectNameTooLong(t *testing.T) { ... }\n","go",[19,1975,1976,1981,2016,2020],{"__ignoreMap":147},[151,1977,1978],{"class":153,"line":154},[151,1979,1980],{"class":193},"\u002F\u002F TestCreateRedis_Success 測試成功建立 Redis 申請\n",[151,1982,1983,1986,1989,1991,1995,1998,2001,2004,2007,2010,2013],{"class":153,"line":168},[151,1984,1985],{"class":171},"func",[151,1987,1988],{"class":161}," TestCreateRedis_Success",[151,1990,365],{"class":157},[151,1992,1994],{"class":1993},"s9osk","t",[151,1996,1997],{"class":171}," *",[151,1999,2000],{"class":161},"testing",[151,2002,2003],{"class":157},".",[151,2005,2006],{"class":161},"T",[151,2008,2009],{"class":157},") { ",[151,2011,2012],{"class":171},"...",[151,2014,2015],{"class":157}," }\n",[151,2017,2018],{"class":153,"line":184},[151,2019,448],{"emptyLinePlaceholder":447},[151,2021,2022,2024,2027,2029,2031,2033,2035,2037,2039,2041,2043],{"class":153,"line":190},[151,2023,1985],{"class":171},[151,2025,2026],{"class":161}," TestCreateProject_ProjectNameTooLong",[151,2028,365],{"class":157},[151,2030,1994],{"class":1993},[151,2032,1997],{"class":171},[151,2034,2000],{"class":161},[151,2036,2003],{"class":157},[151,2038,2006],{"class":161},[151,2040,2009],{"class":157},[151,2042,2012],{"class":171},[151,2044,2015],{"class":157},[10,2046,2047,2048,2051,2052,2055],{},"情境比較多時，Go 社群更常用 table-driven test，把情境寫在 ",[19,2049,2050],{},"name"," 欄位，用 ",[19,2053,2054],{},"t.Run"," 跑子測試：",[142,2057,2059],{"className":1971,"code":2058,"language":1973,"meta":147,"style":147},"tests := []struct {\n    name    string\n    input   string\n    wantErr bool\n}{\n    {name: \"專案名稱超過長度上限_回傳錯誤\", input: strings.Repeat(\"a\", maxNameLength+1), wantErr: true},\n    {name: \"專案名稱正常_不回傳錯誤\", input: \"order\", wantErr: false},\n}\nfor _, tt := range tests {\n    t.Run(tt.name, func(t *testing.T) { ... })\n}\n",[19,2060,2061,2078,2086,2093,2101,2106,2141,2161,2165,2181,2213],{"__ignoreMap":147},[151,2062,2063,2066,2069,2072,2075],{"class":153,"line":154},[151,2064,2065],{"class":157},"tests ",[151,2067,2068],{"class":171},":=",[151,2070,2071],{"class":157}," []",[151,2073,2074],{"class":171},"struct",[151,2076,2077],{"class":157}," {\n",[151,2079,2080,2083],{"class":153,"line":168},[151,2081,2082],{"class":157},"    name    ",[151,2084,2085],{"class":171},"string\n",[151,2087,2088,2091],{"class":153,"line":184},[151,2089,2090],{"class":157},"    input   ",[151,2092,2085],{"class":171},[151,2094,2095,2098],{"class":153,"line":190},[151,2096,2097],{"class":157},"    wantErr ",[151,2099,2100],{"class":171},"bool\n",[151,2102,2103],{"class":153,"line":197},[151,2104,2105],{"class":157},"}{\n",[151,2107,2108,2111,2114,2117,2120,2122,2125,2128,2131,2133,2136,2138],{"class":153,"line":224},[151,2109,2110],{"class":157},"    {name: ",[151,2112,2113],{"class":422},"\"專案名稱超過長度上限_回傳錯誤\"",[151,2115,2116],{"class":157},", input: strings.",[151,2118,2119],{"class":161},"Repeat",[151,2121,365],{"class":157},[151,2123,2124],{"class":422},"\"a\"",[151,2126,2127],{"class":157},", maxNameLength",[151,2129,2130],{"class":171},"+",[151,2132,295],{"class":294},[151,2134,2135],{"class":157},"), wantErr: ",[151,2137,646],{"class":294},[151,2139,2140],{"class":157},"},\n",[151,2142,2143,2145,2148,2151,2154,2157,2159],{"class":153,"line":245},[151,2144,2110],{"class":157},[151,2146,2147],{"class":422},"\"專案名稱正常_不回傳錯誤\"",[151,2149,2150],{"class":157},", input: ",[151,2152,2153],{"class":422},"\"order\"",[151,2155,2156],{"class":157},", wantErr: ",[151,2158,1080],{"class":294},[151,2160,2140],{"class":157},[151,2162,2163],{"class":153,"line":270},[151,2164,523],{"class":157},[151,2166,2167,2170,2173,2175,2178],{"class":153,"line":276},[151,2168,2169],{"class":171},"for",[151,2171,2172],{"class":157}," _, tt ",[151,2174,2068],{"class":171},[151,2176,2177],{"class":171}," range",[151,2179,2180],{"class":157}," tests {\n",[151,2182,2183,2186,2189,2192,2194,2196,2198,2200,2202,2204,2206,2208,2210],{"class":153,"line":307},[151,2184,2185],{"class":157},"    t.",[151,2187,2188],{"class":161},"Run",[151,2190,2191],{"class":157},"(tt.name, ",[151,2193,1985],{"class":171},[151,2195,365],{"class":157},[151,2197,1994],{"class":1993},[151,2199,1997],{"class":171},[151,2201,2000],{"class":161},[151,2203,2003],{"class":157},[151,2205,2006],{"class":161},[151,2207,2009],{"class":157},[151,2209,2012],{"class":171},[151,2211,2212],{"class":157}," })\n",[151,2214,2215],{"class":153,"line":332},[151,2216,523],{"class":157},[10,2218,2219,2222],{},[14,2220,2221],{},"JavaScript \u002F TypeScript（Jest、Vitest）","：用字串描述，天生適合寫成句子：",[142,2224,2228],{"className":2225,"code":2226,"language":2227,"meta":147,"style":147},"language-ts shiki shiki-themes github-dark","describe('CreateOrderService.handle', () => {\n  it('會員不存在時，應拋出 MemberNotFoundError', async () => { ... })\n})\n","ts",[19,2229,2230,2247,2273],{"__ignoreMap":147},[151,2231,2232,2235,2237,2240,2243,2245],{"class":153,"line":154},[151,2233,2234],{"class":161},"describe",[151,2236,365],{"class":157},[151,2238,2239],{"class":422},"'CreateOrderService.handle'",[151,2241,2242],{"class":157},", () ",[151,2244,1285],{"class":171},[151,2246,2077],{"class":157},[151,2248,2249,2252,2254,2257,2259,2262,2264,2266,2269,2271],{"class":153,"line":168},[151,2250,2251],{"class":161},"  it",[151,2253,365],{"class":157},[151,2255,2256],{"class":422},"'會員不存在時，應拋出 MemberNotFoundError'",[151,2258,298],{"class":157},[151,2260,2261],{"class":171},"async",[151,2263,1282],{"class":157},[151,2265,1285],{"class":171},[151,2267,2268],{"class":157}," { ",[151,2270,2012],{"class":171},[151,2272,2212],{"class":157},[151,2274,2275],{"class":153,"line":184},[151,2276,2277],{"class":157},"})\n",[50,2279,2280],{"id":2280},"小結",[997,2282,2283,2289,2295,2301,2307],{},[34,2284,2285,2288],{},[14,2286,2287],{},"3A 給測試一個固定的閱讀順序","：先看 Act 知道測哪個方法，再看 Assert 知道預期什麼，最後才需要看 Arrange 的細節。",[34,2290,2291,2294],{},[14,2292,2293],{},"Act 只做一件事","，例外要包成委派再交給 Assert 驗證。",[34,2296,2297,2300],{},[14,2298,2299],{},"Assert 同時考慮狀態與行為","：回傳值對了，該發的事件有沒有發、不該發的有沒有擋住。",[34,2302,2303,2306],{},[14,2304,2305],{},"命名用「方法_情境_預期結果」","，整個測試類別的方法名稱列出來就是一份規格書。",[34,2308,2309,2312],{},[14,2310,2311],{},"名稱要具體、描述條件而非步驟","，讓紅燈本身就能說明是哪條業務規則壞了。",[10,2314,2315],{},"測試程式碼也是程式碼，而且是最常被「讀」的那一種：每次紅燈、每次重構、每次新人接手，都會有人回來讀它。花一點力氣讓它好讀，回報會比想像中大。",[2317,2318,2319],"style",{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":147,"searchDepth":168,"depth":168,"links":2321},[2322,2329,2334],{"id":52,"depth":168,"text":53,"children":2323},[2324,2325,2326,2327,2328],{"id":132,"depth":184,"text":133},{"id":555,"depth":184,"text":556},{"id":1169,"depth":184,"text":1170},{"id":1374,"depth":184,"text":1375},{"id":1669,"depth":184,"text":1670},{"id":1711,"depth":168,"text":1712,"children":2330},[2331,2332,2333],{"id":1850,"depth":184,"text":1851},{"id":1888,"depth":184,"text":1888},{"id":1951,"depth":184,"text":1951},{"id":2280,"depth":168,"text":2280},"2023-12-05","寫單元測試時，最常遇到的問題通常不是「不會寫」，而是三個月後回來看，看不懂當初在測什麼。測試失敗時，紅燈只告訴你一個方法名稱；如果名稱是 Test1、CreateOrderTest2，還得把整段測試讀完、甚至 debug，才知道是哪個業務規則壞了。",null,"md",{},"\u002Farticles\u002Funit-test-aaa-naming",{"title":5,"description":2336},"articles\u002Funit-test-aaa-naming",[2344,2345,2346,2347,2348],"Unit Test","TDD","C#","xUnit","測試","sBCGX5Jqr4Wqlw9D61SDDMc9AYCQ7kWkPPY7XDt2DjI",1792959150470]