行為驅動開發(BDD) - 一個快速的描述和示例
BDD表示乙 ehavior ð里文ð才有發展。用於描述行為的語法是Gherkin。
這個想法是盡可能自然地描述一種語言應該發生什么。
如果你熟悉單元測試,並且很容易編寫單元測試,那么你熟悉它們的閱讀方式。根據測試需要覆蓋的程度,可以很難弄清楚它的作用,因為畢竟只是代碼。
只有開發人員才能真正了解那里發生了什么。
BDD以不同的方式解決問題。
讓我們來隱藏代碼,開始一個對話,所以現在任何人都可以閱讀一個場景並了解它的測試。
舉一個例子:
給定第一個數字4
和第二個數字3
當添加兩個數字時
那么結果是7
這里沒有代碼。這種情況可以像故事一樣閱讀。我們可以將它交給業務分析師,以確保我們正在處理正確的事情,或者將其提供給測試人員,或者稍后重新審視,並重新記住事情需要如何工作,為什么我們建立一定的事情辦法。
您是否跟上新的開發人員技術?通過我們的免費開發者雜志,涵蓋C#,模式,.NET Core,MVC,Azure,Angular,React等等,提升您的IT職業生涯。免費訂閱我們的雜志,並下載所有以前的,即將到來的版本。
我們在這里描述一些行為,在這種情況下,它可能是一個數學運算子系統,我們已經明確定義了該系統的一個行為。當然,更多的測試將被寫入以覆蓋整個行為並處理邊緣情況。
如果這一切都開始聽起來像寫單元測試,那就是一件好事。
BDD和單元測試在某些方面是相似的,不妨礙開發人員使用這兩者,如果這是合適的。
使用Gherkin語法可以很容易地解釋什么是以自然語言進行測試,甚至非開發人員可以閱讀和理解。
例如,質量保證人員或業務分析師可以復制和粘貼此類測試,更改數字並提供自己的測試用例,而無需編寫任何代碼,甚至不會看到代碼。
如果您對細節感興趣,這是一個非常好的寫在小黃瓜:https://github.com/cucumber/cucumber/wiki/Gherkin
現在我們進行了測試,從這里開始如何工作?
測試中的每一行都稱為步驟,每一步都將成為一個單獨的方法,每個方法都按照寫入的順序進行調用。
在我們的示例中,前兩行(給定和And)將設置初始數據,“ 什么時候”將會調用我們要測試的方法,然后Then將發生assert。
由於每個步驟是一個單獨的方法,希望現在很明顯,我們需要能夠在步驟之間共享一些狀態。
不要擔心,這不是你想象的狀態,它不會破壞任何測試原則,特別是說測試不應該改變狀態或者應該依賴於另一個測試創建的狀態。
這只是意味着每個測試需要能夠擁有自己的狀態,並且該測試中的每個步驟需要該狀態。
Specflow給了我們一個ScenarioContext,它只是一個字典,用於存儲執行測試所需的數據。此上下文在測試結束時被清除,並在下一次測試運行時再次為空。
以每個步驟為單獨的方法,這里要考慮的最后一點是可以在多個測試之間重復使用該步驟。看看我們的測試示例中的前兩個步驟。如果我們將該數字作為輸入參數傳遞給這個步驟方法,我們可以重用它,無論我們重新使用這些步驟。
測試看起來更像這樣:
給定第一個數字{parameter1}
第二個數字{parameter2}
當添加兩個數字時
那么結果是{預期結果}
現在這是更通用的,希望能夠清楚地顯示出每個步驟的可重用性。我們不必在每次測試中使用相同的步驟,甚至不需要按照相同的順序!稍后我們來看一下這個。
隨着我們不斷添加測試,我們編寫的實際代碼變得越來越小,因為對於我們正在測試的每個系統行為,我們將了解到我們只是重新使用已經編碼的現有步驟。
所以即使我們花了一點時間最初寫測試代碼; 隨着我們的進步,最終花費在寫入額外步驟的時間減少到零。
用於BDD的軟件
我們需要看看哪些工具可以幫助我們充分利用BDD的全部功能。本文是從后端的角度來看的,但是還有純粹的前端工作的替代方法,但是在本文中將不再討論。
我會用:
- Visual Studio 2017(bit.ly/dnc-vs-download)
- Specflow - Visual Studio擴展 - 這將有助於Gherkin語法和測試與步驟代碼之間的鏈接。
- NUnit - 用於斷言。你可以在這里使用別的東西, FluentAssertions也是一樣。
有一個NuGet軟件包安裝了Specflow和NUnit,我會使用一個,因為它使事情變得更容易。
所以,首先安裝Visual Studio Specflow擴展。這將給我們提供文件模板和語法着色。
Specflow
Specflow Visual Studio擴展將允許您創建功能文件。這些文件是測試場景的占位符。
該擴展還為功能文件添加了語法着色,這是一個很好的視覺指示器,您所做的工作以及您仍然需要做什么。它在每個測試場景的步驟和它們之后的測試方法之間創建一個連接,這是非常方便的,特別是當你有很多功能文件和大量的測試。
一旦創建了一個特征文件,它將如下所示:
功能文本描述了問題。
該場景基本上是一個測試,我們可以在一個功能文件中有多個場景。
標簽在測試資源管理器窗口中使用,它允許我們以合乎邏輯的方式對測試進行分組。我們的初步測試可能如下所示:
請注意如何刪除對UI元素的引用。這可以追溯到最初所說的 - 專注於功能,以及在做某些事情的核心部分; 不是如何顯示事物和在哪里。
在Visual Studio解決方案中,我們仍然需要使用NuGet軟件包SpecFlow.NUnit安裝Specflow和NUnit:
我創建了一個MathLib類庫並添加了這個NuGet包。
一旦我們安裝了所有這些軟件包,打開測試資源管理器窗口,構建解決方案,你應該看到以下內容:
我被Traits過濾,然后顯示我們創建的標簽。我使用了兩個,MathLib顯示庫中的所有測試(Add,Divide等),但是我可以通過Math操作以及Add標簽下的組合來看到它們。這只是個人喜好。標簽可以是一種非常有效的方法,以對您有意義的方式對測試進行分組。
所以現在我們有一個功能文件,還有一個測試,但是我們還沒有寫任何測試代碼。
我們接下來需要的是一個步驟代碼文件,我們所有的測試步驟都可以進行。我們將從一個文件開始,但是我們可以將步驟分成多個步驟文件,以避免在一個文件中存在太多的代碼。
如果你再看看我們的測試,你會看到這些步驟是紫色的。這是一個視覺指標,沒有代碼。
我們創建一個步驟代碼文件,它只是一個標准的C#文件。
代碼如下所示:
using
TechTalk.SpecFlow;
namespace
MathLibTests
{
[Binding]
public
sealed
class
Steps
{
}
}
|
我們唯一添加的是Binding屬性在類的頂部。這是一個Specflow屬性,它使此文件中的所有步驟都可用於此項目中的任何功能文件,無論它們位於何處。
現在,返回功能文件,右鍵單擊任何步驟,您將在上下文菜單中看到“ 生成步驟定義”選項:
單擊生成步驟定義選項,然后將方法復制到剪貼板:
注意四個步驟如何顯示在窗口中。將為其中每一個生成代碼。
現在只需將代碼粘貼到前面創建的步驟文件中:
using
TechTalk.SpecFlow;
namespace
MathLibTests
{
[Binding]
public
sealed
class
Steps
{
[Given(
@"a first number (.*)"
)]
public
void
GivenAFirstNumber(
int
p0)
{
ScenarioContext.Current.Pending();
}
[Given(
@"a second number (.*)"
)]
public
void
GivenASecondNumber(
int
p0)
{
ScenarioContext.Current.Pending();
}
[When(
@"the two numbers are added"
)]
public
void
WhenTheTwoNumbersAreAdded()
{
ScenarioContext.Current.Pending();
}
[Then(
@"the result should be (.*)"
)]
public
void
ThenTheResultShouldBe(
int
p0)
{
ScenarioContext.Current.Pending();
}
}
}
|
保存文件,然后再次查看功能文件。我們最初的情景,其中有紫色的所有步驟,現在看起來像這樣:
注意顏色如何變成黑色,數字是斜體的,這意味着它們被視為參數。為了使代碼更清晰一些,我們來改一下一下:
using
TechTalk.SpecFlow;
namespace
MathLibTests
{
[Binding]
public
sealed
class
Steps
{
[Given(
@"a first number (.*)"
)]
public
void
GivenAFirstNumber(
int
firstNumber)
{
ScenarioContext.Current.Pending();
}
[Given(
@"a second number (.*)"
)]
public
void
GivenASecondNumber(
int
secondNumber)
{
ScenarioContext.Current.Pending();
}
[When(
@"the two numbers are added"
)]
public
void
WhenTheTwoNumbersAreAdded()
{
ScenarioContext.Current.Pending();
}
[Then(
@"the result should be (.*)"
)]
public
void
ThenTheResultShouldBe(
int
expectedResult)
{
ScenarioContext.Current.Pending();
}
}
}
|
在這一點上,我們有步驟,我們有起點,我們可以添加一些有意義的代碼。
我們添加實際的數學庫,這是我們實際測試的數學庫。
創建一個類庫,使用Add()方法添加一個MathLibOps類:
using
System;
namespace
MathLib
{
public
sealed
class
MathLibOps
{
public
int
Add(
int
firstNumber,
int
secondNumber)
{
throw
new
NotImplementedException();
}
}
}
|
現在讓我們寫出足夠的測試代碼來進行測試。
我們再來看一下“步驟”文件。注意所有這些ScenarioContext.Current.Pending()行在每一步?這是我們以前談論的語境。這就是我們所有需要的數據。將其視為字典,帶有鍵/值對。關鍵將用於檢索正確的數據,所以我們將給出一些有意義的價值觀,使我們的生活更輕松。
using
MathLib;
using
NUnit.Framework;
using
TechTalk.SpecFlow;
namespace
MathLibTests
{
[Binding]
public
sealed
class
Steps
{
[Given(
@"a first number (.*)"
)]
public
void
GivenAFirstNumber(
int
firstNumber)
{
ScenarioContext.Current.Add(
"FirstNumber"
, firstNumber);
}
[Given(
@"a second number (.*)"
)]
public
void
GivenASecondNumber(
int
secondNumber)
{
ScenarioContext.Current.Add(
"SecondNumber"
, secondNumber);
}
[When(
@"the two numbers are added"
)]
public
void
WhenTheTwoNumbersAreAdded()
{
var firstNumber = (
int
)ScenarioContext.Current[
"FirstNumber"
];
var secondNumber = (
int
)ScenarioContext.Current[
"SecondNumber"
];
var mathLibOps =
new
MathLibOps();
var addResult = mathLibOps.Add(firstNumber, secondNumber);
ScenarioContext.Current.Add(
"AddResult"
, addResult);
}
[Then(
@"the result should be (.*)"
)]
public
void
ThenTheResultShouldBe(
int
expectedResult)
{
var addResult = (
int
)ScenarioContext.Current[
"AddResult"
];
Assert.AreEqual(expectedResult, addResult);
}
}
}
|
看看前兩個給定方法,注意我們如何將參數傳遞給方法,然后將其添加到具有清除鍵的上下文中,以便我們知道它們代表什么。
在當步驟從上下文使用這兩個值,實例化Math類和與這兩個數調用Add()方法,然后將其結果存儲回的上下文。
最后,Then步驟從特征文件獲取預期結果,並將其與存儲在上下文中的結果進行比較。當然,當我們運行測試時,我們會失敗,因為我們沒有正確的代碼。
要運行測試,請在“測試資源管理器”窗口中右鍵單擊該測試,並使用“運行所選測試”選項:
結果將如下所示:
結果是如預期的,所以現在讓我們修復lib代碼並使其通過:
namespace
MathLib
{
public
sealed
class
MathLibOps
{
public
int
Add(
int
firstNumber,
int
secondNumber)
{
return
firstNumber + secondNumber;
}
}
}
|
現在讓我們再試一次,我們應該看到一些更開朗的東西:
很酷,所以在這一點上,我們應該相當熟悉它們如何掛在一起。
現在最大的問題是:
好的,這是非常好的,但是這與單元測試有什么不同,它實際提供了什么價值?我得到什么
我有一個功能文件,這很好,我想,但我可以很容易地寫一個單元測試,並完成它。業務分析師不會關心我的基本添加兩個數字的事情。
所以,我們來看看我們如何實現一些更復雜的東西。
Specflow有更多的功能,我們只是碰到了幾個。一個非常好的功能是能夠處理數據表。當數據不像數字那么簡單時,這很重要。例如,假設你有一個具有五個屬性的對象,這將使它更難處理,因為我們現在需要五個參數,而不是一個。
所以讓我們來一個比較嚴肅的項目,讓我們為一個網站實現一個Access框架,而這個Access Framework會告訴我們一個用戶是否可以在我們的網站上執行各種操作。
一個復雜的問題描述
我們有一個網站,人們可以訪問,然后搜索和申請工作。限制將根據其成員類型適用。
會員類型(白金,金,銀,免費)
鉑金可以搜索50次/天,每天50次。
黃金可以每天搜索15次,每天可以申請15個工作
銀可以每天搜索10次,每天可以申請10個工作
免費可以搜索5次/天,並適用於1個工作/天。
我們需要的
我們需要定義用戶
2.我們需要定義會員類型
3.我們需要定義每個會員類型的限制
4.我們需要一種方法來檢索用戶每天所做的搜索和應用程序。
前三個是配置,最后一個是用戶數據。我們可以用它來定義與系統交互的方式。
實際代碼
我們創建一個類來表示成員資格類型。它可能看起來像這樣:
namespace
Models
{
public
sealed
class
MembershipTypeModel
{
public
string
MembershipTypeName {
get
;
set
; }
public
RestrictionModel Restriction {
get
;
set
; }
}
}
|
該RestrictionModel類包含每天最大搜索,每天最大的應用:
namespace
Models
{
public
sealed
class
RestrictionModel
{
public
int
MaxSearchesPerDay {
get
;
set
; }
public
int
MaxApplicationsPerDay {
get
;
set
; }
}
}
|
接下來,我們要一個UserModel,它將保存用戶需要的數據:
namespace
Models
{
public
sealed
class
UserModel
{
public
int
ID {
get
;
set
; }
public
string
Username {
get
;
set
; }
public
string
FirstName {
get
;
set
; }
public
string
LastName {
get
;
set
; }
public
string
MembershipTypeName {
get
;
set
; }
public
UserUsageModel CurrentUsage {
get
;
set
; }
}
}
|
該UserUsageModel將告訴我們有多少搜索和應用的用戶已經完成的那一天:
namespace
Models
{
public
sealed
class
UserUsageModel
{
public
int
CurrentSearchesCount {
get
;
set
; }
public
int
CurrentApplicationsCount {
get
;
set
; }
}
}
|
最后,我們需要一個能夠保存AccessFramework調用結果的類:
namespace
Models
{
public
sealed
class
AccessResultModel
{
public
bool
CanSearch {
get
;
set
; }
public
bool
CanApply {
get
;
set
; }
}
}
|
正如你所看到的,我保持這樣很簡單,我們不想迷失實施細節。
我們確實希望了解BDD如何幫助我們解決不僅僅是Hello World應用程序的問題。
所以現在我們有了我們的模型,我們來創建幾個接口,這些將負責數據檢索部分。
首先,處理通用配置數據的一個:
using
Models;
using
System.Collections.Generic;
namespace
Core
{
public
interface
IConfigurationRetrieval
{
List<MembershipTypeModel> RetrieveMembershipTypes();
}
}
|
第二個處理用戶特定的數據:
using
Models;
namespace
Core
{
public
interface
IUserDataRetrieval
{
UserModel RetrieveUserDetails(
string
username);
}
}
|
這兩個接口將成為AccessFrameworkAnalyser類的參數,它們將允許我們模擬測試所需的數據:
using
Core;
using
Models;
using
System;
using
System.Linq;
namespace
AccessFramework
{
public
sealed
class
AccessFrameworkAnalyser
{
IConfigurationRetrieval _configurationRetrieval;
IUserDataRetrieval _userDataRetrieval;
public
AccessFrameworkAnalyser(IConfigurationRetrieval configurationRetrieval, IUserDataRetrieval userDataRetrieval)
{
if
( configurationRetrieval ==
null
|| userDataRetrieval ==
null
)
{
throw
new
ArgumentNullException();
}
this
._configurationRetrieval = configurationRetrieval;
this
._userDataRetrieval = userDataRetrieval;
}
public
AccessResultModel DetermineAccessResults(
string
username)
{
if
(
string
.IsNullOrWhiteSpace(username))
{
throw
new
ArgumentNullException();
}
var userData =
this
._userDataRetrieval.RetrieveUserDetails(username);
var membershipTypes =
this
._configurationRetrieval.RetrieveMembershipTypes();
var userMembership = membershipTypes.FirstOrDefault(p => p.MembershipTypeName.Equals(userData.MembershipTypeName, StringComparison.OrdinalIgnoreCase));
var result =
new
AccessResultModel();
if
(userMembership !=
null
)
{
result.CanApply = userData.CurrentUsage.CurrentApplicationsCount < userMembership.Restriction.MaxApplicationsPerDay ?
true
:
false
;
result.CanSearch = userData.CurrentUsage.CurrentSearchesCount < userMembership.Restriction.MaxSearchesPerDay ?
true
:
false
;
}
return
result;
}
}
}
|
我們這里做的不多 我們簡單地在我們的兩個接口中使用依賴注入,然后根據當前的搜索和應用程序,比較有多少個搜索和應用程序可用於所選用戶的成員資格類型。
請注意,我們並不關心這個數據是如何實際加載的,通常會有一個實現到每個接口到一個數據庫,但是在這個例子中,我們並不在乎。
我們需要知道的是,我們將有一種獲取數據的方法,並且可能會在實際的UI項目中使用某種類型的IOC來連接實際的實現,這需要真正的數據。既然我們真的不在乎這一點,我們就不會實現它,我們將簡單的展示一些所需的測試。
我們的功能文件可能如下所示:
管道表示處理表格數據的Specflow方法。
第一行包含標題,后面的行包含數據。重要的是要注意我們設置了多少數據,以及它們的可讀性。事情變得更簡單,因為這里沒有代碼,沒有隱藏實際的數據。在這一點上,我們可以簡單地復制和粘貼測試,更改數據,並再次准備就緒。
關鍵是非開發人員也可以做到這一點。
我們來看看第一種情況。
您可以看到,首先我們設置我們要使用的會員類型。記住我們不關心真實數據,我們關心這里的功能和業務規則,這就是我們正在測試的。這使得我們很容易以任何我們喜歡的方式設置數據。
第二步設置用戶及其現有的搜索和應用程序數量。
最后,當使用AccessFrameworkAnalyser類時,我們期待一定的結果。
這里有一些重要的事情要提及。
我們如何在步驟代碼中加載表格數據?
以下是加載成員資格數據的示例:
private
List<MembershipTypeModel> GetMembershipTypeModelsFromTable(Table table)
{
var results =
new
List<MembershipTypeModel>();
foreach
( var row
in
table.Rows)
{
var model =
new
MembershipTypeModel();
model.Restriction =
new
RestrictionModel();
model.MembershipTypeName = row.ContainsKey(
"MembershipTypeName"
) ? row[
"MembershipTypeName"
] :
string
.Empty;
if
(row.ContainsKey(
"MaxSearchesPerDay"
))
{
int
maxSearchesPerDay = 0;
if
(
int
.TryParse(row[
"MaxSearchesPerDay"
],
out
maxSearchesPerDay))
{
model.Restriction.MaxSearchesPerDay = maxSearchesPerDay;
}
}
if
(row.ContainsKey(
"MaxApplicationsPerDay"
))
{
int
maxApplicationsPerDay = 0;
if
(
int
.TryParse(row[
"MaxApplicationsPerDay"
],
out
maxApplicationsPerDay))
{
model.Restriction.MaxApplicationsPerDay = maxApplicationsPerDay;
}
}
results.Add(model);
}
return
results;
}
|
在嘗試加載任何東西之前,始終檢查一個標題是否存在是個好主意。這是非常有用的,因為根據您正在構建的內容,您並不總是同時需要所有的屬性和對象。您可能只需要幾個屬性進行一些特定測試,在這種情況下,您不需要充滿數據的表。你只需使用你需要的,忽略其余的,一切仍然有效。
現在加載會員類型的實際步驟變得非常簡單:
[Given(
@"the membership types"
)]
public
void
GivenTheMembershipTypes(Table table)
{
var membershipTypes =
this
.GetMembershipTypeModelsFromTable(table);
ScenarioContext.Current.Add(
"MembershipTypes"
, membershipTypes);
}
|
這就像以前一樣 - 在上下文>作業完成后加載數據>存儲。
另一個有趣的一點是我們如何模擬我們所需要的。
我用的是NSubstitute,代碼很簡單:
[When(
@"access result is required"
)]
public
void
WhenAccessResultIsRequired()
{
//data from context
var membershipTypes = (List<MembershipTypeModel>)ScenarioContext.Current[
"MembershipTypes"
];
var user = (UserModel)ScenarioContext.Current[
"User"
];
//setup the mocks
var configurationRetrieval = Substitute.For<IConfigurationRetrieval>();
configurationRetrieval.RetrieveMembershipTypes().Returns(membershipTypes);
var userDataRetrieval = Substitute.For<IUserDataRetrieval>();
userDataRetrieval.RetrieveUserDetails(Arg.Any<
string
>()).Returns(user);
//call to AccessFrameworkAnalyser
var accessResult =
new
AccessFrameworkAnalyser(configurationRetrieval, userDataRetrieval).DetermineAccessResults(user.Username);
ScenarioContext.Current.Add(
"AccessResult"
, accessResult);
}
|
初始數據來自在此之前運行的步驟,然后我們設置mocks,最后調用AccessFramework並將結果存儲在上下文中。
最后一步,實際的斷言如下所示:
[Then(
@"access result should be"
)]
public
void
ThenAccessResultShouldBe(Table table)
{
var expectedAccessResult =
this
.GetAccessResultFromTable(table);
var accessResult = (AccessResultModel)ScenarioContext.Current[
"AccessResult"
];
expectedAccessResult.ShouldBeEquivalentTo(accessResult);
}
|
在這里我使用了另一個NuGet軟件包FluentAssertions。這可以讓我比較對象,而不用擔心每個屬性將需要多少個斷言。我仍然可以只有一個斷言。
附上完整的代碼,請看看,在Visual Studio中跟蹤事情要容易得多。注意解決方案的結構,一切都在一個單獨的項目中,一切都引用了它所需要的,沒有什么更多:
希望現在你開始看到使用BDD的優點。對我而言的要點是,一旦實際需求清楚,我們就不需要看代碼來解決它的功能。我們需要做的就是查看功能文件。
用票號標記場景是一個好主意,以便您了解每個測試涵蓋的要求。這提供了業務的可見性方面,我們已經涵蓋了多少和剩下的事情。
遇到錯誤時,編寫一個復制錯誤然后修復錯誤的測試是個好主意。這樣一來,您可以確定某個bug一旦修復,它就會保持固定。
如果您需要調試BDD測試場景,您可以簡單地在一個步驟上設置一個斷點,然后右鍵單擊“測試資源管理器”窗口,選擇“調試所選測試”並關閉您。
BDD Downsides
所以,你向我們展示了蛋糕,這種做法的缺點是什么?
只有一個我發現到目前為止,這不是BDD問題具體,而是一個工具問題。
一旦你有幾個功能文件和健康的測試數量,你可能會有不少的步驟。沒有簡單的方法可以告訴任何功能文件不使用步驟方法。Codelens不會在這里幫忙。你不能確定這個特定步驟是否被十個場景調用。在任何地方都不算任何地方,這可能意味着你可以使用孤兒步法。
當然,您可以隨時刪除一步法,然后檢查任何功能文件是否受到影響,但可能需要一段時間,具體取決於您擁有的功能文件數量。
正如我所說,這不是一個BDD問題,它是一個Specflow問題,很可能只有更好的時間過去。
對我來說,使用BDD的好處大大超過了Specflow的問題。
下載本文的全部源代碼(Github)。