利用Spec Flow编写自动化验收测试

2013 年 11 月 26 日

对验收测试、Gherkin 及 Spec Flow 的介绍

验收测试或功能测试是验证系统是否满足需求的一种测试。这些测试作为黑盒测试的一种,与其内部具体执行无关。验收测试只是用来验证系统是否符合某一需求。

现在我们一起看下面这个关于网页登录功能的需求:

复制代码
Feature: Login
In order to access my account
As a user of the website
I want to log into the website
Scenario: Logging in with valid credentials
Given I am at the login page
When I fill in the following form
| field | value |
| Username | xtrumanx |
| Password | P@55w0Rd |
And I click the login button
Then I should be at the home page

其可读性非常强,是吧?以上的详细需求是通过 Gherkin 语言来描述的。Gherkin 是一种领域特定语言,它允许我们在不解释具体执行细节的情况下,详细描述应用应该如何执行。以上详细需求的大部分内容是由自由文字组成;只有几个特定的 Gherkin 关键字:Feature、Scenario、Given、When、And 和 Then,其他的都是自由文字,并且主要记录了功能特性是如何被使用的。

Gherkin 是一种基于行的编程语言,场景中的每一行(Line)就是一个步骤(Step)。“Logging in with valid credentials”场景中的第一个步骤是“Given I am at the login page”。该步骤需要一个具体步骤定义,这样我们的测试执行者(test runner)才能知道如何去完成该步骤。Spec Flow 中的步骤定义其实就是一个带有变量的方法, 而该变量包含有该步骤的具体文本。所有步骤定义方法都需包含于一个含有 Binding 属性的类中。

复制代码
[Binding]
class LoginStepDefinitions
{
[Given("I am at the login page")]
public void GivenIAmAtTheLoginPage()
{
// TODO
}
}

上面的类和方法名都是随意的。真正重要的是应用到类和方法中的变量。如果没有它们,Spec Flow 就无法确认步骤定义方法和具体步骤的绑定关系。

现在就剩下步骤定义执行了。而这时候就该 WatiN 和 Nunit 上场了。

WatiN 是一个基于浏览器的自动化工具。我们将利用它来打开一个 IE 实例,浏览 URL,填充表格,点击按钮或链接等。与此同时,我们将利用 NUnit 来断言我们的期望。尽管如此,WatiN 和 NUnit 并非是必需的。也可以使用 Selenium 进行浏览器自动化;而事实上,任何单元测试框架都可用来断言,甚至可以使用 Windows 自带的应用自动化库,比如: White ,然后为相应的 Windows Forms 或 WPF 应用编写自动化验收测试。

现在我们就来尝试为一个真实应用创建实际的验收测试。针对本文,我们将使用该实例程序。读者可以从这里的 repository 获取一份。该 repository 还包含完整的验收测试工程,但是我还是建议通过执行本文剩下的内容来创建自己的验收测试代码。

先决条件

Spec Flow 为所有支持的第三方测试运行器授权以运行真正繁重的验收测试。正如前面所提的,我们将使用 NUnit 执行测试和 WatiN 自动化浏览器。以下就是如何使用 WatiN 自动化浏览器为 WatiN 执行谷歌查询的例子 (来自 WaitN 网站)。

复制代码
[Test]
public void SearchForWatiNOnGoogle()
{
using (var browser = new IE("http://www.google.com"))
{
browser.TextField(Find.ByName("q")).TypeText("WatiN");
browser.Button(Find.ByName("btnG")).Click();
Assert.IsTrue(browser.ContainsText("WatiN"));
}
}

上面的测试创建了一个新 IE 实例,然后将 Google 的 URL 传给构造器,然后由构造器让浏览器跳转到 Google 页面。随后寻找命名为“q”的文本框。该文本框就是你要输入具体查询内容的地方。找到该文本框后,输入“WatiN”。紧接着,查找命名为“btnG”的按钮,然后点击它。最后,由一个断言来确定页面上存在有“WatiN”(可以是页面上任何地方)。

以上的代码为我们快速展示了通过 WatiN 自动化常规任务是多么的简单,这些任务可以是在浏览器上执行例如填写文本框、点击按钮等动作。

接下来,就可以在 Visual Studio 中为你的验收测试创建新的类库工程。当你下载完 NUnit 和 WatiN 后,你将需要添加必需的 DLL 到你的验收测试工程中。从你的 NUnit 下载中添加一个 nunit.framework.dll 引用到你的验收测试工程。对于 WatiN,你则需要添加两个 DLL 引用到你的验收测试工程:Interop.SHDocVw.dll 和 WatiN.Core.dll。

值得一提的是,你可以通过 NuGet 获取 NUnit 和 WatiN 这两个项目。这两个都很容易在 NuGet 中找到,并能自动添加到你的工程中。如果你已经在你的项目中使用 NuGe 了,大可以通过它下载这两个项目。

从网站上获取一份 Spec Flow ,然后安装到你的系统中。跟 NUnit 和 WatiN 不同,你需要在系统中安装 Spec Flow,而不是简单的拷贝 Dll 文件。Spec Flow 自带有某些工具,每次往项目中添加一个特性文件,它都会创建相应的隐藏代码文件。另外,在你编辑特性文件时,它还带有一些语法高亮和其他调整。

成功安装 Spec Flow 后,检查安装目录(默认为 Program Files)。里面有一堆 DLL 文件,但是你只需要添加该引用到你的工程中:TechTalk.SpecFlow.dll。

建立验收测试工程

在我们进一步编写验收测试之前,我们需要建立我们的验收测试工程。我们将在工程中添加几个文件夹以便使项目更有条理。

Features

所有的说明都放在这个文件夹下。

StepDefinitions

所有情景步骤的步骤定义将放在这个文件夹下

StepHelpers

创建 Spec Flow 特性文件

在 Features 文件夹中添加一个新的名字为 Login.feature 的 Spec Flow 特性文件。该文件带有针对场景中新增特性的规格说明。可以将其删除,然后添加以下文本。

复制代码
Feature: Login

Feature 在 Gherkin 中是关键字。它需要在每个 feature 文件中出现一次,紧接着它是一个冒号和其特性名。然后你可以通过任意行的自由文字来描述该特性。为了保持简易性,Gherkin 的创建者建议用户尽量将文档保持最短,并遵循以下格式:

复制代码
In order to realize a named business value
As an explicit system actor
I want to gain some beneficial outcome which furthers the goal

个人看来,我更倾向于跳过上述描述,像登录这样能自我描述的特性,如果你尝试依据某一特定格式将其描述的话,反而让人困惑;有的时候很难区分出第一行中的“named business value”和第三行中你应描述的“beneficial outcome”之间的不同。需要记住的是该部分是自由文字,你可以任意描述。接下来就让我们先跳过这一段,开始编写情景。

复制代码
Feature: Login
Scenario: Logging in with valid credentials

跟 Feature 一样,Scenario 也是 Gherkin 的一个关键字,其后面紧接着一个冒号和其命名。跟 Feature 不同的是,scenario 不能在一行中就完成,它需要由各个步骤一起来完成 scenario。我们可以想一想:我们需要做什么通过有效凭证来成功登录?

  1. 填写登录表单
  2. 点击登录按钮

但是且慢,在我们能够填写登录表单前,我们需要打开有着登录表单的页面。之后,我们需要检查我们是否成功登录。可以假设在登录后,我们将被重定向到主页,从而意味着我们已经成功登录。

现在我们的 scenario 有了前提条件(比如:我们必须在登录页面)和后置条件(比如:我们在主页)。Gherkin 中,前提条件需从关键字 Given 开始,而后置条件需要由关键字 When 开始。

复制代码
Feature: Login
Scenario: Logging in with valid credentials
Given I am at the 'Login' page
When I fill in the following form
| field | value |
| Username | testuser |
| Password | testpass |
And I click the 'Login' button
Then I should be at the 'Home' page

是否注意到它的类表结构?Spec Flow 会自动将第一行斜体化,该行在管道限制行中,并由管道开始。第一行为表头,紧接着各行中的每列将指代表头中定义的任意文本。比如,Password 就是 field 在第二行的内容,而 testuser 则是 value 列在第一行的值。

同时,也应注意到由 And 开始的步骤。And 关键字可以使用于任一步骤之后,并将被自动认为与前一步骤属于同一类型。如果 And 步骤紧接着 Given 步骤, 那么该步骤也被认为是一个 Given 步骤。在上述例子中,该 And 步骤被认为是 When 步骤。When 步骤不用做 pre 或 post 条件,但是 scenario 需要这一部分用于进一步执行。

创建第一个步骤定义

现在我们完成了对 Login 特性的定义,但是我们的 test runner 还不知道如何执行该特性情景中的每一个步骤。我们需要为 Login 特性情景中的 4 个步骤一一定义。为了完成这个,我们将在 Step 目录中创建一个类,并将其命名为 LoginSteps。为了让 Spec Flow 知道该类含有步骤定义,我们给该类绑定下 Binding 属性。该 Binding 属性属于 TechTalk.SpecFlow 命名空间的一部分。

复制代码
using TechTalk.SpecFlow;
[Binding]
class LoginSteps
{
}

接下来,我们需要给每个步骤创建一个方法。该方法将告诉 Spec Flow 如何执行每一步骤。现在我们就只为第一个步骤进行具体步骤定义:“Given I am at the ‘Login’page”。

复制代码
[Given("I am at the 'Login' page")
public void GivenIAmAtTheLoginPage()
{
// TODO
}

请注意到该方法带有一个属性。该属性将告诉 Spec Flow 此方法所指代的步骤,每个步骤都有相应的属性。任何一个 feature 文件中的任意一个以关键字“Given”开始的步骤,紧接着的文本是“I am at the ‘Login’page”的都将会与该方法配对。

现在我们需要为该步骤定义编写具体执行。我们需要告诉 WatiN 启动浏览器,然后访问程序登录页面。但是在这之前我们需要创建一个浏览器实例。我们也要确保 scenario 中剩余步骤也将使用该浏览器实例。为了保证 scenario 中所有步骤使用的是同一个浏览器实例,我们需要为浏览器对象创建一个实例,并将其保存于 ScenarioContext 字典中。ScenarioContext 字典可以用于保存 Scenario 执行过程中的数据。我们还要创建一个名为 WebBrowser 的 helper 类,该类将保存 scenario 执行时的浏览器实例。

复制代码
using TechTalk.SpecFlow;
using WatiN.Core;
static class WebBrowser
{
public static IE Current
{
get
{
if(!ScenarioContext.Current.ContainsKey("browser"))
ScenarioContext.Current["browser"] = new IE();
return ScenarioContext.Current["browser"] as IE;
}
}
}

以上的 Helper 类含有 Current 属性,它将获取现有浏览器用于目前正在执行的 scenario。如果它没在 ScenarioContext 字典中找到浏览器实例,它将创建一个新浏览器实例,然后将其添加到字典中。这样存在于字典中的浏览器实例又回来了。

最后,我们终于可以回去实现我们的步骤定义了。在示例应用中,登录页面存在于 http://localhost:9876/authentication/login。我们将通过让当前 Scenario 浏览器实例定位到该 URL 以执行我们的步骤定义。我们可以通过浏览器实例回到主页,然后点击登录链接来执行我们的步骤定义,而这在现在看来是最简单的。然后,我们将重构我们的步骤定义,这样我们只要有一个步骤定义就能解决应用中所有页面跳转问题。

复制代码
[Given("I am at the 'Login' page")
public void GivenIAmAtTheLoginPage()
{
// Make sure to add the namespace the WebBrowser class is inside
WebBrowser.Current.GoTo("http://localhost:9876/authentication/login");
}

结尾

现在我们已经为尝试执行验收测试做好准备了。虽然我们还没完成所有的步骤定义,但是我们想在进一步深入前,确保各方面都已经正确衔接上。在执行测试之前,我们要保证所有加到工程中的引用都已设置成了 Copy Local。如果是通过 NuGet 添加的 WatiN,Interop.SHDocVw DLL 则默认将它的 Embed Interop 属性设置成 True。这时,需要确保将 Embed Interop 属性设置成 false,这样才能将它的 Copy Local 属性设置成 True。

同时,我们也需要在单线程的 Apartment State 中运行 NUnit,不然就不能自动化 IE 浏览器。之所以选择 IE,而非 Firefox 是因为 Firefox 持续更新它的主要版本,而这会不断破坏 WatiN 与 Firefox 之间的衔接。

设置 NUnit 的 Apartment State 需要使用到配置文件。往工程中添加一个 app.config 文件,并添加以下配置。

复制代码
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<sectionGroup name="NUnit">
<section name="TestRunner" type="System.Configuration.NameValueSectionHandler"/>
</sectionGroup>
</configSections>
<NUnit>
<TestRunner>
<!-- Valid values are STA,MTA. Others ignored. -->
<add key="ApartmentState" value="STA" />
</TestRunner>
</NUnit>
</configuration>

执行验收测试

Login.feature 文件将有个名字为 Login.feature.cs(或者.vb)的代码隐藏文件。该文件是含有 TestFixture 属性的类,这样 NUnit 才能知道需要测试哪个类。如果你的 Visual Studio 中已经安装了 NUnit 测试运行插件,比如:TestDriven.Net 或 ReSharper,那么你就可以使用 Login.feature.cs 文件来运行测试。如果你没有相应的测试插件,那么你可以打开 NUnit 自带的、位于应用目录下的测试运行器,然后将其指向由验收测试产生的配置文件中,这样它将定位 Login.feature.cs 中的测试设置。

在执行测试前,保证应用的服务器处于运行状态。然后运行程序, 一个新的 IE 实例将被打开,并跳转到登陆页面。如果做到了这些,那就说明各个部分已经被正确配置,我们就可以继续完成剩余的步骤定义。如果 IE 没有打开,或没有跳转到登陆页面,那就要进一步调试了。

完成剩余的步骤定义

编写剩余步骤定义将会非常简单。你只需对 WatiN API 有一定了解,这样就可以配置 WatiN 以填写表单或查找按钮和链接,然后对其点击。

首先,我们尝试情景中的第二个步骤:“When I fill in the following Form”。需要在 LoginSteps 类中创建一个方法,并带有一个变量将其绑定到我们目前正在工作的步骤上。

复制代码
[When("I fill in the following form")]
public void WhenIFillInTheFollowingForm(TechTalk.SpecFlow.Table table)
{
// TODO
}

注意传到该步骤定义中的参数。该 Table 对象包含有我们在情景中所描述的值。而 WatiN 在其命名空间中也有一个 Table 类,为了避免冲突,我对该 Table 对象采用了完全限定名。该 Table 对象将由行组成。行中的每列可以通过索引或列名来获取。列名由情景中表的第一行来定义。

复制代码
[When("I fill in the following form")]
public void WhenIFillInTheFollowingForm(TechTalk.SpecFlow.Table table)
{
foreach(var row in table.Rows)
{
var textField = WebBrowser.Current.TextField(Find.ByName(row["field"]));
if(!textField.Exists)
Assert.Fail("Expected to find a text field with the name of '{0}'.", row["field"]);
textField.TypeText(row["value"]);
}
}

是的,只需要 5 行,我们就完成了填写表单的步骤定义。我们首先循环表中的行,然后试图查找相应的带有命名属性的 field 文本,用来匹配当前行中 field 列值。如果没有匹配到相应的文本 field,测试将会失败(但是会有相应信息被记录下来)。但是如果我们找到一个匹配的文本 field,WatiN 将会被指示输入在当前行所找到的列值。

接下来剩余的步骤定义也基本类似。以下就是我们下个步骤的代码:“And I click the ‘Login’button”。

复制代码
[When("I click the 'Login' button")]
public void AndIClickTheLoginButton()
{
var loginButton = WebBrowser.Current.Button(Find.ByValue("Login"));
if(!loginButton.Exists)
Assert.Fail("Expected to find a button with the value of 'Login'.");
loginButton.Click();
}

而最后一步,我们则需要弄清如何去验证我们是否在主页上。我们可以检测其文件标题来看它是否满足我们的需要;或者检测 URL 来查看其是否与期望的主页 URL 匹配。这里我们将通过 URL 来检测。但需要记住的是步骤定义的执行会完全依赖于具体应用:如果我们大量使用 Ajax 进行页面切换,URL 可能不会被更新,从而可能无法验证我们是否在正确页面上。

复制代码
[Then("I should be at the 'Home' page")]
public void ThenIShouldBeAtTheHomePage()
{
var expectedURL = "http://localhost:9876/";
var actualURL = WebBrowser.Current.Url;
Assert.AreEqual(expectedURL, actualURL);
}

现在重新执行验收测试,而这次它将执行所有步骤。如果成功了,那么恭喜你,你成功地通过 Spec Flow 编写了第一个验收测试。

重构测试代码

现在我们的步骤定义已经成功运行了,可是我们还是需要停下来回顾一下。我们已经有了针对登录按钮的步骤定义,有很大可能我们的应用中将有大量的按钮。我们是否为验收测试集中的每个按钮提供一个步骤定义呢?这些步骤定义中唯一一个需要修改的地方就是 WatiN 在某页面上寻找期望按钮所对应的具体文本,以下就是个例子:

复制代码
[When("I click the 'Login' button")]
public void AndIClickTheLoginButton()
{
var loginButton = WebBrowser.Current.Button(Find.ByValue("Login"));
if(!loginButton.Exists)
Assert.Fail("Expected to find a button with the value of 'Login'.");
loginButton.Click();
}
[When("I click the 'Register' button")]
public void AndIClickTheRegisterButton()
{
var registerButton = WebBrowser.Current.Button(Find.ByValue("Register"));
if(!registerButton.Exists)
Assert.Fail("Expected to find a button with the value of 'Register'.");
registerButton.Click();
}

幸运的是,Spec Flow 为该问题提供了解决方案。你可以使用正则表达式,该表达式必须是传递给绑定属性的字符串,这样我们就将不同步骤绑定到同一个步骤定义。任何由该正则表达式捕获的文本都可以当作一个参数传递给步骤定义。具体例子如下:

复制代码
[When("I click the '(.*)' button")]
public void AndIClickAButton(string buttonText)
{
var button = WebBrowser.Current.Button(Find.ByValue(buttonText));
if(!button.Exists)
Assert.Fail("Expected to find a button with the value of '{0}'.", buttonText);
button.Click();
}

该步骤定义可以是以下任意一种:

  • 点击‘登陆’按钮
  • 点击‘注册’按钮
  • 点击‘任意文本’按钮

你可以使用该技巧来让所有步骤定义可重用。让所有的步骤定义都具有可重用性是个非常好的想法,我们应该避免编写只有某一特定情景才可以用的步骤定义。可重用性允许你写出的 Scenario 能立即使用,因为你已经拥有一个可以重复使用的步骤定义。但是也没必要在需要前就把步骤定义编写出来,在需要时编写就好了。大多数情况下,你的测试所需要做的基本上是同样的事情:跳转到一个页面,填写表单,点击之类的。只有在极少数情况下,你才需要为一个情景编写特殊步骤定义,比如验证 jQuery UI 日历是否在点击一个需要日期值的文本框时弹出,这样不寻常的例子时候才需要。

对于具体如何重构你的步骤定义,我将其作为练习留给你,确保每个都具有可重用性。之后,你就可以编写新的特性文件,而因为所需的步骤定义都已经准备好了,它们都可以立刻工作。当然,你将需要更多的步骤定义来自动化不同行为(比如:点击一个链接),你也需要进一步改善当前步骤定义,以便在不同情景中也能使用。你将注意到我们的表单填充步骤定义中处理的表单只带有文本框。如果你想勾选复选框,通过 value 或 ID 寻找按钮,或其它与表单相关操作,那你就需要在表单填充步骤定义中添加相应的逻辑。

总结

总之,以上就是使用 Spec Flow 编写验收测试之旅。尽管如此,Spec Flow 依然有更多特性需要你自己去发现。比如:Spec Flow 还有个跟踪机制,因此你能跟踪某个指定特性在特定特性,情景或步骤前后是如何执行某些代码。对于以下情况,它就会起到非常大的作用,比如:如果你需要在完成含有登陆功能的场景后的登出;测试前准备数据库,或只是简单地在每个测试结束后关闭浏览器窗口。

随着测试不断被创建,你会发现完成验收测试集所需的时间也会随之增加。但是你的测试执行得越快,你就能越快得到回馈,并找到问题所在。你需要尽可能快地执行你的测试。方法之一就是将你的测试并行化,而非按顺序执行,所有测试将一起执行,而且将更快结束。但是 Spec Flow 并没有提供并行处理功能,因此需要从别处想办法。如果你使用 NUnit,你可以查看 PNUnit 看其是否满足你的并行需求。

Gherkin,我们曾经是使用 DSL 来编写我们的详细需求,它被设计作为衔接技术人员与非技术利益相关者之间的桥梁,以便他们在某一特定特性应该如何运行上能够达成一致。有的团队甚至有非技术利益相关者参与到使用 Given-When-Then 语句来编写实际详细需求。可以想象到,培训大家如何做到这点并不太难,但是其结果却大大不同。

也有一部分人更进一步地使用 Spec Flow。他们在开始所有新特性开发时,首先就是编写详细需求,接着单元测试,最后才是真正的编码。然后他们通过常规的 TDD red-green-refactor 循环通过单元测试,最后通过验收测试。如果你已经在实践 TDD,打算为项目编写验收测试,那你应该尝试下验收测试驱动开发。

无论你是通过什么途径,你都需确保它对你、还有你的工程都要行之有效。如果你还处于建模状态,并不断修改你的应用,持续更新验收测试可能会造成一定时间拖延。什么时候应该着手编写验收测试是你的决定,只有你能保证它将发生。

关于作者

Mustafa Saeed Haji Ali 现居于 Hargeisa,Somaliland。作为一名软件开发工程师,他通常利用 ASP.Net MVC 来工作。Mustafa 热衷于测试和使用 JaveScript 框架,比如:KnockoutJS,AngularJS 和 SignalR。在传播最佳实践上,Mustafa 也有极大的热情。

参考英文原文: Writing Automated Acceptance Tests with Spec Flow


感谢陈菲对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。

2013 年 11 月 26 日 04:001791
用户头像

发布了 39 篇内容, 共 10.6 次阅读, 收获喜欢 2 次。

关注

评论

发布
暂无评论
发现更多内容

什么是 Kubeless?| 玩转 Kubeless

donghui2020

Kubernetes kubeless

MySQL-技术专题-SQL性能分析

李浩宇/Alex

技术解码 | 玩转视频播放,自适应码流技术

腾讯云视频云

音视频 转码

2020第十三届南京国际智慧新零售暨无人售货展览会

InfoQ_caf7dbb9aa8a

2020第十三届南京国际智慧工地装备展览会

InfoQ_caf7dbb9aa8a

数字货币交易所系统开发源码,交易平台搭建

WX13823153201

数字货币交易所系统开发

高难度对话读书笔记——目的篇

wo是一棵草

MySQL-技术专题-Join语法以及性能优化

李浩宇/Alex

Redis-技术专题-数据结构

李浩宇/Alex

SpringBoot-技术专题-@Async异步注解

李浩宇/Alex

从戚家军看组织战斗力塑造(组织的六脉神剑)

异想的芦苇

组织

我就不服了,看完这篇文章,5大常见消息队列开发你还学不会

小Q

Java 编程 程序员 开发 消息队列

技术革新的脉络及趋势

异想的芦苇

技术 进步

2020第十三届南京国际智慧停车展览会

InfoQ_caf7dbb9aa8a

2020第十三届南京国际大数据产业博览会

InfoQ_caf7dbb9aa8a

“三段三域法”应用架构模型

异想的芦苇

架构 架构设计 技术架构

全屋智能2020第十三届(南京)国际智能家居展览会

InfoQ_caf7dbb9aa8a

2020南京国际人工智能产品展览会

InfoQ_caf7dbb9aa8a

人工智能

架构方法论之“极限审视法”

异想的芦苇

架构 方法论 设计思维

架构师训练营第一期 - 第四周课后 - 作业二

极客大学架构师训练营

MySQL-技术专题-实战技巧

李浩宇/Alex

第3周学习总结

饭桶

坚持写技术博客一年能有多少收获!

小傅哥

Java 面试题 架构师 编程经验 技术博客

2020南京国际工业互联网及工业通讯展览会

InfoQ_caf7dbb9aa8a

轻言业务架构图

异想的芦苇

架构 企业架构 架构设计 架构设计原则 业务架构

手把手教你锤面试官 04——假装精通redis

慵懒的土拨鼠

PanDownload复活了!60MB/s!附下载地址

程序员生活志

PanDownload 网盘 下载器

第3周作业提交

饭桶

晨间日记的奇迹

熊斌

读书笔记

Java 客户端操作 FastDFS 实现文件上传下载替换删除

哈喽沃德先生

Java 文件系统 分布式文件存储 fastdfs 文件服务器

转型敏捷123

技术管理Jo

利用Spec Flow编写自动化验收测试-InfoQ