单元测试和集成测试在业务应用程序中的应用






4.94/5 (49投票s)
本文展示了 N 层 Web 应用程序中一些实际的单元和集成测试示例,
引言
我还没有找到关于真实测试驱动开发 (TDD) 的好例子,以及如何为复杂的业务应用程序编写恰当的单元测试,从而让我们有足够的信心停止手动测试。通常,这些例子展示了如何测试堆栈或链表,这比测试典型的 N 层应用程序要简单得多,尤其是在您使用 Entity Framework、LINQ to SQL 或数据访问层中的某个 ORM,并在中间层进行日志记录、验证、缓存、错误处理时。有许多文章、博客文章和视频教程介绍如何编写单元测试,我相信它们都是很好的起点,但所有这些例子都展示了基础测试,不足以取代 QA 团队。在本文中,我将展示一些真实的单元测试和集成测试示例,它们应该有助于编写能够提供信心并帮助您逐步实现 TDD 的测试。
背景
我将向您展示在我开源项目Dropthings上进行的测试,这是一个使用 jQuery、ASP.NET 3.5、LINQ to SQL、Unity 依赖注入、Microsoft Enterprise Library 缓存、Velocity 等构建的 Web 2.0 AJAX 门户。基本上,它在一个项目中包含了所有热门技术。该项目是一个典型的 N 层应用程序,具有 Web 层、业务层和数据访问层。为该项目编写单元测试、集成测试和负载测试具有挑战性,因此分享它很有意义,以便您可以看到如何在实际项目中实现单元测试和集成测试,并演示如何逐步转向测试驱动开发。
首先,对于那些不熟悉单元测试、集成测试、测试驱动开发等概念的人,以下几点可以作为快速介绍。
- 单元测试是一种编写代码来测试一小段代码(例如一个类)的方式。您编写测试方法来测试类的方法(一个单元)。例如,您编写代码来确保
Stack
的Pop
方法返回最后压入的项。在单元测试中,您一次只测试一个类。如果您正在测试的类依赖于另一个类,那么该类将被存根化或模拟化(虚拟实现)。例如,如果您正在测试一个使用File
的类,在测试该类时,会使用DummyFile
来消除对物理文件系统的需求。单元测试应仅测试被测试类的逻辑,仅此而已。为了使类能够从真实实现切换到虚拟/存根实现,会使用接口代替真实类。因此,您的类应该使用IFile
或IDatabase
而不是直接使用File
和Database
。有许多文章、博客文章和视频教程介绍如何编写单元测试。在单元测试中要记住的一个原则是:当您对一个类进行单元测试时,您不应该依赖数据库、文件、注册表、Web 服务等。您应该能够“隔离”地测试任何类,并且您的类应该被设计成支持完全“隔离”。 - 集成测试在类与其他类集成时对其进行测试。例如,假设您正在测试
CustomerData
,它依赖于 LINQ to SQL 和数据库连接。在集成测试中,您通过调用CustomerData
的方法来测试它,并确保它在涉及所有相关类的情况下正常工作。集成测试在开发人员中更受欢迎,因为它实际上是在测试系统本应有的样子。 - 测试驱动开发 (TDD) 是单元测试的一种极端形式。基本原则是先编写单元测试,然后编写实际代码。例如,首先编写测试
CustomerData
类的单元测试,此时CustomerData
类中没有任何实际代码。CustomerData
类可能包含InsertCustomer
、DeleteCustomer
、GetCustomer
等函数,它们除了返回一些虚拟Customer
对象以满足单元测试之外,什么也不做。一旦单元测试在虚拟数据下都能通过,您就开始在CustomerData
的每个方法中编写实际代码,让它访问数据库并进行实际处理。编写实际代码后,您的单元测试应该在不更改测试代码的情况下通过。换句话说,测试驱动着开发工作。TDD 还要求类被设计成这样一种方式,即任何类都不直接依赖于任何其他类。所有依赖关系都通过接口。例如,CustomerData
不直接使用SqlConnection
,而是使用ISqlConnection
。所有依赖项都通过构造函数提供给CustomerData
。
测试驱动开发是一个复杂的主题。它需要时间来理解,因为它需要特定的设计和开发方法,以便代码可以进行单元测试。它还需要纪律来先编写测试,然后稍后编写实际实现。进行 TDD 的好处是所有代码都已提供自动化测试。此外,类必须基于其使用方式来设计,因为测试代码是在编写类实现代码之前编写的。
使用行为驱动开发进行测试
我个人偏好我的测试编写方式,因为我觉得传统的单元测试方法不足以满足我的需求。在纯单元测试中,一个测试方法用于测试某个类的唯一一个方法,并且有一个 Assert
方法一次只测试一个预期。例如,如果您正在测试 Stack
的 Pop
方法,单个单元测试方法应该只测试 Pop
方法的一个预期。Pop
应该返回压入堆栈的最后一个项,所以一个传统的单元测试方法可以这样写:
[Test] public void Pop_Should_Return_The_Last_Pushed_Item() { Stack stack = new Stack(); stack.Push(1); Assert.Equal(1, stack.Pop()); }
但是,测试单个方法的一个预期会很麻烦。您必须编写太多的测试方法来测试每个方法的整体行为。此外,在每个测试方法中,您都必须使用适当的上下文来设置被测类,然后调用您想要测试的方法,以仅验证一个特定的预期。我认为行为驱动开发 (BDD) 风格比传统的“每个测试方法一个函数和一个断言”风格更有用。我也使用 xUnit
,因为我认为它是最实用的单元测试框架,与 NUnit
和 mbUnit
相比。在本文中,我将使用 xUnit 和 Subspec(它是 xUnit 的扩展)进行单元测试。
在行为驱动开发中,组件会根据其预期的“行为”进行测试,行为定义如下:
给定一些初始上下文(给定的条件),
当事件发生时,
则确保某种结果。
例如,
给定一个空的 Stack
当一个项被压入堆栈并调用 Pop
时
则返回压入堆栈的最后一个项,该项已从堆栈中移除,并且任何后续对 Pop
的调用都会抛出异常。
在这里,您定义了 Pop
方法的完整行为。测试该行为的方法应该测试 Pop
所有预期的和相关的行为。
在接下来的几节中,我将展示如何根据行为对组件进行单元测试,然后展示如何对这种行为进行集成测试。最后,我将展示如何转向测试驱动开发,即在编写代码之前编写测试。
使用 BDD 进行单元测试
在这个第一个示例中,我们将对数据访问层进行单元测试。数据访问层使用 LINQ to SQL 进行对象持久化,并在实体级别进行缓存。例如,当您想加载一个用户时,它首先检查缓存,看用户是否已缓存,如果没有,则从数据库加载用户,然后进行缓存。
让我们看看 PageRepository
,它处理 Page
实体的持久化。它具有常见的创建、读取、更新和销毁 (CRUD) 方法。考虑一个示例方法 GetPageById
,它接受 PageId
并从数据库加载该 Page
。
public class PageRepository : IPageRepository { #region Fields private readonly IDropthingsDataContext _database; private readonly ICache _cacheResolver; #endregion Fields #region Constructors public PageRepository(IDropthingsDataContext database, ICache cacheResolver) { this._database = database; this._cacheResolver = cacheResolver; } #endregion Constructors #region Methods public Page GetPageById(int pageId) { string cacheKey = CacheSetup.CacheKeys.PageId(pageId); object cachedPage = _cacheResolver.Get(cacheKey); if (null == cachedPage) { var page = _database.GetSingle<Page, int>( DropthingsDataContext.SubsystemEnum.Page, pageId, LinqQueries.CompiledQuery_GetPageById); page.Detach(); _cacheResolver.Add(cacheKey, page); return page; } else { return cachedPage as Page; } }
PageRepository
接受 IDropthingsDataContext
,这是一个可以使用 LINQ to SQL DataContext
进行单元测试的接口。默认情况下,LINQ to SQL 不会生成可进行单元测试的 DataContext
。您需要尝试Kazi 的方法来使 DataContext
可进行单元测试。接下来,它接受 ICache
,这是一个处理缓存的接口。假设某个地方有一个该接口的实现,它提供使用 Enterprise Library Caching、Velocity 或 Memcached 等进行缓存支持。在此示例中,假设有一个名为 EnterpriseLibraryCache
的类实现了 ICache
。
让我们来测试它。我们将确保:
给定一个新的 PageRepository
和一个空的缓存,
当调用 GetPageById
并传入 PageId
时,
则它首先检查缓存。如果找不到任何内容,它将从数据库加载页面,并按预期返回页面。
以上陈述是一个行为。让我们使用 xUnit
和 Subspec
来编写它。
[Specification] public void GetPage_Should_Return_A_Page_from_database_when_cache_is_empty_and_then_caches_it() { var cache = new Mock<ICache>(); var database = new Mock<IDropthingsDataContext>(); IPageRepository pageRepository = new PageRepository(database.Object, cache.Object); const int pageId = 1; var page = default(Page); var samplePage = new Page() { ID = pageId, Title = "Test Page", ...}; database .Expect<Page>(d => d.GetSingle<Page, int>( DropthingsDataContext.SubsystemEnum.Page, 1, LinqQueries.CompiledQuery_GetPageById)) .Returns(samplePage); "Given PageRepository and empty cache".Context(() => { // cache is empty cache.Expect(c => c.Get(It.IsAny<string>())).Returns(default(object)); // It will cache the Page object afte loading from database cache.Expect(c => c.Add(It.Is<string>(cacheKey => cacheKey == CacheSetup.CacheKeys.PageId(pageId)), It.Is<Page>(cachePage => object.ReferenceEquals(cachePage, samplePage)))) .AtMostOnce().Verifiable(); }); "when GetPageById is called".Do(() => page = pageRepository.GetPageById(1)); "it checks in the cache first and finds nothing and then caches it".Assert(() => cache.VerifyAll()); "it loads the page from database".Assert(() => database.VerifyAll()); "it returns the page as expected".Assert(() => { Assert.Equal<int>(pageId, page.ID); }); }
首先,我设置 PageRepository
。它同时接受 IDropthingsDataContext
和 ICache
,所以我使用Moq对其进行模拟,然后设置一个 Page
对象样本,该样本将用于测试。我设置了一个数据库预期,即 PageRepository
将调用 GetSingle
方法来运行特定的 LINQ to SQL 查询,并将 PageId
设置为 1 以检索单个 Page
对象。此外,对 database.GetSingle(…)
的调用被存根化为返回 samplePage
。模拟的优点在于,它省去了编写虚拟类或存根以用于单元测试的麻烦,您不仅可以编写存根,还可以验证这些存根是否使用了正确的输入被调用,并且是否产生了正确的输出。
接下来,我设置上下文,“给定 PageRepository
和一个空的缓存”。PageRepository
已经存在,所以我只需要设置一个空的缓存。我通过设置预期,即每当调用 Get
时,它都将返回 null,从而模拟一个空的缓存。另一个预期是,将调用一次且仅一次 Add
方法来存储从数据库加载的 samplePage
对象。
接下来,我调用 PageRepository
的 GetPageById
方法。调用完成后,我测试对缓存对象的预期,即调用 Get
函数一次并且仅一次调用 Add
函数,这些预期是否已满足。这确保了 GetPageById
正在按预期进行缓存。
下一个 Assert
验证了数据库上的预期是否已满足,即通过将 PageId
设置为 1 来查询数据库以获取单个 Page
对象。然后,最后一个 Assert
检查返回的页面的 ID 是否与请求的 ID 相同。
这些测试确保了预期的行为得到满足。
单元测试的意义何在?
此时,许多人会想,我们为什么要编写三倍的代码来测试几行显而易见的简单代码?如果我们正在存根化对 database
和 cache
的调用,而 PageRepository
只是调用 database
和 cache
,那么在什么都不发生只是调用一些模拟的情况下,测试还有什么意义?如果所有实现细节在测试代码中都已知,那么编写测试还有什么意义?
我认为,当被测方法仅仅调用在测试中被模拟的其他方法时,编写单元测试是没有意义的。单元测试已经确切地知道将调用哪些其他类和方法,并且测试模拟了所有这些。编写代码来测试这些方法似乎没有意义。对我来说,编写这样的测试是毫无意义的。
但是,如果被测方法本身包含其他逻辑,那么单元测试就有意义了,因为单元测试将测试方法内的逻辑,并确保在应用逻辑时正确调用模拟。在上面的例子中,是根据方法内的逻辑来决定使用 cache
还是 database
,所以存在可以测试的逻辑。在这种情况下,单元测试是有意义的。
如果您仍然不相信,请考虑一个这样的场景,这些单元测试将能救您一命。假设您更改了数据访问层中缓存的方式。例如,我更改了代码以使用AspectF库。这需要在 PageRepository
的代码中进行更改。更改代码后,我需要确保 PageRepository
仍然按照预期行为做事。无论我使用何种缓存方法,它都不应该改变缓存行为:检查缓存以确保请求的对象尚未缓存,然后从数据库加载并缓存它。实现AspectF后的更改后的 GetPageById
方法如下所示:
public Page GetPageById(int pageId) { return AspectF.Define .Cache<Page>(_cacheResolver, CacheSetup.CacheKeys.PageId(pageId)) .Return<Page>(() => _database.GetSingle<Page, int>(DropthingsDataContext.SubsystemEnum.Page, pageId, LinqQueries.CompiledQuery_GetPageById).Detach()); }
在这里,AspectF
只是让我的缓存生活更轻松。现在,当我运行单元测试时,它运行正常。
这证实了 PageRepository
的行为没有改变,尽管其代码发生了巨大变化。它仍然按照预期执行,因此我可以重构和重新设计,而不必担心会破坏一些直到后来才发现的东西,从而导致错过交付。拥有*恰当*的单元测试可以让您放心,即使您在代码中进行了大刀阔斧的更改,只要您的单元测试都通过了,您的系统就没有问题。
接下来,我们来测试当缓存已满时,它能正确地从缓存返回对象,而不是不必要地查询数据库。以下测试将确保这一点:
[Specification] public void GetPage_Should_Return_A_Page_from_cache_when_it_is_already_cached() { var cache = new Mock<ICache>(); var database = new Mock<IDropthingsDataContext>(); IPageRepository pageRepository = new PageRepository(database.Object, cache.Object); const int pageId = 1; var page = default(Page); var samplePage = new Page() { ID = pageId, Title = "Test Page", ColumnCount = 3, LayoutType = 3, UserId = Guid.Empty, VersionNo = 1, PageType = Enumerations.PageTypeEnum.PersonalPage, CreatedDate = DateTime.Now }; "Given PageRepository and the requested page in cache".Context(() => { cache.Expect(c => c.Get(CacheSetup.CacheKeys.PageId(samplePage.ID))) .Returns(samplePage); }); "when GetPageById is called".Do(() => page = pageRepository.GetPageById(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); }); }
这个测试更简单。唯一的区别在于 Context
的设置,我们在其中设置了一个预期,即当请求特定页面时,它将从缓存返回 samplePage
对象。Mock
在任何未设置预期(即没有为之定义行为)的函数被调用时都会抛出异常,所以如果代码尝试调用 database
对象上的任何函数,或者 cache
对象上的其他任何函数,都会抛出异常,从而表明它没有执行不应该执行的操作。这确保了我们也进行了负面测试。
使用 BDD 进行集成测试
集成测试意味着您将测试一个类,同时它与其他类以及数据库、文件系统、邮件服务器等基础设施集成。每当您编写集成测试时,您都会测试组件的行为,就像它应该有的样子,而无需任何模拟。集成测试更容易编写,因为没有模拟。此外,它们提供了额外的信心,表明代码按预期工作,因为所有必需的组件和依赖项都已到位并且也正在被测试。
让我展示一下我如何测试我的业务外观层。业务外观层负责协调数据访问组件和其他所有实用组件。它将用户操作封装在一个业务操作中。例如,在 Dropthings
中,当一个全新的用户第一次访问时,用户将获得创建好的默认页面和部件。这些页面和部件来自模板。有一个名为 anon_user@dropthings.com 的用户,他拥有默认页面和部件。该特定用户的页面和部件会被复制到每个新用户。由于这是一个复杂的操作,它是一个进行自动化集成测试的良好候选。
当用户第一次访问 Default.aspx
时,将在 Facade
上调用 FirstVisitHomePage
。它会经历一个复杂的过程来克隆模板页面和部件,并设置默认用户设置等。集成测试将确保,如果使用标识新用户访问站点的参数调用 FirstVisitHomePage
,它将返回一个具有为该用户创建的默认页面和部件的对象。因此,行为是:
给定一个匿名用户,他以前从未访问过该网站,
当用户第一次访问时,
则在新建的页面上,在与匿名用户页面完全相同的列和位置上创建部件。
public class TestUserVisit { public TestUserVisit() { Facade.BootStrap(); } /// <summary> /// Ensure the first visit produces the pages and widgets defined in the template user /// </summary> [Specification] public void First_visit_should_create_same_pages_and_widgets_as_the_template_user() { MembershipHelper.UsingNewAnonUser((profile) => { using (var facade = new Facade(new AppContext(string.Empty, profile.UserName))) { UserSetup userVisitModel = null; // Load the anonymous user pages and widgets string anonUserName = facade.GetUserSettingTemplate() .AnonUserSettingTemplate.UserName; var anonPages = facade.GetPagesOfUser(facade.GetUserGuidFromUserName(anonUserName)); "Given anonymous user who has never visited the site before" .Context(() => { }); "when the user visits for the first time".Do(() => { userVisitModel = facade.FirstVisitHomePage(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(() => { anonPages.Each(anonPage => { var userPage = userVisitModel.UserPages.First(page => page.Title == anonPage.Title && page.OrderNo == anonPage.OrderNo && page.PageType == anonPage.PageType); facade.GetColumnsInPage(anonPage.ID).Each(anonColumn => { var userColumns = facade.GetColumnsInPage(userPage.ID); var userColumn = userColumns.First(column => column.ColumnNo == anonColumn.ColumnNo); var anonColumnWidgets = facade.GetWidgetInstancesInZoneWithWidget(anonColumn.WidgetZoneId); var userColumnWidgets = facade.GetWidgetInstancesInZoneWithWidget(userColumn.WidgetZoneId); // 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)); }); }); }); } }); }
我不会详细介绍代码是如何工作的,因为注释应该足以说明问题,但最后的 Assert
很复杂,需要进一步解释。
- 对于从模板用户找到的每个页面
- 确保新用户获得完全相同的页面
- 从模板用户的页面获取部件。
- 从新用户的页面获取部件
- 比较每个部件。对于每个部件
- 确保有一个且仅有一个具有相同标题、状态、位置等的部件。
每当我更改业务层时,我都可以运行集成测试以确保关键功能按预期工作,并且我没有在整个业务层的任何地方破坏任何东西。我使用 *xunit.console.exe* 在集成测试上运行测试,它会生成一个漂亮的 html
报告。
该报告使用以下命令行生成:
d:\xunit\xunit.console.exe d:\trunk\src\Dropthings.Business.Facade.Tests\bin\Debug\Dropthings.Business.Facade.Tests.dll /html FacadeTest.html
您也可以使用 xUnit GUI。
使用 BDD 进行测试驱动开发(用于单元测试)
到目前为止,我们都是在编写测试之前先有代码,但测试驱动开发又是怎么回事呢?您先编写测试,然后再编写代码。假设我们要添加一个行为:
给定一个 PageRepository
,
当调用 Insert
时,
则它应该将页面插入数据库,清除该用户(获得新页面的用户)的所有缓存页面集合,并返回新插入的页面。
让我们先编写测试。
[Specification] public void InsertPage_should_insert_a_page_in_database_and_cache_it() { var cache = new Mock<ICache>(); var database = new Mock<IDropthingsDataContext>(); IPageRepository pageRepository = new PageRepository(database.Object, cache.Object); const int pageId = 1; var page = default(Page); var samplePage = new Page() { ID = pageId, Title = "Test Page", ColumnCount = 3, LayoutType = 3, UserId = Guid.NewGuid(), VersionNo = 1, PageType = Enumerations.PageTypeEnum.PersonalPage, CreatedDate = DateTime.Now }; database .Expect<Page>(d => d.Insert<Page>(DropthingsDataContext.SubsystemEnum.Page, It.IsAny<Action<Page>>())) .Returns(samplePage); "Given PageRepository".Context(() => { // It will clear items from cache cache.Expect(c => c.Remove(CacheSetup.CacheKeys.PagesOfUser(samplePage.UserId))); }); "when Insert is called".Do(() => page = pageRepository.Insert((newPage) => { newPage.Title = samplePage.Title; newPage.ColumnCount = samplePage.ColumnCount; newPage.LayoutType = samplePage.LayoutType; newPage.UserId = samplePage.UserId; newPage.VersionNo = samplePage.VersionNo; newPage.PageType = samplePage.PageType; })); ("then it should insert the page in database" + "and clear any cached collection of pages for the user who gets the new page" + "and it returns the newly inserted page").Assert(() => { database.VerifyAll(); cache.VerifyAll(); Assert.Equal<int>(pageId, page.ID); }); }
首先,我们在 PageRepository.Insert
方法中编写一些虚拟代码,使其除了返回一个新的 Page
之外什么都不做。它应该失败,因为它目前不满足对数据库对象的预期。如果它不失败,那么我们的测试就是错误的。
public Page Insert(Action<Page> populate) { return new Page(); }
运行测试,结果如预期一样失败。
TestCase 'Given PageRepository when InsertPage is called, then it should insert the page in databaseand clear any cached collection of pages for the user who gets the new pageand it returns the newly inserted page' failed: Moq.MockVerificationException : The following expectations were not met: IDropthingsDataContext d => d.Insert(Page, null) at Moq.Mock`1.VerifyAll() PageRepositoryTest.cs(278,0): at Dropthings.DataAccess.UnitTest.PageRepositoryTest.<>c__DisplayClass35. <InsertPage_should_insert_a_page_in_database_and_cache_it>b__34()
这表明没有调用 database.Insert
,因此测试失败。我们实现了 TDD 的第一个支柱,即先编写测试并使其失败,因为被测组件没有正确设置预期。
现在让我们添加实际代码。
public Page Insert(Action<Page> populate) { var newPage = _database.Insert<Page>( DropthingsDataContext.SubsystemEnum.Page, populate); RemoveUserPagesCollection(newPage.UserId); return newPage.Detach(); }
现在,当单元测试在 PageRepository
上运行时,它会通过新测试,并且也通过了之前的测试。
太棒了!我们使用行为驱动开发实现了测试驱动开发。
使用 BDD 进行测试驱动开发(用于集成测试)
在上一节中,我们进行了单元测试。那么,如果我们想为集成测试进行 TDD 怎么办?我们如何先编写测试,然后再编写与组件集成的业务层代码?我们如何为 Web 层进行 TDD?
方法是相同的——首先编写测试代码,该代码提供正确的输入并期望业务操作产生正确的输出。但是,集成测试不应仅仅孤立地调用一个业务操作来确保它在不抛出异常的情况下正常工作。集成测试还应确保在执行其他操作时发生正确的行为。例如,在测试 FirstVisitHomePage
时,预期是第一次访问后,用户会创建正确的页面。测试代码通过检查返回的对象模型来验证这一点,但实际场景是,第一次访问后,如果用户再次访问,他们应该看到相同的部件。这将证明第一次访问操作是成功的,但我还没有进行重复访问来确认第一次和后续访问返回的数据相同。我还应该测试以下内容:
[Specification] public void Revisit_should_load_the_pages_and_widgets_exactly_the_same() { MembershipHelper.UsingNewAnonUser((profile) => { using (var facade = new Facade(new AppContext(string.Empty, profile.UserName))) { UserSetup userVisitModel = null; UserSetup userRevisitModel = null; "Given an anonymous user who visited first".Context(() => { userVisitModel = facade.FirstVisitHomePage(profile.UserName, ...); }); "when the same user visits again".Do(() => { userRevisitModel = facade.RepeatVisitHomePage(profile.UserName, ...); }); "it should load the exact same pages, column and widgets as the first visit produced".Assert(() => { userVisitModel.UserPages.Each(firstVisitPage => { Assert.True(userRevisitModel.UserPages.Exists(page => page.ID == firstVisitPage.ID)); var revisitPage = userRevisitModel.UserPages.First(page => page.ID == firstVisitPage.ID); var revisitPageColumns = facade.GetColumnsInPage(revisitPage.ID); facade.GetColumnsInPage(firstVisitPage.ID).Each(firstVisitColumn => { var revisitColumn = revisitPageColumns.First(column => column.ID == firstVisitColumn.ID); var firstVisitWidgets = facade .GetWidgetInstancesInZoneWithWidget(firstVisitColumn.WidgetZoneId); var revisitWidgets = facade .GetWidgetInstancesInZoneWithWidget(revisitColumn.WidgetZoneId); firstVisitWidgets.Each(firstVisitWidget => Assert.True(revisitWidgets.Where(revisitWidget => revisitWidget.Id == firstVisitWidget.Id).Count() == 1)); }); }); }); } }); }
进行集成测试的正确方法与编写单元测试相反。在单元测试中,方法是测试一个且仅一个类,并对其余所有内容进行存根化或模拟化。在集成测试中,您不仅应该测试一个操作,还应该执行其他相关操作,以确保被测操作确实按照预期执行。我已经将可能的测试用例概括为以下几类:
- 当测试一个创建新数据(例如,在数据库中插入行或调用 Web 服务创建实体)的操作时,通过以下方式确保操作已正确执行:
- 调用其他读取数据的操作,通过再次读取行或调用另一个 Web 服务来获取创建的实体。如果数据未正确插入(例如,插入子行),则会失败。这是一个正面测试。
- 调用其他操作,如果插入成功,它们将会失败,例如再次插入相同的行会产生约束冲突。这是一个负面测试。
- 当您测试一个更新数据(例如,更新数据库中的行)的操作时,通过以下方式确保操作已正确更新数据:
- 调用使用更新数据且在数据未正确更新时会失败的其他操作(例如,在第一次转账后余额不足的情况下进行两次连续的转账)。这是一个正面测试。
- 调用其他操作,如果更新成功,它们将会失败,例如,在更新后尝试使用相同的值插入新行应该会产生约束冲突。这是一个负面测试。
- 当您测试一个删除数据的操作时,通过以下方式确保操作已正确删除数据:
- 调用其他操作,如果数据存在,它们将会失败,例如,再次插入相同的行以产生约束冲突。
- 调用其他操作,如果数据未正确删除,它们将会失败,例如,为不存在的行插入子行。
在集成测试中进行正面和负面测试都很重要,即使您在单元测试中也这样做,以确保测试涵盖了系统的所有主要行为。集成测试的一个好处是,它测试了基础设施的不可预测性,而不是测试您自己的代码(假设您自己的代码已经进行了良好的单元测试)。重要的是要尽可能多地涵盖正面和负面场景,以排除基础设施变量。
结论
我希望本文能提供一些使用我认为有用的方法的真实单元测试和集成测试示例。您可以通过多种方式进行 TDD,并且不需要始终严格遵循某种方法。我发现使用 BDD 进行 TDD 是最有效的方法。这项技术减少了我必须编写的用于测试组件完整行为的单元测试方法的数量。使用 xUnit
和 Subspec
进行 BDD 使测试代码更具可读性,并且编写起来更自然。