松散耦合您的测试






4.65/5 (14投票s)
松耦合你的测试和你的实现,以便在不更改测试的情况下重构你的实现
引言
我看到的最常见的测试集之一是为每个类预先命名一个测试夹具。当后来类被重构到其他类中时,就会出现困难,而你却剩下命名为不再存在的类的测试夹具。然而,更阴险的是,由于这种耦合,你增加了重构类的惯性。为了保持感知上的一致性,你不得不重命名测试夹具。更糟糕的是,许多现有的源代码实现拒绝与文件重命名愉快地配合,这使得重构工具的效果不那么好,而对变革的抵制又增加了。通过解耦测试和实现,测试可以独立存在,而实现则可以根据需要进行重构。
实践
考虑一个非常简单的博客引擎实现。故事如下...
一个博客由多个条目组成。每个条目必须有标题和内容。每个条目都有一个创建日期,表示条目的初始草拟日期,以及一个发布日期,表示条目的发布日期。一个条目可以被创建并添加到博客中而不被发布——这是一个草稿。一个条目可以被创建并立即发布。草稿可以在稍后发布。
第一种方法
相当典型的实现将包括以下测试夹具...(经过一些重构)。
TestBase.cs
/// <summary>
/// Contains common constants and objects for testing blog
/// and entry classes
/// </summary>
public class TestBase
{
protected Entry _entry;
protected const string _testTitle = "Test Title";
protected const string _testContent = "Test Content";
protected void SetUp()
{
_entry = new Entry();
}
}
EntryFixture.cs
/// <summary>
/// Tests to ensure entries are valid and have the correct defaults
/// </summary>
///
[TestFixture]
public class EntryFixture : TestBase
{
[SetUp]
public void SetUpEntryFixture()
{
SetUp();
}
[Test]
public void CanGetAndSetProperties()
{
_entry.Title = _testTitle;
_entry.Content = _testContent;
Assert.AreEqual(_testTitle, _entry.Title);
Assert.AreEqual(_testContent, _entry.Content);
}
[Test]
public void EntryCreatedGetsCreatedDate()
{
Assert.AreEqual(DateTime.Today, _entry.Created);
}
[Test]
public void ValidEntryHasTitleAndContent()
{
_entry.Title = _testTitle;
_entry.Content = _testContent;
Assert.IsTrue(_entry.IsValid);
}
[Test]
public void EntryWithoutTitleIsInvalid()
{
_entry.Content = _testContent;
Assert.IsFalse(_entry.IsValid);
}
[Test]
public void EntryWithoutContentIsInvalid()
{
_entry.Title = _testTitle;
Assert.IsFalse(_entry.IsValid);
}
}
BlogFixture.cs
/// <summary>
/// Provides tests around the behaviour of the blog.
/// </summary>
[TestFixture]
public class BlogFixture : TestBase
{
private Blog _blog;
[SetUp]
public void SetUpBlogFixture()
{
SetUp();
_blog = new Blog();
}
[Test]
public void PostingEntryProvidesPostedDate()
{
_entry.Title = _testTitle;
_entry.Content = _testContent;
_blog.Post(_entry);
Assert.AreEqual(DateTime.Today, _entry.Posted );
}
[Test]
public void PostingEntryIncreasesBlogEntryCount()
{
_entry.Title = _testTitle;
_entry.Content = _testContent;
_blog.Post(_entry);
Assert.AreEqual(1, _blog.Count);
}
[Test]
[ExpectedException(typeof(ArgumentException))]
public void AnInvalidBlogCannotBePosted()
{
Entry entry = new Entry();
_blog.Post(entry);
}
}
这导致了两个类——Entry
和 Blog
。这当然是有道理的,而且实现非常简单和整洁。简而言之,TDD 的这条路径导致了 Blog
和 Entry
的成功实现,我可以发布、获取日期,并对我的博客进行基本的验证。
然而,缺点是,拾起这些测试的读者/业务分析师/新开发人员在更改它们时会遇到更大的惯性。测试夹具本身的名称就迫使一种近乎潜意识的愿望来维护当前的 Blog
/ Entry
结构,从而降低了开发人员的灵活性和创造力。
第二种方法
同一个故事,但要去除任何形式的人为构造,只需一个接一个地添加测试,无情地重构。我从我能想到的最简单的、提供一些行为的东西开始...
/// <summary>
/// Tests. Note that there is currently no naming scheme - we leave that for
/// refactoring to find...
/// </summary>
[TestFixture]
public class Fixture
{
[Test]
public void PostSingleItemIncreasesCount()
{
Blog.Post("Test Title", "Test Content");
Assert.AreEqual(1, Blog.Count);
}
}
在添加第二个测试之后,该测试断言 PostedDate
附加到 Blog
,会出现以下信息——一、我们需要一个 Entry
类来填充博客的已发布内容,二、我们可以将两个测试的设置重构到一个设置方法中,并给测试夹具一个可读的名称。这涵盖了故事中“条目数量”和“包含已发布日期”的要求。
/// <summary>
/// The tests are starting to flesh out - we can now rename things -
/// for instance, the fixture has now become SuccessfulPosting, and we've
/// extracted the requirements for a successful posting into the setup.
/// </summary>
[TestFixture]
public class SuccessfulPosting
{
private int _index = 0;
private Blog _blog;
[SetUp]
public void SetUp()
{
_blog = new Blog();
_index = _blog.Post("Test Title", "Test Content");
}
[Test]
public void PostSingleItemIncreasesCount()
{
Assert.AreEqual(1, _blog.Count);
}
[Test]
public void PostSingleItemProvidesPostedDate()
{
Assert.AreEqual(DateTime.Today, ((Entry)_blog.Entries[_index]).Posted);
}
}
接下来,专注于无效条目会导致提取一个更简单的设置超类,其中包含实例化博客的方法,并提供干净的条目类来测试成功发布和无效发布数据。
/// <summary>
/// Manage overall setups for blog test
/// </summary>
public class TestBase
{
protected Entry _cleanEntry;
protected Blog _blog;
protected void Prepare()
{
_cleanEntry = GetCleanEntry();
_blog = GetTheBlog();
}
public static Blog GetTheBlog()
{
return new Blog();
}
public static Entry GetCleanEntry()
{
return new Entry();
}
}
/// <summary>
/// Test cases focusing on the normal flow of operations and the successful
/// outcome
/// </summary>
[TestFixture]
public class SuccessfulPosting : TestBase
{
private const string testTitle = "Test Title";
private const string testContent = "Test Content";
protected Entry _validEntry;
[SetUp]
public void SetUp()
{
Prepare();
_validEntry = GetValidEntry();
_blog.Post(_validEntry);
}
[Test]
public void IsContainedInBlog()
{
Assert.Contains(_validEntry, _blog.Entries);
}
[Test]
public void PopulatesDatePosted()
{
Assert.AreEqual(DateTime.Today.Date, ((Entry) _blog.Entries[0]).PostedDate);
}
public static Entry GetValidEntry()
{
Entry entry = GetCleanEntry();
entry.Title = testTitle;
entry.Content = testContent;
return entry;
}
}
/// <summary>
/// Tests to ensure that only valid data gets posted (Alternative flows)
/// </summary>
[TestFixture]
public class PostingValidation : TestBase
{
[SetUp]
public void SetUp()
{
Prepare();
}
[Test]
[ExpectedException(typeof(ArgumentException))]
public void FailsIfTitleNotPopulated()
{
_cleanEntry.Content = _testContent;
_blog.Post(_cleanEntry);
}
[Test]
[ExpectedException(typeof(ArgumentException))]
public void FailsIfContentNotPopulated()
{
_cleanEntry.Title = _testTitle;
_blog.Post(_cleanEntry);
}
}
请注意,这现在本质上更具可读性。发布验证规则已移至一个组并重命名,成功的发布规则也是如此。最后两件事要处理的是创建条目时的默认值,以及将条目作为草稿添加到博客而不发布。
/// <summary>
/// Ensure that entries are created with default values
/// </summary>
[TestFixture]
public class EntryDefault : TestBase
{
[SetUp]
public void SetUp()
{
Prepare();
}
[Test]
public void CreatedDateIsToday()
{
Assert.AreEqual(DateTime.Today.Date, _cleanEntry.CreatedDate.Date);
}
}
最后,将条目作为草稿添加...
/// <summary>
/// Ensure that draft entries can be persisted
/// </summary>
[TestFixture]
public class DraftAddition : TestBase
{
[SetUp]
public void SetUp()
{
Prepare();
_blog.Add(_cleanEntry);
}
[Test]
public void IncrementsBlogCount()
{
Assert.AreEqual(1, _blog.Entries.Count);
}
[Test]
public void IsContainedInBlog()
{
Assert.Contains(_cleanEntry, _blog.Entries);
}
}
现在这读起来几乎像一套业务规则...
DraftAddition.IncrementsBlogCount
DraftAddition.IsContainedInBlog
EntryDefault.CreatedDateIsToday
PostingValidation.FailsIfTitleNotPopulated
PostingValidation.FailsIfContentNotPopulated
SuccessfulPosting.IsContainedInBlog
SuccessfulPosting.PopulatesDatePosted
SuccessfulPosting.IncrementsBlogCount
最好的地方是,没有任何规则或测试会限制实现。规则是独立存在的。通过重构工具对实现进行的任何更改都会改变测试,这是件好事,但它们不会被测试人为地限制。
结论
总之,通过从测试夹具中移除预设的结构,你可以让代码在适应新的业务规则和约束时,以更自然的方式增长。无情地重构可以消除重复,并经常将创建有效对象的“TestHelpers
”提升为产品代码中的“Factory
”对象。测试的可读性得到了增强,甚至非开发人员也能读懂它们。最后,测试变得不那么脆弱,因为你不再专注于迫使代码适应测试结构,而是专注于代码如何解决测试的行为要求。
编辑
- 更新的示例调用,以删除对
Setup();
的不必要调用,因为文本夹具会运行它。 - 将标题和内容的测试中的
ArgumentNullException
更改为ArgumentException
,因为空字符串不是null
——而是无效的参数。也可以使用 out of range,但对我来说,这暗示着期望一个值的范围(例如 1 - 100)。——感谢 Joshua McKinney 提供这些。