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

AoTestDataGen:.NET 的随机测试数据生成器库

2017 年 6 月 18 日

CPOL

14分钟阅读

viewsIcon

11085

downloadIcon

129

演练如何在合理场景中使用 AoTestDataGen 库

引言

在原型应用程序时生成测试数据通常很有用,这样您就可以看到它如何处理大量真实的(?)数据。这与模拟流量或严格意义上的负载测试不同,但生成的???比手动绞尽脑汁填充少量记录要好。

市面上有许多 GUI 应用程序可以为目标数据库平台生成数据——例如 Red Gate 的 SQL Data Generator。我尝试了其中几个,但我发现缺少一个针对多租户系统的功能,以确保生成的数据保持租户隔离。这在随机查找外键值时会起作用。假设您正在设计一个多租户系统,不同的租户有自己的“订单类型”列表。当您生成“订单”记录时,“订单类型”必须来自属于特定租户的订单类型。换句话说,您不能将一个租户的订单类型与另一个租户的订单混合。在我查看的几个工具中,我从未见过这样的功能,这是一个交易的决定因素。

其次,我不喜欢测试数据生成的 GUI 体验。换句话说,这似乎是一个将“代码优先”的理念带入该问题的好机会。这在避免复杂的 UI 承诺方面打开了一些大门,同样也使得针对不同的后端数据库成为可能。我实现的测试数据生成器(从现在开始称为 TDG)不依赖于任何特定数据库。它甚至不建立数据库连接!正如我们将看到的,它只是生成运行时对象,您可以决定如何持久化它们。

在下面的演练中,我使用了 Postulate ORM,因为我有点偏爱,但您实际上可以使用任何 ORM。如果您想在此处逐步使用 Postulate,请继续阅读下一节“设置”。如果您只想直接进入生成测试数据的核心,请跳到下面的“生成数据”。

设置

在本演练中,我们将创建一个控制台项目,演示如何为一个假设的销售订单系统使用我的 TDG 库。该系统很简单,但它演示了多租户概念,并突出显示了在使用我的 TDG 时可能会遇到的一些问题。

1.创建一个新的解决方案,其中包含一个名为“CodeProjectTDG”的控制台应用程序。添加一个名为“ModelClasses”的类库项目。将两个项目的 .NET 版本设置为 4.6.1,并在两个项目上安装 nuget 包 **AoTestDataGen** 和 **Postulate.Orm**。

2.在 GitHub 发布页面上安装 Postulate Schema Merge 应用程序。

3.在 ModelClasses 项目的生成后事件中,粘贴此命令

"C:\Users\Adam\AppData\Local\Adam O'Neil Software\Postulate Schema Merge\PostulateMergeUI.exe" "$(TargetPath)"

4.在两个项目中创建 **app.config** 文件,并添加此连接字符串

  <connectionStrings>
    <add name="default" connectionString="Data Source=(localdb)\MSSqlLocalDb;Database=TdgSample;Integrated Security=true"/>
  </connectionStrings>

5.从文章的 zip 文件中将模型类添加到 ModelClasses 项目。您的解决方案资源管理器应该如下所示

6.生成解决方案。您应该会看到 Schema Merge 应用程序弹出,并看到如下屏幕

执行这些更改(Schema Merge 窗口右上角的按钮),您就可以开始生成数据了。

生成数据

您可能会从表名推断出它们是如何关联的,但这里有一个小图可以帮助您理解。这是一个多租户设计,其中 **Organization** 作为租户的根。一个 Organization 拥有多个 **Customers**(进行购买的人)和 **Items**(组织销售的东西)。Customers 下单 **Orders**,订单包含一个或多个 **Order Items**。

我们需要在控制台应用程序 Program 类中添加一点代码。这里我在标准的控制台应用程序样板代码中添加了两个静态变量 **_tdg** 和 **_db**。为了简洁起见,我没有显示 using 语句。我还创建了从控制台应用程序到 ModelClasses 项目的引用。

class Program
{
    private static TestDataGenerator _tdg = new TestDataGenerator();
    private static TdgDb _db = new TdgDb();

    static void Main(string[] args)
    {
    }
}

现在我们可以真正做一些事情了。我们必须从 **Organization** 表开始,因为它没有依赖项(外键)。这是我们第一次使用 TDG 库

private static void CreateOrgs()
{
    _tdg.Generate<Organization>(10, org =>
    {
        org.Name = _tdg.Random(Source.CompanyName) + _tdg.RandomInRange(1, 100).ToString();
        org.TaxRate = _tdg.RandomInRange(0, 10, i => Convert.ToDecimal(i) * 0.01m);
    }, records =>
    {
        _db.SaveMultiple(records);
    });
}

这实际上意味着创建 10 个 Organization 实例,并为每个实例赋予一个随机名称,后跟一个 1 到 100 之间的随机数。另外,将税率设置为 0% 到 10% 之间的随机值。最后,使用 Postulate 的 SaveMultiple 方法保存对象。如果您使用的是 Entity Framework 或其他 ORM,您将以适合您的方式执行保存。(请参阅 GitHub 仓库中的 Generate 方法。)

为什么我在公司名称后附加了一个数字? 为了确保它在表中是唯一的。Organization.Name 是主键。TDG 库中我的随机公司名称列表相当短,因此附加一个数字是确保唯一性的最简单方法。

_tdg.Random 方法是什么? 这是您在填充记录时需要生成各种随机数据时可以调用的方法。它有几个重载,我们将在后面介绍,但在上面的用法中,它从 TDG 的虚构公司名称列表中获取一个随机的“公司名称”。可用的 Source 参数列表 在这里

_tdg.RandomInRange 方法是什么? 这是生成范围内的随机整数的方法,并且可以选择性地根据需要将其投影到不同的格式或类型。在上面的情况中,当我需要生成随机税率(decimal 类型)时,我将生成的随机整数乘以 .01,使其更像税率。这取决于 Enumerable.Range 静态方法。由于没有通用的方法来生成不同类型的范围,我需要接受一个 lambda 来执行某种投影或转换为另一种类型。稍后我们将看到这如何应用于日期。

为什么保存 (_db.SaveMultiple) 与记录生成部分分开? 生成的对象是分批保存的。默认情况下,每生成 100 个对象就会触发一次保存。您可以更改 _tdg.BatchSize 来更改生成器循环内保存的频率,实际上在下面的案例中我们需要这样做。

让我们执行此操作。将 CreateOrgs() 方法调用添加到 Main 方法中,如下所示,然后运行它

static void Main(string[] args)
{
    CreateOrgs();
}

现在让我们在 SQL Server 表中查看一下输出。

您的结果应该有所不同,但应该有十行。(请注意,由于我将 Organization.Name 标记为主键,并且 Postulate 默认集群 PK,因此在没有 ORDER BY 子句的情况下,它们会按名称排序。)是的,我内置的公司名称相当奇怪。我欢迎在 GitHub 仓库中贡献更多内容,但奇怪有时是规则。

让我们继续处理 **Item** 表。这是我编写的用于填充 Item 表的方法。

private static void CreateItems()
{
    int[] orgIds = null;

    using (var cn = _db.GetConnection())
    {
        cn.Open();
        orgIds = cn.Query<int>("SELECT [Id] FROM [Organization]").ToArray();
    }

    _tdg.Generate<Item>(120, item =>
    {
        item.OrganizationId = _tdg.Random(orgIds);
        item.Name = _tdg.Random(Source.WidgetName);
        item.UnitCost = Convert.ToDecimal(_tdg.RandomInRange(10, 150)) + _tdg.RandomInRange(10, 25, i => i * 0.1m);
        item.UnitPrice = item.UnitCost + Convert.ToDecimal(_tdg.RandomInRange(3, 50));
        item.IsTaxable = _tdg.RandomWeighted(new TaxableWeighted[]
        {
            new TaxableWeighted() { IsTaxable = true, Factor = 1 },
            new TaxableWeighted() { IsTaxable = false, Factor = 5 }
        }, x => x.IsTaxable);
    }, records =>
    {
        _db.SaveMultiple(records);
    });
}

我们来分解一下。首先,我使用 Dapper 的 Query<T> 方法获取一个包含所有 Organization.Id 值的数组。(Dapper 是 Postulate 的依赖项,因此在安装 Postulate.Orm 包时会安装它。)

接下来,我进行 Item 生成。我生成了 120 行,其中每一行……

  • 通过 **_tdg.Random** 方法获得一个随机的 Organization.Id。这个重载只是从数组中选择一个随机索引。
  • 再次使用 _tdg.Random 获取一个随机的“小部件名称”,使用 TDG 库内置的小部件名称源。
  • 单位成本在 10 美元到 150 美元之间,外加 1 美元到 2.50 美元之间的另一个随机金额,只是为了让数值看起来更“参差不齐”地逼真。
  • 单位价格在成本基础上加上 3 美元到 50 美元之间的随机加价。
  • 标记为非应税的概率是其他情况的 5 倍。要实现*加权*随机性,即某些选项被选择的次数比其他选项多,您可以从 IWeighted 派生一个类,并根据选项的“权重”设置 **Factor** 属性。最后,您提供一个 lambda 表达式(在上面的情况下是 **x => x.IsTaxable**),指示您实际返回到正在设置的属性的是什么。

最后,我使用与 CreateOrgs 相同的 Postulate SaveMultiple 方法以默认的 100 个为批次将记录再次保存到数据库中。我上面选择 120 这个数字只是为了确保在选择一个不等于默认 TDG 批大小的随机数时它也能正常工作。

运行方式与 CreateOrgs 非常相似。请注意,我只是注释掉了 CreateOrgs(),然后添加了 CreateItems()

static void Main(string[] args)
{
    //CreateOrgs();
    CreateItems();
}

我在这里不会截取 SQL Server 输出的截图,但此时您应该有 120 条 Item 记录,其中包含各种各样多彩的 Item.Names。让我们继续处理 Customer 表

private static void CreateCustomers()
{
    int[] orgIds = null;

    using (var cn = _db.GetConnection())
    {
        cn.Open();
        orgIds = cn.Query<int>("SELECT [Id] FROM [Organization]").ToArray();
    }

    _tdg.Generate<Customer>(5000, c =>
    {
        c.OrganizationId = _tdg.Random(orgIds);
        c.FirstName = _tdg.Random(Source.FirstName);
        c.LastName = _tdg.Random(Source.LastName);
        c.Address = _tdg.Random(Source.Address);
        c.City = _tdg.Random(Source.City);
        c.State = _tdg.Random(Source.USState);
        c.ZipCode = _tdg.Random(Source.USZipCode);
        c.Email = $"{c.FirstName.ToLower()}.{c.LastName.ToLower()}@{_tdg.Random(Source.DomainName)}";
    }, records =>
    {
        _db.SaveMultiple(records);
    });
}

您可能已经开始看到一些模式了!首先,我获取所有 Organization.Id 的列表。然后我使用 Generate 方法,这次是 5,000 条记录。我认为记录创建在这里相当直接。唯一值得关注的是电子邮件是如何生成的。我原本想要一个内置的随机电子邮件源,但它效果不佳,因为电子邮件地址通常以某种方式是个人姓名的函数。在 **_tdg.Random** 方法上,我没有简便的方法来接受参数。因此,我更“手动”地将电子邮件地址构建为记录的 FirstName 和 LastName 属性的串联。不过,我确实有一个内置的域名列表。

像以前一样运行此命令,注释掉之前的 Create 方法,以便我们能留下一些记录。

static void Main(string[] args)
{
    //CreateOrgs();
    //CreateItems();
    CreateCustomers();
}

现在让我们填充 Order 表。这将引出我在介绍中提到的租户隔离问题。当我们创建随机 Order 记录时,我们需要确保这些订单的 CustomerIds 属于与 Order 本身相同的 Organization。

private static void CreateOrders()
{
    int[] orgIds = null;
    dynamic[] customerIds = null;

    using (var cn = _db.GetConnection())
    {
        cn.Open();
        orgIds = cn.Query<int>("SELECT [Id] FROM [Organization]").ToArray();
        customerIds = cn.Query("SELECT [OrganizationId], [Id] FROM [Customer]").ToArray();
    }

    _tdg.Generate<Order>(7000, ord =>
    {
        ord.OrganizationId = _tdg.Random(orgIds);
        ord.CustomerId = _tdg.Random(customerIds, item => item.Id, item => item.OrganizationId == ord.OrganizationId);
        ord.Number = _tdg.RandomFormatted("AA000-A0000");
        ord.Date = _tdg.RandomInRange(0, 2000, i => new DateTime(2013, 1, 1).AddDays(i));
    }, records =>
    {
        _db.SaveMultiple(records);
    });
}

首先,我将所有 Organization.Ids 的列表获取到一个数组中。然后,我也获取了所有 Customer.Ids,但我需要为每个客户包含 OrganizationId。我将它们作为动态处理,因为我太懒了,不想创建一个类型,而且由于它仅限于此方法,所以我真的不需要类型。同样,当我在构建随机订单时,我需要某种方法来限制在创建记录时将客户的选择限制在范围内属于该组织的客户。

然后我调用 Generate 方法处理 7,000 条记录。(我想让一些客户拥有一个以上的订单——因此订单计数大于客户计数。)分解说明

  • 从 **orgIds** 中选择一个随机索引
  • 从 **customerIds** 中选择一个随机索引,特别是 **customerIds** 的 **Id** 属性,但要过滤可能的选择,以便只使用那些 OrganizationId 与为该记录刚刚生成的 OrganizationId 匹配的客户。在此处实现 实现
  • 生成一个随机的字母数字字符串,看起来像您可能在某些地方看到的订单号。我创建了一个“简易正则表达式”格式来生成不同类型的随机字符串。(请参阅 GitHub 上的 实现。)
  • 生成一个从 2013 年 1 月 1 日开始,最多可达 2000 天后的随机日期。是的,这里会有未来的日期,但这对我来说无关紧要。

最后,我使用 SaveMultiple,然后像这样运行它

static void Main(string[] args)
{
    //CreateOrgs();
    //CreateItems();
    //CreateCustomers();
    CreateOrders();
}

我们首先要检查的一件事是订单是否按组织正确隔离。换句话说,我们想确保没有客户在另一个组织的订单中。我写了这个查询来检查。我正在寻找那些订单组织与客户组织不匹配的记录。结果没有,所以这很好!

我们剩下最后一个表 **OrderItem**。这个表需要一些略有不同的处理。我将只向您展示代码,然后解释

private static void CreateOrderItems()
{
    int[] orgIds = null;
    dynamic[] items = null;
    dynamic[] orders = null;

    using (var cn = _db.GetConnection())
    {
        cn.Open();
        orgIds = cn.Query<int>("SELECT [Id] FROM [Organization]").ToArray();
        items = cn.Query("SELECT [OrganizationId], [Id] FROM [Item]").ToArray();
        orders = cn.Query("SELECT [OrganizationId], [Id] FROM [Order]").ToArray();

        _tdg.BatchSize = 1;
        foreach (var order in orders)
        {
            _tdg.Generate<OrderItem>(1, 7, oi =>
            {
                do
                {
                    oi.OrderId = order.Id;
                    oi.ItemId = _tdg.Random(items, item => item.Id, item => item.OrganizationId == order.OrganizationId);
                    oi.Quantity = _tdg.RandomInRange(1, 25).Value;
                    oi.UnitPrice = _db.Find<Item>(cn, oi.ItemId).UnitPrice;
                    oi.ExtPrice = oi.Quantity * oi.UnitPrice;
                } while (cn.Exists("[OrderItem] WHERE [OrderId]=@orderId AND [ItemId]=@itemId", new { orderId = oi.OrderId, itemId = oi.ItemId }));
            }, records =>
            {
                _db.SaveMultiple(cn, records);
            });
        }
    }
}

和以前一样,我获取了一些种子数据——所有 Organization.Ids,然后是 **items** 和 **orders**。请注意,这两者都是 dynamic[],因为我太懒了,不想创建一个类型,而且实际上也不需要。但是,和以前一样,我确实需要知道这些项和订单属于哪个 OrganizationIds,所以 dynamic 是存储每个项和订单的 Id 和 OrganizationId 属性的不错选择。

对于 Generate 部分,请注意我将 BatchSize 设置为 1。这会导致每个 OrderItem 被单独保存,而不是分批保存。稍后您就会明白为什么。

与其生成绝对数量的记录,不如我循环遍历订单,并为每个订单创建随机数量的项(1 到 7 个)。我想要一个现实中不均衡的每订单项数。如果我只生成绝对数量的 OrderItems,那么它们会在订单集中趋于平均。

请注意,OrderItem 的创建是在 do....while 循环中进行的。我这样做是因为 OrderItem 上的主键。您不能在同一个订单中重复相同的项,但 TDG 没有任何关于重复的规则。我发现的最佳方法是将其放在循环中,检查该键组合是否存在。do 循环一直持续到键组合被使用。我使用 Postulate.Exists 方法(实际上是 Dapper 方法的包装器),来判断 OrderId + ItemId 组合是否已被使用。事实上,我将所有内容包装在 **using connection** 块中是为了能够使用已打开的连接来利用 Exists 方法。

为了让 Exists 检查正常工作,我需要每个 OrderItem 记录都逐一保存。否则,您可能会在 Exists 在物理表级别捕获它们之前,将一些重复项缓存到内存中。我曾考虑使用字典键并添加某种重复检查到 TDG 中,但没有简单的选项跳出来。

请注意,当我设置 OrderItem.ItemId 时,我会根据创建记录期间范围内有效的 organizationId 来过滤 itemId 的选择。这使得 itemIds 按组织正确隔离。

oi.ItemId = _tdg.Random(items, item => item.Id, item => item.OrganizationId == order.OrganizationId);

我最后像处理 Order 表一样验证了这一点。我需要确保不存在来自不同组织的项和订单

更新:GenerateUnique 方法

在生成 OrderItem 记录时,我不得不付出一些额外的努力来确保记录在保存之前是唯一的。我意识到这将是一个相当常见的情况,所以我添加了一个新的 GenerateUnique 方法,它封装了我上面使用的逻辑——即添加一个 **exists** Func 参数。这允许您在尝试保存记录之前检查它是否已经存在。如果存在,TDG 会继续生成记录,直到生成一个不存在的记录。如果在 100 次尝试后仍无法生成唯一记录,则会抛出异常。这是为了防止无限循环,我在测试时遇到了这种情况。(在我的例子中,我不得不回去生成更多的 **Item** 记录,以便有更大的池可供选择。)

TModel record = new TModel();
int attempts = 0;
const int maxAttempts = 100;
do
{
    attempts++;
    if (attempts > maxAttempts) throw new Exception($"Couldn't generate a random {typeof(TModel).Name} record after {maxAttempts} tries. Exception was thrown to prevent infinite loop.");
    create.Invoke(record);                    
} while (exists.Invoke(connection, record));
save.Invoke(record);

请注意,**exists** 参数需要一个连接。我将其声明为 IDbConnection,以尽可能保持平台中立。我曾考虑过以完全不依赖数据库的方式来处理,通过以某种方式将键值保存在内存中。我认为这会过于复杂。由于这些生成的记录最终是要进入数据库的,我认为假设 IDbConnection 可用并没有什么不对。以下是 **CreateOrderItems** 方法中重写的代码外观

_tdg.GenerateUnique<OrderItem>(cn, 1, 7, oi =>
{
    oi.OrderId = order.Id;
    oi.ItemId = _tdg.Random(items, item => item.Id, item => item.OrganizationId == order.OrganizationId);
    oi.Quantity = _tdg.RandomInRange(1, 25).Value;
    oi.UnitPrice = _db.Find<Item>(cn, oi.ItemId).UnitPrice;
    oi.ExtPrice = oi.Quantity * oi.UnitPrice;
}, (connection, record) =>
{
    return connection.Exists(
        "[OrderItem] WHERE [OrderId]=@orderId AND [ItemId]=@itemId", 
        new { orderId = record.OrderId, itemId = record.ItemId });
}, (record) =>
{
    _db.Save(record);
});

它实际上与以前的逻辑相同,但存在性检查现在已包含在方法中——因此您不必记住将 BatchSize 设置为 1 或自己编写 while 循环。此外,如果您没有足够的种子数据来保证唯一性,您还可以得到无限循环的保护。

结论

我真的很享受这项工作,因为测试数据生成问题一直困扰了我很长时间。特别是,将它视为一个代码优先的问题而不是一个 GUI 应用程序问题,这让我感到很解脱。在 GUI 应用程序中支持类似 lambda 的东西以及不同类型的循环和条件,会非常复杂,而现成的测试数据生成工具要么必须做到这一点,要么永远做不到。我希望您也能发现它有用。查看 GitHub 仓库,如果您有建议,请贡献或提出问题。

 

© . All rights reserved.