类型:转载 责任编辑:asp.net 日期:2007/05/23
热门软件下载:










页面导航:
正文内容:最新发布的 visual studio test system (vsts) 包含了一套用于 visual studio team test 的完整功能。team test 是 visual studio 集成的单元测试框架,它支持:
• 测试方法存根 (stub) 的代码生成。 • 在 ide 中运行测试。 • 合并从数据库中加载的测试数据。 • 测试运行完成后,进行代码覆盖分析。
另外,team test 包含了一套测试功能,可以同时支持开发人员和测试人员。
在本文中,我们准备演练如何创建team test 的单元测试。我们从一个简单的示例程序集开始,然后在该程序集中生成单元测试方法存根。这样可以为team test 和单元测试的新手读者提供基本的语法和代码,同时也很好地介绍了如何快速建立测试项目的结构。然后,我们转到使用测试驱动开发 (test driven development, tdd) 方法,即在写产品代码前先写单元测试。
team test的一个关键特点是从数据库中加载测试数据,然后将其用于测试方法。在演示基本的单元测试后,我们描述如何创建测试数据并集成到测试中。
本文中使用的示例项目包含一个 longoninfo 类,它封装了与登录相关的数据(例如用户名和密码)以及一些关于数据的简单的验证规则。最终的类如下图 1 所示。
图1. 最终的logoninfo类
请注意所有的测试代码位于一个单独的项目。这是有道理的,产品代码应该尽可能少的受测试代码影响,所以我们不想在产品代码的程序集中嵌入测试代码。
首先,我们创建一个名为“vstsdemo”的类库项目。默认情况下,为方案创建目录(create directory for solution) 复选框被选中。保留此选项可以使我们在 vstsdemo 项目的同一层目录创建测试项目。相反,如果不选中此选项,visual studio 2005 会将测试项目放在 vstsdemo 项目的子目录中。测试项目遵循 visual studio 在解决方案文件路径的子目录中创建额外项目的规定。
创建初始的 vstsdemo 项目后,我们使用 visual studio 的解决方案资源管理器将 class1.cs 文件重命名为 logoninfo.cs,这样类名也会被更新为 logoninfo。然后我们修改构造函数以接受两个字符串参数:userid 和 password。一旦构造函数的签名被声明,我们就可以为构造函数生成测试。
图2. longoninfo 构造函数的上下文菜单的“创建测试…” (create tests...) 菜单项
在开始编写 logoninfo的任何实现之前,我们遵循 tdd 实践的规则,首先编写测试。tdd 在team test 中并不是必需的,但最好在本文的剩余部分遵循 tdd。右键单击 logoninfo()构造函数,然后选择“创建测试…”菜单项(如图 2 所示)。这样会出现一个对话框,可以在不同的项目中生成单元测试(如图 3 所示)。默认情况下,项目设置的输出 (output) 选项是一个新的 visual basic 项目,但是也可以选择 c# 和 c++ 测试项目。在本文中,我们选择 visual c#,然后单击 ok 按钮,接着输入项目名 vstsdemo.test。测试项目名称。
图3. 生成单元测试对话框
生成的测试项目包含四个与测试相关的文件。
文件名 目的 authoringtest.txt 提供关于创建测试的说明,包括向项目增加其他测试的说明。 logoninfotest.cs 包含了用于测试 logoninfo()的生成测试,以及测试初始化和测试清除的方法。 manualtest1.mht 提供了一个模板,可以填入手工测试的指令。 unittest1.cs 一个空的单元测试类架构,用于放入另外的单元测试。
因为我们不打算对该项目进行手工测试,并且由于已经有了一个单元测试文件,我们将删除 manualtest1.mht 和 unittest1.cs。
除了一些默认的文件,生成的测试项目还包含了对 microsoft.visualstudio.qualitytools.unittestframework和 vstsdemo 项目的引用。前者是测试引擎运行单元测试需要依赖的测试框架程序集,后者是对我们需要测试的目标程序集的项目引用。
默认情况下,生成的测试方法是包含以下实现的占位符:
清单 1. 生成的测试方法:constructortest(),位于vstsdemo.test.logoninfotest
/// <summary>
///this is a test class for vsttdemo.logoninfo and is intended
///to contain all vsttdemo.logoninfo unit tests
///</summary>
[testclass()]
public class logoninfotest
{
// ...
/// <summary>
///a test case for logoninfo (string, string)
///</summary>
[testmethod()]
public void constructortest()
{
string userid = null; // todo: initialize to an appropriate value
string password = null; // todo: initialize to an appropriate value
logoninfo target = new logoninfo(userid, password);
// todo: implement code to verify target
assert.inconclusive(
"todo: implement code to verify target");
}
}
确切的生成代****根据测试目标的方法类型和签名不同而有所不同。例如,向导会为私有成员函数的测试生成反射代码。在这种特别的情况下,我们需要专门用于公有构造函数测试的代码。
关于team test ,有两个重要的特性。首先,作为测试的方法由 testmethodattribute属性指定,另外,包含测试方法的类有 testclassattribute属性。这些属性都可以在 microsoft.visualstudio.qualitytools.unittesting.framework 命名空间中找到。team test 使用反射机制在测试程序集中搜索所有由 testclass修饰的类,然后查找由 testmethodattribute修饰的方法来决定执行的内容。另外一个重要的由执行引擎而不是编译器验证的标准是,测试方法的签名必须是无参数的实例方法。因为反射搜索 testmethodattribute,所以测试方法可以使用任意的名字。
测试方法 constructortest()首先实例化目标 longoninfo 类,然后断言测试是非决定性的(使用assert.inconclusive())。当测试运行时,assert.inconclusive()说明了它可能缺少正确的实现。在我们的示例中,我们更新 constructortest()方法,让它检查用户名和密码的初始化,如下所示。
清单2. 更新的constructortest()实现
/// <summary>
///a test case for logoninfo (string, string)
///</summary>
[testmethod()]
public void constructortest()
{
string userid = "imontoya";
string password = "p@ssw0rd";
logoninfo logoninfo = new logoninfo(userid, password);
assert.areequal<string>(userid, logoninfo.userid,
"the userid was not correctly initialized.");
assert.areequal<string>(password, logoninfo.password,
"the password was not correctly initialized.");
}
请注意我们的检查使用 assert.areequal<t>() 方法完成。assert方法也支持没有泛型的 areequal(),但是泛型版本几乎总是首选,因为它会在编译时验证类型匹配 - 在 clr 支持泛型前,这种错误在单元测试框架中非常普遍。
因为 userid 和 password 的实例域还没有创建,我们需要回头将其添加到 logoninfo类中,以便vsttdemo.test 项目可以编译。
即使我们还没有一个有效的实现,让我们开始运行测试。如果我们遵循 tdd 方法,我们就应该直到测试证明我们需要这样的代码时才去编写产品代码。我们仅在建立项目结构时违背此原则,但是一旦项目建立后,就可以容易地始终遵循 tdd 方法。
运行测试
要运行项目中的所有测试,只需要运行测试项目。要实现这一点,我们需要右键单击解决方案资源管理器的vstsdemo.test 项目,选择设置为启动项目(set as startup project)。接着,使用菜单项调试->启动(f5) 或者调试->开始执行(不调试)(ctrl+f5) 开始运行测试。
这时出现测试结果窗口,列出项目中的所有测试。因为我们的项目只包含一个测试,因此只列出了一个测试。开始的时候,测试会处于挂起的状态,但是一旦测试完成,结果将是我们意料中的失败(如图 4 所示)。
图 4. 执行所有测试后的测试结果窗口
图 4 显示了测试结果 (test results) 窗口。这个特别的屏幕快照除了默认的列外,还显示了错误信息。您可以在列头上单击右键并选择菜单项增加/删除列…以增加或者删除列。
如果要查看测试的额外细节,我们可以选定测试并双击,打开“constructortest[results]”窗口,如图 5 所示。
图 5. 详细的测试constructortest [results]窗口
另外,我们可以右键单击单个测试,然后选择打开测试(open test) 菜单项,进入测试代码。因为我们已经知道问题在于 logoninfo 构造函数的实现,我们可以去那里编写初始化 userid 和 password 字段的代码,使用传入的参数对它们进行初始化。重新运行测试以验证测试现在可以通过。
检查异常
下一步是创建 longoninfo 类,以提供对 userid 和 password 的一些验证。不幸的是,userid和 password 字段是公共的,这意味着它们没有提供任何封装来确保它们有效。但是在我们将其转换为属性并提供验证前,让我们编写一些测试来验证任何实现的结果都是正确的。
我们首先来编写一个测试,防止空值 (null) 或空字符串赋值给 userid。预期结果是,如果空值传送给构造函数,会引发一个 argumentexception异常。测试代码如清单 3 所示。
清单3. 使用expectedexceptionattribute对异常情况进行测试
[testmethod]
[expectedexception(typeof(argumentexception),
"a userid of null was inappropriately allowed.")]
public void nulluseridinconstructor()
{
logoninfo logoninfo = new logoninfo(null, "p@ss0word");
}
[testmethod]
[expectedexception(typeof(argumentexception),
"a empty userid was inappropriately allowed.")]
public void emptyuseridinconstructor()
{
logoninfo logoninfo = new logoninfo("", "p@ss0word");
}
请注意对于 argumentexception没有 try-catch 代码块的显式测试。不过,两个测试都包含另外一个属性 expectedexception,它接受一个类型参数,以及一个可选的错误信息,用于在没有引发异常时显示。当这个单元测试执行时,测试框架会显式地监视引发的 argumentexception异常,如果方法没有引发这个异常,测试将失败。运行这些测试会证明我们还没有对 userid 做任何验证检查;因此,测试会失败,因为没有引发预期的异常。
有了失败的测试,现在可以回到产品代码进行更新来提供测试需要检查的功能。在这个例子中,我们将 userid字段转换为属性,并提供验证检查(清单 4)。
清单4. 在logoninfo类中验证userid
public class logoninfo
{
public logoninfo(string userid, string password)
{
this.userid = userid;
this.password = password;
}
private string _userid;
public string userid
{
get { return _userid; }
private set
{
if (value == null || value.trim() == string.empty)
{
throw new argumentexception(
"parameter userid may not be null or blank.");
}
_userid = value;
}
}
// ...
}
属性的实现使用了 c# 2.0 的功能,其中 getter 和 setter 的访问权限不一致。setter的实现标识为私有,而 getter 实现为公有。这样 userid 就不能在 logoninfo 类外被修改了(除非通过反射机制)。
一旦增加了验证,我们可以重新运行测试来验证实现是正确的。我们运行所有的三个测试来验证 userid 字段转换为属性的重构过程没有产生任何意外的错误。单元测试的真正价值在代码修改的时候才真正有所体现。一套单元测试可以保证我们在维护和改进代码的时候没有破坏代码。
从数据库中加载测试数据
对于 logoninfo 类的下一次修改,我们将提供一个方法来改变密码。该方法接受旧密码和新密码作为参数。另外,我们会验证密码符合某种复杂性需求。确切的说,我们将保证密码符合 windows active directory 的默认需求,即包含以下四种类型字符中的三种:
•
大写字母
•
小写字母
•
标点符号
•
数字
另外,我们将检查密码最少包含 6 个字符,最多包含 255 个字符。
和之前一样,我们在编写实现前先为密码复杂性需求编写测试。但是显然,我们需要提供一个测试值的大集合用于验证实现。我们不是为每个测试用例创建一个单独的测试,也不是创建一个循环来调用一系列的测试用例,我们将创建一个数据驱动测试,它从数据库中取出所需的数据。
测试视图 (test view) 窗口
首先我们定义一个名为 changepasswordtest() 的新测试。定义后,从菜单项测试->查看和创建测试(test->view and author tests)为测试方法打开测试视图窗口,如图 6 所示:
图6. 测试视图 (test view) 窗口
测试视图窗口可用来运行指定的测试和浏览测试的特定属性。通过增加额外的列(右键单击列头并选择添加/删除列…),我们可以排序并根据偏好查看测试。有些列来自修饰测试的属性。例如,添加 ownerattribute将在所有者列显示测试的所有者。其它元数据属性(如 descriptionattribute)也可以使用。这些属性都可以在 microsoft.visualstudio.qualitytools.unittesting.framework 命名空间中找到。如果没有显式的属性存在,那么我们可以使用自由形式的 testpropertyattribute来为特别的测试方法增加名-值对。
没有对应列的属性可以在一个测试的属性窗口中显示(选择一个测试,在右键上下文菜单中单击属性)。它包含了指定数据连接字符串和用于载入测试数据的表名的属性。显然,为了指定有效值,我们需要一个数据库连接。
增加一个测试数据库
从服务器资源管理器窗口,我们可以使用创建新的 sql server数据库(create new sql server database) 菜单项。但是要小心这种方法,如果我们要在其它计算机上执行测试的话,我们要保证在一台服务器上创建数据库,其它可能执行测试的计算机必须能够访问该服务器 — 例如一台用于构建的计算机。
另外一个选择是仅仅增加一个数据库文件。使用项目->增加新项… (project->add new item...) 允许向项目插入一个 sql 数据库文件。这种方法使测试数据和测试项目保持在一起。缺点是如果数据库变得很大,我们就不想这么做,而宁可提供全局的数据源。
对于本项目中的数据,我们创建一个名为 vstsdemo.mdf的本地项目数据库文件。为了向文件加入测试数据,我们使用菜单工具->连接到数据库 (tools->connect to database),然后指定 vstsdemo.mdf 文件。然后,从服务器资源管理器窗口我们可以使用设计器加入一个新的表 longoninfotest。清单 5 显示了该表的定义。
清单5. logoninfotestdata sql 脚本
create table dbo.logoninfotest
(
userid nchar(256) not null primary key clustered,
password nvarchar(256) null,
isvalid bit not null
) on [primary]
go
保存表后,我们可以将其打开,然后输入不同的非法密码,如下表所示。
userid
password
isvalid
humperdink
p@w0d
false
imontoya
p@ssword
false
inigo.montoya
p@ssw0rd
false
wesley
password
false
将数据与测试关联
一旦完成表的创建,我们需要将其与测试 invalidpasswords()联系起来。从测试 invalidpasswords的属性窗口,我们填写数据连接字符串(data connection string) 和数据表名 (data table name) 属性。这样做将使用附加的属性 datasourceattribute和 datatablenameattribute更新测试。最终的方法 changepasswordtest()在清单 6 中显示。
清单6. 用于数据驱动测试的测试代码
enum column
{
userid,
password,
isvalid
}
private testcontext testcontextinstance;
/// <summary>
///gets or sets the test context which provides
///information about and functionality for the
///current test run.
///</summary>
public testcontext testcontext
{
get
{
return testcontextinstance;
}
set
{
testcontextinstance = value;
}
}
[testmethod]
[owner("mark michaelis")]
[testproperty("testcategory", "developer"),
datasource("system.data.sqlclient",
"data source=.\\sqlexpress;attachdbfilename=\"<path to the sample .mdf file>";integrated security=true",
"logoninfotest",
dataaccessmethod.sequential)]
public void changepasswordtest()
{
string userid =
(string)testcontext.datarow[(int)column.userid];
string password =
(string)testcontext.datarow[(int)column.password];
bool isvalid =
(bool)testcontext.datarow[(int)column.isvalid];
logoninfo logoninfo = new logoninfo(userid, "p@ssw0rd");
if (!isvalid)
{
exception exception = null;
try
{
logoninfo.changepassword(
"p@ssw0rd", password);
}
catch (exception tempexception)
{
exception = tempexception;
}
assert.isnotnull(exception,
"the expected exception was not thrown.");
assert.areequal<type>(
typeof(argumentexception), exception.gettype(),
"the exception type was unexpected.");
}
else
{
logoninfo.changepassword(
"p@ssw0rd", password);
assert.areequal<string>(password, logoninfo.password,
"the password was not changed.");
}
}
清单 6 第一个需要注意的地方是增加了 datasourceattribute属性,它指明了连接字符串、表名和访问顺序。在这个清单中,我们使用数据库文件名标识数据库。这样的优点是该文件和测试项目一起迁移,假设它可能会被移动到一个相对的路径。
第二个注意的地方是 testcontext.datarow调用。testcontext是在我们运行创建测试向导时由生成器提供的属性,它在运行时由测试执行引擎自动赋值,这样我们就可以在测试中访问跟测试环境关联的数据。如图 7 所示。
图 7. testcontext 关联
如图 7 所示,testcontext提供了 testdirectory和 testname数据,以及 begintimer()和endtimer()方法。对 changepasswordtest()方法最有意义的是 datarow属性。因为 changepasswordtest()方法由 datasourceattribute修饰,该属性指定的表返回每个记录时,该方法都会被调用一次。这就使测试代码使用运行中的测试的数据,而且对插入 longoninfotest 表的每条记录重复执行测试。如果表包含四条记录,那么测试将会分别执行四次。
使用这样的数据驱动测试方法,可以很容易的提供额外的测试数据,而不需要编写任何代码。一旦需要额外的测试用例,我们需要做的就是向 longoninfotest 表增加关联的数据。尽管我们可以创建两个独立的测试来使用单独的表分别测试有效和无效数据,这个特定的例子合并了这些测试来显示稍微复杂的数据测试实例。
实现和重构目标方法
现在我们已经有了测试,是时候为测试编写实现了。使用 c# 重构工具,我们可以右键单击 changepassword()方法调用,选择菜单项generatemethodstub,然后对于生成的方法提供实现,一旦我们成功地运行了使用所有测试数据的测试,我们也可以开始重构代码了,logoninfo 类的最终实现如清单 7 所示。
清单7. logoninfo类
using system;
using system.text.regularexpressions;
namespace vsttdemo
{
public class logoninfo
{
public logoninfo(string userid, string password)
{
this.userid = userid;
this.password = password;
}
private string _userid;
public string userid
{
get { return _userid; }
private set
{
if (value == null || value.trim() == string.empty)
{
throw new argumentexception(
"parameter userid may not be null or blank.");
}
_userid = value;
}
}
private string _password;
public string password
{
get { return _password; }
private set
{
string errormessage;
if (!isvalidpassword(value, out errormessage))
{
throw new argumentexception(
errormessage);
}
_password = value;
}
}
public static bool isvalidpassword(string value,
out string errormessage)
{
const string passwordsizeregex = "(?=^.{6,255}$)";
const string uppercaseregex = "(?=.*[a-z])";
const string lowercaseregex = "(?=.*[a-z])";
const string punctuationregex = @"(?=.*\d)";
const string upperlowernumericregex = "(?=.*[^a-za-z0-9])";
bool isvalid;
regex regex = new regex(
passwordsizeregex +
"(" + punctuationregex + uppercaseregex + lowercaseregex +
"|" + punctuationregex + upperlowernumericregex + lowercaseregex +
"|" + upperlowernumericregex + uppercaseregex + lowercaseregex +
"|" + punctuationregex + uppercaseregex + upperlowernumericregex +
")^.*");
if (value == null || value.trim() == string.empty)
{
isvalid = false;
errormessage = "password may not be null or blank.";
}
else
{
if (regex.match(value).success)
{
isvalid = true;
errormessage = "";
}
else
{
isvalid = false;
errormessage = "password does not meet the complexity requirements.";
}
}
return isvalid;
}
public void changepassword(
string oldpassword, string newpassword)
{
if (oldpassword == password)
{
password = newpassword;
}
else
{
throw new argumentexception(
"the old password was not correct.");
}
}
}
}
代码覆盖
单元测试的一个关键度量是决定在单元测试运行时测试了多少代码。该度量称为代码覆盖,team test 包含了一个代码覆盖工具,可以详细解释被执行代码的百分率,并突出显示哪些代码被执行,那些没有被执行。该功能如图 8 所示。
图8. 突出显示代码覆盖
图 8 显示了运行所有单元测试后的代码覆盖的突出显示情况。红色突出显示说明了我们有产品代码没有运行任何单元测试,这说明我们编写这些代码时未遵循 tdd 原则,即在编写实现前先提供测试。
初始化和清除测试
一般来说,测试类不仅包含独立的测试方法,还包含了不同的对测试进行初始化和清除的方法。实际上,创建测试向导在创建 vstsdemo.test 项目时,将一些这样的方法添加到类 longoninfotest 中,见清单 8。
清单8. 最终的logoninfotest类
using vsttdemo;
using microsoft.visualstudio.qualitytools.unittesting.framework;
using system;
namespace vstsdemo.test
{
/// <summary>
///this is a test class for vsttdemo.logoninfo and is intended
///to contain all vsttdemo.logoninfo unit tests
///</summary>
[testclass()]
public class logoninfotest
{
private testcontext testcontextinstance;
/// <summary>
///gets or sets the test context which provides
///information about and functionality for the
///current test run.
///</summary>
public testcontext testcontext
{
get
{
return testcontextinstance;
}
set
{
testcontextinstance = value;
}
}
/// <summary>
///initialize() is called once during test execution before
///test methods in this test class are executed.
///</summary>
[testinitialize()]
public void initialize()
{
// todo: add test initialization code
}
/// <summary>
///cleanup() is called once during test execution after
///test methods in this class have executed unless
///this test class initialize() method throws an exception.
///</summary>
[testcleanup()]
public void cleanup()
{
// todo: add test cleanup code
}
// ...
[testmethod]
// ...
public void changepasswordtest()
{
// ...
}
}
}
用于对测试进行设置和清除的方法分别由属性 testinitializeattribute和 testcleanupattribute修饰。在每个这样的方法中,我们可以加入额外的代码,它们将会在每个测试前或者测试后运行。这意味着在每次对应于 longoninfotest 表的记录的 changepasswordtest()执行前,initialize() 和 cleanup() 都会被执行,每次 nulluseridinconstructor和 emptyuseridinconstructor执行时也会发生同样的情况。这样的方法可以用于向数据库中插入默认的数据,然后在测试完成时清除插入的数据。例如,我们可以做到在 initialize()中开始一个事务,然后在清除时回滚同一个事务,这样一来,如果测试方法使用相同的连接时,数据状态会在每次测试执行完成时恢复原状。类似地,测试文件也可以这样处理。
在调试期间,testcleanupattribute修饰的方法可能由于调试器在清除的代码执行前终止运行。由于这个原因,最好在设置测试期间检查清除情况,并在需要时在设置测试前执行清除代码。关于初始化和清除的其它可用的测试属性有 assemblyinitializeattribute/assemblycleanupattribute和 classinitializeattribute/classcleanupattribute。程序集相关的属性对整个程序集运行一次,而类相关的属性对一个特定的测试类的加载运行一次。
最佳实践
在结束前我们回顾几种单元测试的最佳实践。首先,tdd 是非常有价值的实践。在所有现有的开发方法中,tdd 可能是多年来根本上改进开发且投资成本最小的一种。每个 qa 工程师都会告诉您,开发人员在没有相应的测试前不会写出成功的软件。有了 tdd,实践是在实现前编写测试,并且理想情况是,编写的测试可以成为无需人工参与执行的构建脚本的一部分。需要训练来开始养成习惯,但一旦建立习惯后,不使用 tdd 方法编码就像开车时不系安全带一样。
对于测试本身,有一些额外的原则可以帮助成功进行测试:
•
避免测试产生依赖性,这样测试需要按照特定的顺序执行。每个测试都应该是自治的。
•
使用测试初始化代码验证测试清除已经成功执行,如果没有则在执行测试前重新执行清除。
•
在编写任何产品代码的实现前编写测试。
•
对于产品代码中的每个类创建一个测试类。这样可以简化测试的组织,并可以容易地选择在何处放置每个测试。
•
使用 visual studio 生成初始化的测试项目。这样可以大大减少手工设置测试项目并与产品项目关联的步骤。
•
避免创建其他依赖计算机的测试,例如依赖特定的目录路径的测试。
•
创建模拟对象 (mock object) 来测试接口。模拟对象通常在需要验证 api 符合所需功能的测试项目中实现。
•
在继续创建新的测试前验证所有测试运行成功。这样可以保证在破坏代码后立刻进行修正。
•
可以最大化无需人工参与执行的测试代码。在依赖于手工测试前,必须完全肯定无法采用合理的无需人工参与执行的测试方案。
小结
总的来说,vsts 的单元测试功能本身很好理解。而且尽管本文没有提到,它还可以通过自定义执行引擎进行扩展。此外,它包含了代码覆盖分析的功能,这对于评价测试的全面性非常有用。通过使用 vsts,您可以将测试数目和 bug 数目或编写的代码数量进行关联比较。这为项目的运行状况提供了很好的指标。
本文介绍了team test 产品中的基本单元测试功能,也探讨了关于数据驱动测试的一些更加高级的功能。通过开始实践对代码进行单元测试,您会为产品的整个生命期建立一套宝贵的测试集。team test 通过与 visual studio 的强大集成和其它 vsts 产品线,使这一切变得容易。
mark michaelis 在 itron 公司担任软件架构师和讲师。他曾经对几个微软的产品设计进行检查,包括 c# 和vsts。现在他正在撰写另外一本有关 c# 的书,essential c# (addison wesley)。不使用计算机时,他会陪伴家人,进行户外运动,或者进行环球旅行。mark michaelis 住在 spokane, wa。您可以通过 mark@michaelis.net 和他联系或者访问他的网络日志:http://mark.michaelis.net。
转到原英文页面
翻译者luke是微软公司的软件工程师,习惯使用c++和c#开发应用程序。闲暇时间他喜欢音乐,旅游和怀旧游戏,并且愿意帮助msdn翻译更多的文章和其他开发者共享。可以通过ecaijw@msn.com联系他。