创建测试数据方法的演变

开发代码时经常需要测试数据的准备工作,有时候为了测试复杂的情况,需要准备的测试数据往往会有一些细微的不同,为了提高测试和测试代码的可维护性,如何让测试数据的准备变得可读、可理解十分重要。前段时间参与了一个项目,随着项目的进展,需要准备的测试用例变化越来越多,因此我们也必须演化测试数据的准备方式。
该项目要实现一个类Blog的功能,而该测试数据准备针对的是Blog列表功能的单元测试和集成测试。我们要测试的逻辑包括分页、排序、查询等,通常要准备几十条(x页)的blog数据。
我们经常可以在测试函数或者Setup函数中看到如下的代码:

[TestFixture]Public void BlogListTest()
{
     protected readonly IList<Blog]]> _blogs;
     protectedIList<Blog]]>_actualBlogs;
 
[Setup]
     Public void InitializeResources()
     {
          // create three pages of fake blog posts (41 blog posts)
          for (int i = 0; i < 41; i++)
          {
             _blogs.Add(new Blog
               {
               Author = "author",
               Content = "blog number" + i,
               Title = "test title",
               Id = "ID00000000" + (i + 1),
               SubmitUser = "user",
               Tags = tagList,
                ...
            });
          }
 
        _actualBlogs=_blogs
            .Where(r.SubmitUser == 'user'")
            .OrderByDescending(r.CreateTime)
            .Take(20).ToList();
     }
 
     [Test]
 
     public void OrderBy_CreateTime_Desc()
 
     {
 
         AssertBlogs_OrderByCreateTime_Descending(_actualBlogs);
 
     }
 
}

这段代码中,让人看得不爽的是测试函数中的初始话数据部分,里面花了很大功夫去创建一个三页的Blog列表,整个函数头重脚轻,这一堆代码对于理解我的测试毫无意义:

  • 它是一种噪音(Noise),为了理解整个测试,必须阅读很多不相关的信息,增加了很多没有必要的阅读量,也就是维护成本。
  • 经常见到这样的代码遍布很多的测试用例,假设需要Blog的字段发生变动,必须要更新所有的测试用例,这也是维护成本
  • 对于Setup中列出的很多字段,很难弄明白哪几个字段对我们的测试是真正有意义的,只能猜,这也是维护成本。
  • 第一步,首先要把这种测试数据的创建代码挪到同一个地方,所有的测试用例引用同一个地方去创建测试数据。一个理想的位置当然是Blog类的旁边。

    internal static class BlogsGenerator
    {
         internal static IList<blog> CountOfBlogs(this int totalCount, string tags)
        {
             var tagList = tags.ToTagList();
     
            var blogs = new List<blog>();
     
            for (int i = 0; i < totalCount; i++)
            {
                 blogs.Add(new blog
                {
                     Author = "author",
                    Content = "blog number" + i,
                    Title = "test title" + i,
                    Id = "ID00000000" + (i + 1),
                    SubmitUser = "user",
                    Tags = tagList,
                        。。。
                    });
                }
     
                return blogs;
            }
        }
    }

    接下来在所有的单元测试和集成测试中需要Blog列表的测试用例都可以使用这样的代码

    [Setup]
         Public void InitializeResources()
         {
             _blog = 41.CountOfBlogs("agile");
     
            _actualBlogs=_blogs
                .Where(r.SubmitUser == 'user'")
                .OrderByDescending(r.CreateTime)
                .Take(20).ToList();
         }

    与第一种方案相比,Setup函数清晰了很多。但是,还是有一个Magic Number => 41。不理解的人往往需要猜测这个41代表了什么。(其实原意是为了每页有20条,然后有3页,第3页只有1条)。那就不如用代码把背后隐含的意义写清楚

          _blogs = BlogListBuilder
                    .AnBlogList()
                    .OfPage(3)
                    .WithDifferentCreateUpdateTime()
                    .WithTag("agile")
                    .Build();

    实现方式参照[Freeman, Pryce, 2009]的Test Data Builder模式。测试数据创建开起来十分清晰,基本上消除了噪音,而且十分明确的指出了测试数据某些属性的不同之处。具体实现方法其实也很简单,首先在属性赋值函数中设定一些一些变量,然后在Build方法中根据这些变量生成相应的数据。具体如下

          public class BlogListBuilder
        {
            private int _countOfPage = 1;
            private const int PageSize = 20;
            private bool _differentCreateTime;
            private bool _persistable;
            private string _tagName = "tag";
     
            public static BlogListBuilder AnBlogList()
            {
                return new BlogListBuilder();
            }
     
            public IList<Blog> Build()
            {
                var countOfBlog = _countOfPage*PageSize - (PageSize - 1);
                var blogs = new List<Blog>();
     
                for (int i = 0; i < countOfBlog; i++)
                {
                    var blogBuilder = BlogBuilder.AnBlog().WithTag(_tagName);
     
                    if (_differentCreateTime )
                    {
                       blogBuilder = blogBuilder.WithCreateUpdateTime(i);
                    }
     
                    blogs.Add(blogBuilder.Build(i));
     
                }
     
                if (_persistable)
                {
                    // save to DB
                }
     
                return blog;
            }
     
            public BlogListBuilder OfPage(int countOfPage)
            {
                _countOfPage = countOfPage;
                return this;
            }
     
            public BlogListBuilder WithDifferentCreateUpdateTime()
            {
                _differentCreateTime = true;
                return this;
            }
     
            public BlogListBuilder PersistIntoRepository()
            {
                _persistable = true;
                return this;
            }
     
            public BlogListBuilder WithTag(string tagName)
            {
                _tagName = tagName;
                return this;
            }
        }
    Share