65.9K
CodeProject 正在变化。 阅读更多。
Home

松散耦合您的测试

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.65/5 (14投票s)

2007年8月29日

CPOL

4分钟阅读

viewsIcon

37653

松耦合你的测试和你的实现,以便在不更改测试的情况下重构你的实现

引言

我看到的最常见的测试集之一是为每个类预先命名一个测试夹具。当后来类被重构到其他类中时,就会出现困难,而你却剩下命名为不再存在的类的测试夹具。然而,更阴险的是,由于这种耦合,你增加了重构类的惯性。为了保持感知上的一致性,你不得不重命名测试夹具。更糟糕的是,许多现有的源代码实现拒绝与文件重命名愉快地配合,这使得重构工具的效果不那么好,而对变革的抵制又增加了。通过解耦测试和实现,测试可以独立存在,而实现则可以根据需要进行重构。

实践

考虑一个非常简单的博客引擎实现。故事如下...
一个博客由多个条目组成。每个条目必须有标题和内容。每个条目都有一个创建日期,表示条目的初始草拟日期,以及一个发布日期,表示条目的发布日期。一个条目可以被创建并添加到博客中而不被发布——这是一个草稿。一个条目可以被创建并立即发布。草稿可以在稍后发布。

第一种方法

相当典型的实现将包括以下测试夹具...(经过一些重构)。

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);
    }
}

Screenshot - Take-1.jpg

这导致了两个类——EntryBlog。这当然是有道理的,而且实现非常简单和整洁。简而言之,TDD 的这条路径导致了 BlogEntry 的成功实现,我可以发布、获取日期,并对我的博客进行基本的验证。

然而,缺点是,拾起这些测试的读者/业务分析师/新开发人员在更改它们时会遇到更大的惯性。测试夹具本身的名称就迫使一种近乎潜意识的愿望来维护当前的 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);
    }
}

Screenshot - Take-2.jpg

请注意,这现在本质上更具可读性。发布验证规则已移至一个组并重命名,成功的发布规则也是如此。最后两件事要处理的是创建条目时的默认值,以及将条目作为草稿添加到博客而不发布。

/// <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);
    }
}

Screenshot - Take-3.jpg

现在这读起来几乎像一套业务规则...

  • DraftAddition.IncrementsBlogCount
  • DraftAddition.IsContainedInBlog
  • EntryDefault.CreatedDateIsToday
  • PostingValidation.FailsIfTitleNotPopulated
  • PostingValidation.FailsIfContentNotPopulated
  • SuccessfulPosting.IsContainedInBlog
  • SuccessfulPosting.PopulatesDatePosted
  • SuccessfulPosting.IncrementsBlogCount

最好的地方是,没有任何规则或测试会限制实现。规则是独立存在的。通过重构工具对实现进行的任何更改都会改变测试,这是件好事,但它们不会被测试人为地限制。

结论

总之,通过从测试夹具中移除预设的结构,你可以让代码在适应新的业务规则和约束时,以更自然的方式增长。无情地重构可以消除重复,并经常将创建有效对象的“TestHelpers”提升为产品代码中的“Factory”对象。测试的可读性得到了增强,甚至非开发人员也能读懂它们。最后,测试变得不那么脆弱,因为你不再专注于迫使代码适应测试结构,而是专注于代码如何解决测试的行为要求。

编辑

  1. 更新的示例调用,以删除对 Setup(); 的不必要调用,因为文本夹具会运行它。
  2. 将标题和内容的测试中的 ArgumentNullException 更改为 ArgumentException,因为空字符串不是 null——而是无效的参数。也可以使用 out of range,但对我来说,这暗示着期望一个值的范围(例如 1 - 100)。——感谢 Joshua McKinney 提供这些。
© . All rights reserved.