65.9K
CodeProject 正在变化。 阅读更多。
Home

用户故事毫无价值,行为才是我们所需

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.17/5 (16投票s)

2010年7月31日

CPOL

9分钟阅读

viewsIcon

168378

用户故事毫无价值,行为才是我们所需

引言

用户故事适用于描述用户的需求,但并不适用于描述用户的行为以及系统在不同情境下如何响应用户的操作。它基本上是为产品团队提供一种量化其产出的方式,并告知其上级领导他们正在履行职责。作为一名开发者,你无法根据用户故事编写代码,因为你不知道用户操作和系统响应的顺序、验证规则、需要调用的API等等。作为一名QA,你无法根据用户故事测试软件,因为它没有捕捉到情境、事件顺序以及所有可能的系统响应。用户故事对开发生命周期几乎没有价值。它只帮助产品团队了解他们最终需要做多少工作,并帮助财务团队了解人们在讨论多少资金。但对于UI设计师、解决方案设计师、开发者来说,它们只不过是一些高度不精确的陈述,留下了数百个待回答的问题。缺少“情境”和“因果关系”,以及“作为一个……我想要……这样……”这种不精确的表达方式,留下了太多的误解空间,使得开发团队不可能仅凭用户故事就产出软件,而无需花费大量时间重新分析用户故事。软件,以及最终的宇宙,都与因果关系有关。用户故事中并未描述因果关系。

与用户故事不同,行为驱动开发 (BDD) 提出的“行为”是一种更好的方法,因为行为的格式(给定情境,当事件,那么结果)在正确使用时,可以让你从事件序列的角度进行思考,其中每个用户或系统操作的情境、事件和结果都被捕获,从而成为设计UI和架构的明确规范。它遵循因果关系模型,因此可以解释世界(或你的软件)是如何运作的。它可以非常精确,有时一个行为甚至可以作为开发者编写单个函数的指南!不仅是开发者,QA团队也可以清楚地捕捉到他们需要执行的操作以及系统应该如何响应。然而,要充分发挥行为的价值,你需要正确地编写它们,遵循正确的格式。所以,让我举例说明如何为UI、业务层、服务甚至函数编写好的行为,从而消除通常在用户故事驱动的开发生命周期中重复的需求分析。

如果你能迫使产品经理在将需求传递到生命周期的早期提高10%的精确度,那么你就可以通过节省后期分析讨论、文档编写和修订的时间和成本,从而节省整个开发生命周期中近30%的总体浪费成本。

用户故事到行为

如果你仍然生活在一个不完美的世界里,用户故事堆积在你的桌上,而你必须从中产出代码,那么你需要学会将用户故事转化为行为。这里有一个例子。

As an anonymous user, I want to login on the homepage, 
so that I can access my account details. 

上面的用户故事留下了许多解读的空间。将上面的用户故事交给开发者,然后看看从开发者到产品团队,再到开发者到QA之间需要多少轮往返才能最终将其发布到生产环境中。用户如何在主页上登录?是去一个登录页面还是登录框就嵌入在主页上?用户名是电子邮件格式还是自由文本?当用户名或密码错误时,我们会显示什么?

因此,你必须用验收标准来支撑这个用户故事,以回答这些问题。

验收标准

  • 主页必须显示一个登录框
  • 系统必须验证用户名和密码
  • 用户名必须是电子邮件格式
  • 密码必须是6个字符且包含1个数字
  • 当用户名和密码正确时,系统必须显示仪表盘
  • 如果输入的用户名和密码错误,系统必须显示错误信息

首先,你需要编写一些验收标准来描述用户所处的环境。你必须描述存在一个登录框,其中包含用户名和密码。然后,你必须描述一些用户操作。用户如何输入用户名和密码。然后,你必须描述系统的行为,它如何做出反应。所有这些都被作为验收标准以断言语句的形式给出,这些语句的结果必须是True/False。这是一种描述用户操作和系统对这些操作响应的荒谬方式。软件(以及宇宙)都与因果关系有关。这种因果关系在用户故事中完全缺失。

如果你将这个用户故事转换为行为,它看起来会像这样

Given an anonymous user who has registered before and is on the homepage login box, 
When user enters username and password on the login box and 
clicks Login button or hits enter, 
Then it should validate the username and password and 
redirect user to the dashboard if the account is valid, 
and it should show invalid username and password inside the login box 
when the credentials are incorrect, 
and it should update the last login datetime for the user in database. 

在这里,你可以清楚地看到情境(例如,匿名用户,在主页上)在“给定”块中被分开和解释,这与用户故事不同,用户故事中的情境以随机顺序分布在验收标准中。然后,“当”块清楚地解释了用户操作,这与用户故事不同,用户故事中的用户操作部分描述在“我想要……”部分,然后部分描述在某些验收标准中,你需要自己去弄清楚。“然后”块中的行为描述了结果,包括正面和负面的,这相当于用户故事的“这样……”以及随附的验收标准。正如你所见,在一个行为陈述中,你可以精确地定义用户在执行操作之前的环境或情境,然后是用户执行操作,然后是系统对操作的响应。过去、现在和未来的顺序在行为中得到了清晰的遵循。

UI 的行为

行为是描述 UI 需求的绝佳方式,因为它清楚地捕捉了用户操作的顺序以及所有可能的系统响应。例如

Given an account holder with some outstanding payments and direct debits setup, 
When user goes to the Account Details page by clicking the link on the header area, 
Then it should show the users’ account numbers and balances in a grid view, 
and it should show balance in red if it’s a negative balance, 
and it should show a list of outstanding payments in red, 
and it should show a list of direct debits setup in a grid.

将这个行为交给开发者,开发者会欣喜若狂地用它来编写优美的代码。相比之下,如果将等效的用户故事交给开发者

As an account holder, I want to go to Account Details page 
so that I can see my accounts and their balances, 
my outstanding payments and direct debits.

验收标准

  • 账户持有人已有一些待处理的付款。
  • 账户持有人已配置了一些直接付款。
  • 一个网格视图显示账户号码和余额。
  • 负余额必须显示为红色。
  • 一个列表显示待处理付款(红色)。
  • 一个网格显示直接付款(如果有)。

需要更多的句子来描述相同的行为。此外,前两个验收标准描述了情境,但它们并非真正的验收标准。由于用户故事中没有其他方式来放置情境,它只能放在验收标准中的某个地方。最后,没有事件发生的顺序。它没有说明用户如何转到账户详情页面。

我在我的开源项目 Dropthings 中使用行为来描述 UI 需求。这是一个 Web 2.0 的起始页,可以渲染小部件并允许你自定义它们。我可以这样定义第一次用户访问的行为。

Given a new user, 
When user visits the homepage, 
Then it should show the default widgets.

然后,我可以使用完全相同的行为来编写测试代码,自动执行浏览器,模拟用户操作并验证预期结果。

[Specification]
public void Visit_Homepage_as_new_user_and_verify_default_widgets()
{
    var browser = default(Browser);

    "Given a new user".Context(() =>
        {
            BrowserHelper.ClearCookies();
        });
    "When user visits the homepage".Do(() =>
        {
            browser = BrowserHelper.OpenNewBrowser(Urls.Homepage);
        });
    "It should show the default widgets".Assert(() =>
        {
            using (browser)
            {
                var expectedWidgets = new string[] 
                {
                    "How to of the Day",
                    "Omar's Blog (Fast RSS)",
                    "Book on building Dropthings",
                    "Twitter",
                    "Fast Flickr",
                    "Digg - Silverlight Widget",
                    "BBC World",
                    "Weather",
                    "CNN.com",
                    "Travelocity",
                    "Stock",
                    "HTML"
                };

                var homepage = browser.Page<HomePage>();

                Assert.Equal(expectedWidgets.Length, homepage.Widgets.Count);

                Assert.True(
                    expectedWidgets.All(
                        widgetTitle =>
                            homepage.Widgets.Any(
                                control => string.Compare(control.Title,
						widgetTitle, true) == 0)));
            }
        });
}

上面的测试是使用 xUnitSubspec (xUnit 扩展) 和 WatiN 编写的。你可以在项目中找到数百个此类测试,它们展示了如何使用行为来定义 UI,然后编写代码来自动执行浏览器,模拟真实用户操作并检查浏览器上的输出,以确定行为是否得到满足。Dropthings 是一个 AJAX 网站。测试代码包含了大量的 AJAX 技巧和窍门,应该能帮助你理解如何自动化 UI 测试。

服务的行为

你可以使用行为格式来清楚地定义你期望从服务中获得的输入和输出。例如

Given an account holder with some outstanding payments and direct debit setup, 
When GetAccountDetails method of AccountService is called with the account holder’s ID 
Then it should return the account summary containing account names and balances, 
and it should return an array of overdue payments, 
and it should return an array of direct debits.

现在,如果必须将这个定义为用户故事,哦,天哪

As the Account Details page, I want to call GetAccountDetails of AccountService so that 
I can get the account summary, overdue payments and direct debits.

事实上,这是完全错误的。这里没有“用户”会使用用户故事格式。你别无选择,只能使用其他方法将这些需求传达给分布式组件开发团队。而最好的方法就是使用这样的行为来描述组件的行为。

业务组件的行为

与服务一样,你也可以为业务组件描述行为。例如

Given a user who has accounts with the bank, 
When LoginManager.Login is called with user’s username and password, 
Then it should return true if the username and password is valid 
by calling Active Directory API, 
and it should update the last login date time of the user in Audit database, 
and it should throw exception if the username and password is wrong.

如果你将这个行为交给开发者,开发者可以轻松地编写业务组件。这不需要进一步的讨论。它不需要一些显示谁调用谁的 UML 图。这是一种超快速地将需求传达给开发者的途径。此外,如果你正在编写单元测试,那么你可以使用完全相同的行为来生成单元测试。

以下是如何使用行为创建业务层组件的示例

[Specification]
public void First_visit_should_create_same_pages_and_widgets_as_the_template_user()
{
    var profile = default(UserProfile);
    UserSetup userVisitModel = null;
    var facade = default(Facade);
    var anonUserName = default(string);
    var anonTabs = default(List<Tab>);
           
    "Given anonymous user who has never visited the site before".Context(() => 
    {
        profile = MembershipHelper.CreateNewAnonUser();
        facade = new Facade(new AppContext(string.Empty, profile.UserName));

        // Load the anonymous user pages and widgets
        anonUserName = facade.GetUserSettingTemplate().AnonUserSettingTemplate.UserName;
        anonTabs = facade.GetTabsOfUser(facade.GetUserGuidFromUserName(anonUserName));

    });

    "When the user visits for the first time".Do(() =>
    {                
        userVisitModel = facade.FirstVisitHomeTab
		(profile.UserName, string.Empty, true, false);
    });

    "It creates widgets on the newly created page at exact columns 
	and positions as the anon user's pages".Assert(() =>
    {
        anonTabs.Each(anonTab =>
        {
            var userTab = userVisitModel.UserTabs.First(page =>
                            page.Title == anonTab.Title
                            && page.OrderNo == anonTab.OrderNo
                            && page.PageType == anonTab.PageType);

            facade.GetColumnsInTab(anonTab.ID).Each(anonColumn =>
            {
                var userColumns = facade.GetColumnsInTab(userTab.ID);
                var userColumn = userColumns.First(column =>
                                column.ColumnNo == anonColumn.ColumnNo);

                var anonColumnWidgets = 
		facade.GetWidgetInstancesInZoneWithWidget(anonColumn.WidgetZone.ID);
                var userColumnWidgets = 
		facade.GetWidgetInstancesInZoneWithWidget(userColumn.WidgetZone.ID);

                // Ensure the widgets from the anonymous user template's columns are 
                // in the same column and row.
                anonColumnWidgets.Each(anonWidget => 
		Assert.True(userColumnWidgets.Where(userWidget =>
                        userWidget.Title == anonWidget.Title
                        && userWidget.Expanded == anonWidget.Expanded
                        && userWidget.State == anonWidget.State
                        && userWidget.Resized == anonWidget.Resized
                        && userWidget.Height == anonWidget.Height
                        && userWidget.OrderNo == anonWidget.OrderNo).Count() == 1));
            });
        });

        facade.Dispose();
    });

}

上面的代码可能看起来很多,但我想说的是,你可以根据行为产出业务层组件,并使用相同的行为进行测试。

数据访问层的行为

你甚至可以像定义服务和业务层一样定义数据库的行为。如果你有一个执行复杂工作的存储过程,你可以使用行为格式来定义存储过程应该做什么。例如

Given a user in the aspnet_users table, 
When AuthenticateUser is SP is called with the user’s loweredusername and password,
Then it should query the aspnet_users table to find a match,
and it should compare the password with the selected row in a case sensitive way,
and it should return the aspnet_user row if the username and 
password matches successfully,
and it should return nothing if there’s no match.

一旦你这样定义了行为,你就可以根据它来编写存储过程,然后你可以使用一些单元测试工具来测试存储过程。

函数的行为!

如果你的函数承担的任务不仅仅是微不足道的,那么你应该使用行为来解释函数应该做什么。例如

Given a file in a local folder
When File.ReadAllLines is called with the file’s full path,
Then it should open the file and read all the content and return a string array,
and it should throw InvalidArgumentException if the path is wrong,
and it should return a null array if the file is zero length file,
and it should throw InvalidArgumentException if the path is a UNC or URL,

上面的行为对于编写单元测试非常有价值。一旦你根据行为编写了函数代码,你就可以轻松地为函数编写单元测试。

同样,这里有一个例子说明我如何编写行为来解释特定函数应该做什么。

[Specification]
public void GetTab_Should_Return_A_Tab_from_cache_when_it_is_already_cached()
{
    var cache = new Mock<ICache>();
    var database = new Mock<IDatabase>();
    ITabRepository pageRepository = new TabRepository(database.Object, cache.Object);

    const int pageId = 1;
    var page = default(Tab);
    var sampleTab = new Tab() { ID = pageId, 
	Title = "Test Tab", ColumnCount = 3, LayoutType = 3, VersionNo = 1, 
        	PageType = (int)Enumerations.PageType.PersonalTab, CreatedDate = DateTime.Now };

    "Given TabRepository and the requested page in cache".Context(() =>
    {
        cache.Expect(c => c.Get(CacheKeys.TabKeys.TabId(sampleTab.ID)))
                .Returns(sampleTab).AtMostOnce();
    });

    "when GetTabById is called".Do(() =>
            page = pageRepository.GetTabById(1));

    "it checks in the cache first and finds the object is in cache".Assert(() =>
    {
        cache.VerifyAll();
    });

    "it returns the page as expected".Assert(() =>
    {
        Assert.Equal<int>(pageId, page.ID);
    });
}

这里,行为解释了TabRepositoryGetTab 函数应该做什么。然后我可以生成一个单元测试,并使用像Moq这样的模拟框架来编写函数的单元测试。

结论

行为是一种全能的解决方案,可以为 UI、服务、业务组件、数据库、实用库甚至复杂函数指定需求。该格式鼓励以精确的方式描述需求,并在正确遵循时留下很少的困惑空间。与只能解释用户意图的用户故事相比,如果行为贯穿整个开发生命周期,它将大大减少重复的需求分析工作,并能使产品、设计、开发和 QA 团队之间的沟通更加有效。如果你能迫使产品负责人将其需求定义得更精确10%,那么通过节省后期分析讨论、文档编写和修订的时间和成本,你就可以节省整个开发生命周期中近30%的总体浪费成本。

© . All rights reserved.