转载:使用 SpecFlow 和 WatiN 进行行为驱动开发

随着自动化单元测试在软件开发中变得越来越普遍,对各种“测试优先”方法的采用也呈现出相同的趋势。 这些实践为开发团队既带来了难得的机遇,也带来了独特的挑战,但所有这些机遇和挑战都是为了帮助从业人员建立“根据设计进行测试”的思路。

但是在“测试优先”时代的大多数时间,用于表达用户行为的方法一直贯穿于使用系统语言(一种与用户的语言不相关的语言)编写的单元测试。 随着行为驱动开发 (BDD) 技术的问世,这种情况也随之改变。 利用 BDD 技术,您可使用业务语言来编写自动化测试,同时还可保持与已实现系统的连接。

当然,现在我们创建了很多工具,可帮助您在开发过程中实现 BDD。 这些工具包括 Ruby 中的 Cucumber 以及适用于 Microsoft .NET Framework 的 SpecFlow 和 WatiN。 SpecFlow 可帮助您在 Visual Studio 中编写和执行规范,而 WatiN 可用于驱动浏览器进行自动化的端到端系统测试。

本文中,我将简要概述 BDD,然后解释 BDD 周期如何通过用于驱动单元级别实现的功能级别测试来包括传统的测试驱动开发 (TDD) 周期。 在介绍“测试优先”方法的基本内容后,我将介绍 SpecFlow 和 WatiN,并向您演示如何将这些工具与 MSTest 结合使用来为您的项目实现 BDD 的示例。

自动化测试简史

敏捷软件发展过程中产生的最有价值的实践之一就是测试优先的自动化开发模式,通常称为“测试驱动开发”或“TDD”。 TDD 的一条关键原则就是,测试创建不仅与设计和开发指南相关,同样还与验证和回归相关。 测试创建还涉及到使用测试来指定一组所需的功能,以及稍后通过测试来只编写实现该功能所需的代码。 因此,实现任何新功能的第一步就是通过一个失败测试来描述您的期望(参见图 1)。

图 1 测试驱动开发的周期

许多开发人员和团队已通过 TDD 取得了巨大的成功, 而其他人没有使用 TDD,结果发现自己长期以来疲于应付进程管理工作,尤其是,随着测试量开始增长,这些测试的灵活性却开始降低,情况会更糟糕。 尽管有些人觉得 TDD 很容易上手,而有些人却不清楚如何开始使用 TDD,结果只能将它放在一边,眼睁睁地看着最终期限的临近和工作的大量积压而束手无策。 最后,许多对此感兴趣的开发人员遇到了其组织内部对这项工作的重重阻力,要么是因为“测试”这个词暗示这项职能属于另一个团队,或是因为“TDD 产生了太多额外的代码并减缓了项目进度”这个错误的观念。

Steve Freeman 和 Nat Pryce 在他们的著作“Growing Object-Oriented Software, Guided by Tests”(Addison-Wesley Professional, 2009) 中指出,“传统的”TDD 缺少真正的“测试优先”开发的某些优点:

“通过为应用程序中的类编写单元测试来开始 TDD 过程是有风险的。 这不仅比不进行任何测试要好得多,还可以发现那些我们所熟知但又无法避免的常见编程错误…但是项目仅进行单元测试却会让 TDD 过程的重要好处大打折扣。 我们也看到了,有些具有高品质和经过严格单元测试的代码的项目并非从任何位置都可以调用,或者这些项目无法与系统的其余部分集成,因而必须重写。”

2006 年,Dan North 在 Better Software 杂志 (blog.dannorth.net/introducing-bdd) 中的一篇文章中提到了许多这类难题。 在他的文章中,North 介绍了三年来在测试实践方面所采用的一系列做法。 尽管这些实践就本身而言仍属于 TDD 的范畴,但却促使 North 采用一种更加侧重分析的观点来看待测试,并创造了术语“行为驱动开发”以概括这种转换。

BDD 最常用的一个应用尝试通过验收测试或可执行规范来强化创建测试的重点和过程,从而扩展 TDD。 每个规范将作为进入开发周期的一个入口点,它从用户角度以分步骤的形式介绍系统的行为方式。 完成编写后,开发人员将使用规范及其现有的 TDD 过程来实现足量的生产代码,从而得到一个通过测试的方案(参见图 2)。

图 2 行为驱动开发的周期

从何处开始设计

大多数人认为 BDD 是 TDD 的超集,而不是它的替代品。 两者的重要区别是对初始设计和测试创建的侧重点不同。 与 TDD 侧重于针对单元或对象的测试不同,我将以用户的目标以及他们为了实现这些目标而采取的步骤为侧重点。 因为我不再从小型单元的测试着手,所以我也不太愿意考虑具体用法或设计细节。 我更多的是记录能够证明系统合适的可执行规范。 我仍然编写单元测试,但是 BDD 鼓励采用由外而内的方法,该方法首先要提供所要实现的功能的完整说明。

让我们看看此差异的示例。 在传统的 TDD 实践中,您可以在图 3 中编写测试,以便演练 CustomersController 的 Create 方法。

图 3 针对创建客户的单元测试

  1.           [TestMethod]
  2. public void PostCreateShouldSaveCustomerAndReturnDetailsView() {
  3.   var customersController = new CustomersController();
  4.   var customer = new Customer {
  5.     Name = "Hugo Reyes",
  6.     Email = "hreyes@dharmainitiative.com",
  7.     Phone = "720-123-5477" 
  8.   };
  9.  
  10.   var result = customersController.Create(customer) as ViewResult;
  11.  
  12.   Assert.IsNotNull(result);
  13.   Assert.AreEqual("Details", result.ViewName);
  14.   Assert.IsInstanceOfType(result.ViewData.Model, typeof(Customer));
  15.  
  16.   customer = result.ViewData.Model as Customer;
  17.   Assert.IsNotNull(customer);
  18.   Assert.IsTrue(customer.Id > 0);
  19. }
  20.         

这将是我使用 TDD 编写的首批测试之一。 我通过设置所期望的 CustomersController 对象的行为方式为它设计一个公共 API。 对于 BDD,我仍然创建该测试,但并非从一开始就创建。 相反,我通过编写更类似于图 4 的测试来提高对功能级别功能的侧重度。 然后,我将该方案用作针对实现所需代码的各个单元的指南,以使此方案通过。

图 4 功能级别规范

  1.           Feature: Create a new customer
  2.   In order to improve customer service and visibility
  3.   As a site administrator
  4.   I want to be able to create, view and manage customer records
  5.  
  6. Scenario: Create a basic customer record
  7.   Given I am logged into the site as an administrator
  8.   When I click the "Create New Customer" link
  9.   And I enter the following information
  10.     | Field | Value                       |
  11.     | Name  | Hugo Reyes                  |
  12.     | Email | hreyes@dharmainitiative.com |
  13.     | Phone | 720-123-5477                |
  14.   And I click the "Create" button
  15.   Then I should see the following details on the screen:
  16.     | Value                       |
  17.     | Hugo Reyes                  |
  18.     | hreyes@dharmainitiative.com |
  19.     | 720-123-5477                |
  20.         

这是图 2 中的外层循环,失败的验收测试。 在创建此测试并且测试失败之后,我将按图 2 中所述的内部 TDD 循环来实现我的功能中每个方案的每个步骤。 对于图 3 中的 CustomersController,一旦到达功能中的合适步骤,我就会在实现使该步骤通过测试所需的控制器逻辑之前立即编写此测试。

BDD 和自动化测试

从一开始,BDD 社区就已设法使用已成为单元测试中的标准一段时间的验收测试来提供相同级别的自动化测试。 值得注意的一个示例就是 Cucumber (cukes.info),它是一个基于 Rub 的测试工具,强调创建以“特定于域的业务可读语言”编写的功能级别的验收测试。

Cucumber 测试使用针对每个功能文件的 User Story 语法和针对每个方案的 Given、When、Then (GWT) 语法来编写。 (有关 User Story 语法的详细信息,请参阅 c2.com/cgi/wiki?UserStory。)GWT 描述了方案当前的上下文 (Given)、作为测试的一部分所执行的操作 (When) 以及预期的可观察结果 (Then)。 图 4 中的功能是此类语法的一个示例。

在 Cucumber 中,系统会对用户可读的功能文件进行解析,并将每个方案步骤与 Ruby 代码(演练相关系统的公共接口并确定该步骤是成功还是失败)进行匹配。

近年来,创新促使自动化测试这类方案的使用扩展到 .NET Framework 体系。 开发人员现已具有允许通过 Cucumbe 所使用的相同结构的英语语法来编写规范的工具,而随后 Cucumber 又可将这些规范用作演练代码的测试。 利用 SpecFlow (specflow.org)、Cuke4Nuke (github.com/richardlawrence/Cuke4Nuke) 等 BDD 测试工具,您可首先在过程中创建可执行规范,在扩建功能时利用这些规范,并在最后记录那些与您的开发和测试进程直接关联的功能。

SpecFlow 和 WatiN 入门

本文中,我将利用 SpecFlow 来测试一个模型-视图-控制器 (MVC) 应用程序。 若要开始使用 SpecFlow,您首先要下载并安装它。 安装 SpecFlow 后,请使用单元测试项目创建一个新的 ASP.NET MVC 应用程序。 我更愿意我的单元测试项目只包含单元测试(控制器测试、存储库测试等等),这样,我就还可以为我的 SpecFlow 测试创建一个 AcceptanceTests 测试。

添加 AcceptanceTests 项目并添加对 TechTalk.SpecFlow 程序集的引用后,请使用 SpecFlow 在安装时创建的“添加”|“新建项目”模板添加一个新功能,并将其命名为 CreateCustomer.feature。

请注意,该文件创建时的扩展名为 .feature;由于有了 SpecFlow 的集成工具,Visual Studio 将此文件识别为支持的文件。 您还可能注意到,您的功能文件具有一个相关的 .cs 代码隐藏文件。 您每次保存 .feature 文件时,SpecFlow 会对文件进行解析,并将该文件中的文本转换到一个测试装置。 关联的 .cs 文件中的代码代表该测试装置,即每次运行您的测试套件时实际执行的代码。

默认情况下,SpecFlow 将 NUnit 用作其测试运行程序,但它也支持配置稍有更改的 MSTest。 您只需向测试项目中添加一个 app.config 文件并添加以下元素即可:


          <configSections>
  <section name="specFlow"
    type="TechTalk.SpecFlow.Configuration.ConfigurationSectionHandler, TechTalk.SpecFlow"/>
</configSections>
<specFlow>
  <unitTestProvider name="MsTest" />
</specFlow>
        

首个验收测试

当您创建某个新功能时,SpecFlow 将使用默认文本填充该文件,以解释用于描述该功能的语法。 将 CreateCustomer.feature 文件中的默认文本替换为图 4 中的文本。

每个功能文件分为两个部分。 第一个部分是顶部的功能名称和说明,此部分使用 User Story 语法来描述用户的角色、用户的目标以及用户为了在系统中实现这个目标而必须能够执行的操作的类型。 SpecFlow 需要此部分来自动生成测试,但是内容本身不能用于这些测试。

每个功能文件的第二部分为一个或多个方案。 每个方案用于在关联的 .feature.cs 文件中生成一个测试方法(如图 5 所示),且方案中的每个步骤会传递到 SpecFlow 测试运行程序,该运行程序会将步骤的一个基于 RegEx 的匹配项执行到名为“步骤定义”文件的 SpecFlow 文件中的一个条目。

图 5 由 SpecFlow 生成的测试方法

  1.           public virtual void CreateABasicCustomerRecord() {
  2.   TechTalk.SpecFlow.ScenarioInfo scenarioInfo = 
  3.     new TechTalk.SpecFlow.ScenarioInfo(
  4.     "Create a basic customer record", ((string[])(null)));
  5.  
  6.   this.ScenarioSetup(scenarioInfo);
  7.   testRunner.Given(
  8.     "I am logged into the site as an administrator");
  9.   testRunner.When("I click the \"Create New Customer\" link");
  10.  
  11.   TechTalk.SpecFlow.Table table1 = 
  12.     new TechTalk.SpecFlow.Table(new string[] {
  13.     "Field""Value"});
  14.   table1.AddRow(new string[] {
  15.     "Name""Hugo Reyesv"});
  16.   table1.AddRow(new string[] {
  17.     "Email""hreyes@dharmainitiative.com"});
  18.   table1.AddRow(new string[] {
  19.     "Phone""720-123-5477"});
  20.  
  21.   testRunner.And("I enter the following information"
  22.     ((string)(null)), table1);
  23.   testRunner.And("I click the \"Create\" button");
  24.  
  25.   TechTalk.SpecFlow.Table table2 = 
  26.    new TechTalk.SpecFlow.Table(new string[] {
  27.   "Value"});
  28.   table2.AddRow(new string[] {
  29.     "Hugo Reyes"});
  30.   table2.AddRow(new string[] {
  31.     "hreyes@dharmainitiative.com"});
  32.   table2.AddRow(new string[] {
  33.     "720-123-5477"});
  34.   testRunner.Then("I should see the following details on screen:"
  35.     ((string)(null)), table2);
  36.   testRunner.CollectScenarioErrors();
  37. }
  38.         

完成您首个功能的定义后,请在按住 Ctrl 的同时按 R 和 T,以运行您的 SpecFlow 测试。 您的 CreateCustomer 测试将失败(无结果),因为 SpecFlow 无法为您的测试中的首个步骤找到一个匹配的步骤定义(参见图 6)。 请留意实际 .feature 文件中的异常报告方式,该方式与隐藏代码文件中的异常报告方式相反。

图 6 SpecFlow 找不到步骤定义

因为您尚未创建步骤定义文件,所以发生此异常是正常的。 在“异常”对话框上单击“确定”,并在 Visual Studio“测试结果”窗口中查找 CreateABasicCustomerRecord 测试。 如果未找到匹配的步骤,则 SpecFlow 将使用您的功能文件生成您的步骤定义文件中所需的代码,您可复制并使用这些代码,以开始实现这些步骤。

在您的 AcceptanceTests 项目中,使用 SpecFlow 步骤定义模板创建一个步骤定义文件,并将其命名为 CreateCustomer.cs。 然后,将 SpecFlow 中的输出复制到该类。 您将注意到,每个方法都使用 SpecFlow 特性进行了修饰,该特性将方法指定为 Given、When 或 Then 步骤,并提供用于将方法与功能文件中某个步骤匹配的 RegEx。

集成 WatiN 以进行浏览器测试

使用 BDD 的部分目标是创建一个自动化测试套件,此套件将尽可能多地演练端到端系统功能。 因为我要构建一个 ASP.NET MVC 应用程序,所以我可以使用许多工具,这些工具有助于编写 Web 浏览器的脚本以与网站进行交互。

这类工具中的其中一个就是 WatiN,它是一个用于自动化 Web 浏览器测试的开放源库。 您可从 watin.sourceforge.net 中下载 WatiN,并将对 WatiN.Core 的引用添加到您的验收测试项目,以方便使用。

与 WatiN 进行交互最主要的途径就是通过浏览器对象(IE() 或 FireFox(),具体取决于您选择的浏览器),这就为控制已安装浏览器的实例提供了一个公共接口。 因为您要在方案中通过多个步骤来演练浏览器,所以您需要一种可在步骤定义类中的步骤之间传递相同浏览器对象的方法。 为了处理此问题,我通常创建一个 WebBrowser 静态类作为 AcceptanceTests 项目的一部分,并利用该类来处理 WatiN IE 对象和 ScenarioContext(SpecFlow 将其用来存储方案中各步骤之间的状态):

  1.           public static class WebBrowser {
  2.   public static IE Current {
  3.     get {
  4.       if (!ScenarioContext.Current.ContainsKey("browser"))
  5.         ScenarioContext.Current["browser"] = new IE();
  6.       return ScenarioContext.Current["browser"as IE;
  7.     }
  8.   }
  9. }
  10.         

在 CreateCustomer.cs 中需要实现的第一个步骤是 Given 步骤,该步骤通过让用户以管理员身份登录到网站来开始测试:


          [Given(@"I am logged into the site as an administrator")]
public void GivenIAmLoggedIntoTheSiteAsAnAdministrator() {
  WebBrowser.Current.GoTo(http://localhost:24613/Account/LogOn);

  WebBrowser.Current.TextField(Find.ByName("UserName")).TypeText("admin");
  WebBrowser.Current.TextField(Find.ByName("Password")).TypeText("pass123");
  WebBrowser.Current.Button(Find.ByValue("Log On")).Click();

  Assert.IsTrue(WebBrowser.Current.Link(Find.ByText("Log Off")).Exists);
}
        

请记住,方案的 Given 部分是用来设置当前测试的上下文。 利用 WatiN,您可拥有自己的测试驱动并让它与浏览器交互,以实现此步骤。

在本步骤中,我使用 WatiN 打开 Internet Explorer,导航到网站的“登录”页面,填写“用户名”和“密码”文本框,然后单击屏幕上的“登录”按钮。 当我再次运行测试时,将自动打开一个 Internet Explorer 窗口,当 WatiN 与网站交互(单击链接并输入文本)时,我可以观察工作中的 WatiN(参见图 7)。

图 7 带有 WatiN 的 Autopilot 上的浏览器

现在将通过 Given 步骤,离实现功能又更近一步了。 现在,SpecFlow 将会在第一个 When 步骤上失败,因为该步骤尚未实现。 您可使用以下代码实现该步骤:

  1.           [When("I click the \" (.*)\" link")]
  2. public void WhenIClickALinkNamed(string linkName) {
  3.   var link = WebBrowser.Link(Find.ByText(linkName));
  4.  
  5.   if (!link.Exists)
  6.     Assert.Fail(string.Format(
  7.       "Could not find {0} link on the page", linkName));
  8.  
  9.   link.Click();
  10. }
  11.         

现在,当我再次运行这些测试时,它们又因为 WatiN 无法在页面上找到带有文本“创建新客户”的链接而失败。 只需向主页添加一个带有该文本的链接,下个步骤就会通过。

是否理解了模式? SpecFlow 鼓励使用相同的 Red-Green-Refactor 方法,此方法是“测试优先”开发方法的主流方法。 功能中每个步骤的间隔的作用类似于实现的虚拟绑定程序,鼓励您仅实现通过步骤所必需的功能。

但是 BDD 过程内部的 TDD 又是怎样的呢? 现阶段我只是在页面级别工作,并且我尚未实现实际创建客户记录的功能。 为了简便起见,现在让我们实现剩下的步骤(参见图 8)。

图 8 步骤定义中剩下的步骤

  1.           [When(@"I enter the following information")]
  2. public void WhenIEnterTheFollowingInformation(Table table) {
  3.   foreach (var tableRow in table.Rows) {
  4.     var field = WebBrowser.TextField(
  5.       Find.ByName(tableRow["Field"]));
  6.  
  7.     if (!field.Exists)
  8.       Assert.Fail(string.Format(
  9.         "Could not find {0} field on the page", field));
  10.     field.TypeText(tableRow["Value"]);
  11.   }
  12. }
  13.  
  14. [When("I click the \"(.*)\" button")]
  15. public void WhenIClickAButtonWithValue(string buttonValue) {
  16.   var button = WebBrowser.Button(Find.ByValue(buttonValue));
  17.  
  18.   if (!button.Exists)
  19.     Assert.Fail(string.Format(
  20.       "Could not find {0} button on the page", buttonValue));
  21.  
  22.   button.Click();
  23. }
  24.  
  25. [Then(@"I should see the following details on the screen:")]
  26. public void ThenIShouldSeeTheFollowingDetailsOnTheScreen(
  27.   Table table) {
  28.   foreach (var tableRow in table.Rows) {
  29.     var value = tableRow["Value"];
  30.  
  31.     Assert.IsTrue(WebBrowser.ContainsText(value),
  32.       string.Format(
  33.         "Could not find text {0} on the page", value));
  34.   }
  35. }
  36.         

我重新运行了测试,现在这些测试因为我没有用来输入客户信息的页面而失败。 若要允许创建客户,则需要一个“创建客户视图”页面。 若要在 ASP.NET MVC 中提供这样一个视图,则需要用来提供此视图的 CustomersController。 现在我需要新的代码,这意味着我的步骤要从 BDD 的外部循环进入到 TDD 的内部循环,如图 2 所示。

第一步是创建一个失败的单元测试。

将单元测试写入实现步骤

在 UnitTest 项目中创建 CustomerControllersTests 测试类之后,您需要创建一个测试方法,该方法用于演练要在 CustomersController 中公开的功能。 具体地说,您要创建 Controller 的一个新实例,调用其 Create 方法,并确保您反过来可接收到合适的“视图”和“模型”:

  1.           [TestMethod]
  2. public void GetCreateShouldReturnCustomerView() {
  3.   var customersController = new CustomersController();
  4.   var result = customersController.Create() as ViewResult;
  5.  
  6.   Assert.AreEqual("Create", result.ViewName);
  7.   Assert.IsInstanceOfType(
  8.     result.ViewData.Model, typeof(Customer));
  9. }
  10.         

此代码尚未编译,因为您尚未创建 CustomersController 或其 Create 方法。 创建该控制器和一个空白的 Create 方法后,将立即编译代码,且测试将失败,这是必需的下一个步骤。 而如果您完成了 Create 方法,则测试将立即通过:

  1.           public ActionResult Create() {
  2.   return View("Create"new Customer());
  3. }
  4.         

如果您重新运行 SpecFlow 测试,则会更接近于完成,但功能仍然不能通过。 这次,测试将因为您不具有 Create.aspx 视图页而失败。 如果您按照功能指示,将其随合适的字段一起添加,则您向完整的功能又迈进了一步。

用于实现此创建功能的由外而内的过程如图 9 所示。

图 9 方案到单元测试过程

这些相同的步骤通常会在此过程中重复出现,循环访问这些步骤的速度随着时间的推移将大大加快,尤其是当您在 AcceptanceTests 项目中实现帮助程序步骤(单击链接和按钮,填写表单等等)和着手测试每个方案中的关键功能时。

现在功能将从有效的创建视图中填写相应的表单字段,且将尝试提交该表单。 您现在可以猜到下面将发生什么事:测试将因为您尚不具有保存客户记录所需的逻辑而失败。

请按照与前面相同的过程,使用如前面图 3 中所示的单元测试代码来创建测试。 在添加接受客户对象以允许编译此测试的空白 Create 方法后,您将看到测试失败,随后请按以下方式完成 Create 方法:


          [AcceptVerbs(HttpVerbs.Post)]
public ActionResult Create(Customer customer) {
  _repository.Create(customer);

  return View("Details", customer);
}
        

我的 Controller 只是一个控制器,客户记录的实际创建属于一个了解数据存储机制的存储对象。 为了简便起见,本文省略了该实现;但要注意,在实际情况中,当需要用来保存客户的存储库时应启动另一个单元测试的子循环。 当您需要访问任意协作对象,而该对象不存在或未提供您需要的功能时,则应该遵循您对 Feature 和 Controller 遵循的相同单元测试循环。

实现 Create 方法并拥有工作存储库之后,您将需要创建“详细信息视图”,此视图保留新客户的记录并在页面上显示这些记录。 然后您可再次运行 SpecFlow。 最后,在多次 TDD 循环和子循环后,您现在具有了通过测试的功能,它证明您系统中存在的一些端到端的功能合适。

祝贺您! 您现已通过验收测试和一组完整的单元测试(用于确保系统进行扩展以添加新功能时新功能可以继续工作)实现了一组端到端功能。

关于重构的说明

在 UnitTests 项目中创建单元级别测试时,希望您不断对每个测试创建进行重构。 当您在链中从通过单元测试移回到通过验收测试时,应遵循相同的过程,关注重构的机会,并重定义每个功能以及随后的所有功能的实现。

还要密切关注重构您的 AcceptanceTests 项目中的代码的机会。 您将发现某些功能中经常出现某些重复的步骤,尤其是您的 Given 步骤。 利用 SpecFlow,您可轻易将这些步骤移动到按功能分类的单独的步骤定义文件中,例如,LogInSteps.cs。 这就让主要步骤定义文件变得简单明了并针对您指定的唯一方案。

BDD 侧重于设计和开发。 通过将您的侧重点从对象提升到功能,您自己和您的团队就可以从系统用户的角度出发进行设计。 由于功能设计演变成为单元设计,因此要确保在编写测试时将您的功能考虑在内,还要确保按照不连续的步骤或任务调整测试。

与任何其他实践或规则类似,BDD 与您的工作流结合还需一些时间。 本人建议您使用任何可用的工具来亲自体验相关操作,并观察该工具的运行情况。 当您以这种方式进行开发时,请注意 BDD 鼓励您提出的问题。 不断寻找可改善您的实践和过程的方法,并与他人协作以进行改善。 我希望,无论您使用什么工具集,对 BDD 的研究都能够增加自己的软件开发实践的价值和关注度。

 

Brandon Satrom 是 Microsoft 在德克萨斯州奥斯汀市的开发推广人员。他的博客地址为 userinexperience.com,还可以通过 Twitter 地址 @BrandonSatrom 与他取得联系。

感谢以下技术专家对本文的审阅:Paul Rayner 和 Clark Sell

posted on 2012-12-08 21:30  Sandy8103  阅读(361)  评论(1编辑  收藏  举报