集合的 SpecimenBuilder

SpecimenBuilder for a collection

我需要自定义创建一个集合,其中的对象之间的关系非常复杂,我不知道如何正确地做。

为了这个问题,让我们假设我正在开发一个待办事项应用程序。它有 Items 和 SubItems,并且项目有一个周数,指示何时应该完成:

public class Item {
    public string Name { get; set; }
    public int Week { get; set; }
    public ICollection<SubItem> SubItems { get; set; }
}

public class SubItem {
    public string Name { get; set; }
    public Item Parent { get; set; }
}

现在,因为这是实际应用程序中数据通常的样子,我想创建一个具有以下属性的项目集合:

为了做到这一点,我创建了一个 TodoItemSpecimenBuilder : ISpecimenBuilder 来启动它的 Create 方法,如下所示:

var type = (request as PropertyInfo)?.PropertyType ?? request as Type;
if (type == null || !typeof(IEnumerable<Item>).IsAssignableFrom(type))
{
    return new NoSpecimen();
}

// build up the actual collection
return BuildActualCollection();

然而,当我 运行 使用我的上下文中包含的这个样本构建器进行测试时,我什至在输入我的设置代码之前就对 return 语句进行了很多(可能 20 或 30)次点击,我第一次尝试 CreateMany<Item>() 时,它会抛出异常,因为它无法将 OmitSpecimen 转换为 Item

我做错了什么?


完整示例代码,可在安装 NUnit 和 AutoFixture 后编译:

public class TodoList
{
    public ICollection<Item> Tasks { get; set; }
}

public class Item
{
    public string Name { get; set; }
    public Week Week { get; set; }
    public ICollection<SubItem> SubItems { get; set; }
    public int ItemId { get; set; }
    public TodoList TodoList { get; set; }
}

public class SubItem
{
    public Item Item { get; set; }
    public string Name { get; set; }
    public int SortOrder { get; set; }
    public string HelpText { get; set; }
}

public class Week
{
    public int WeekId { get; set; }
}

public class ItemCollectionSpecimenBuilder : ISpecimenBuilder
{
    public object Create(object request, ISpecimenContext context)
    {
        if (!IsApplicable(request))
        {
            return new NoSpecimen();
        }

        var items = new List<Item>(3);
        var week1 = context.Create<Week>();
        var week2 = context.Create<Week>();

        items.Add(CreateItem(context, week1));
        items.Add(CreateItem(context, week1));
        items.Add(CreateItem(context, week2));

        items.GroupBy(t => t.Week).ToList().ForEach(ConfigureNames);
        ConfigureSubItems(context, items);

        return items;
    }

    private static bool IsApplicable(object request)
    {
        bool IsManyItemsType(Type type) => typeof(IEnumerable<Item>).IsAssignableFrom(type);
        bool IsItemsType(Type type) => type != null && typeof(Item) == type;

        switch (request)
        {
            case PropertyInfo pInfo:
                return IsManyItemsType(pInfo.PropertyType);
            case Type type:
                return IsManyItemsType(type);
            case MultipleRequest multipleRequest:
                if (!(multipleRequest.Request is SeededRequest seededRequest))
                {
                    return false;
                }
                return IsItemsType(seededRequest.Request as Type);
            default:
                return false;
        }
    }

    private static Item CreateItem(ISpecimenContext context, Week week)
    {
        var item = context.Create<Item>();
        item.Week = week;
        return item;
    }

    private static void ConfigureNames(IEnumerable<Item> items)
    {
        string name = null;
        foreach (var item in items)
        {
            if (name == null)
            {
                name = item.Name;
            }
            else
            {
                item.Name = name;
            }
        }
    }

    private static void ConfigureSubItems(ISpecimenContext context, IEnumerable<Item> items)
    {
        foreach (var group in items.GroupBy(item => item.Week.WeekId))
        {
            var subItemTemplates = context.CreateMany<SubItem>().ToList();
            foreach (var item in group)
            {
                item.SubItems.Clear();
                foreach (var subItem in context.CreateMany<SubItem>().Zip(subItemTemplates,
                    (model, subItem) =>
                    {
                        subItem.Item = item;
                        subItem.Name = model.Name;
                        subItem.SortOrder = model.SortOrder;
                        subItem.HelpText = model.HelpText;
                        return subItem;
                    }))
                {
                    item.SubItems.Add(subItem);
                }
            }
        }
    }
}

[TestFixture]
public class AutoFixtureSpecimenBuilderTests
{
    private static void TestCreationOfTasks(Func<IFixture, ICollection<Item>> creator)
    {
        var fixture = new Fixture();
        fixture.Customizations.Add(new ItemCollectionSpecimenBuilder());
        fixture.Behaviors.OfType<ThrowingRecursionBehavior>().ToList()
            .ForEach(b => fixture.Behaviors.Remove(b));
        fixture.Behaviors.Add(new OmitOnRecursionBehavior());

        var tasks = creator(fixture);

        Assert.AreEqual(3, tasks.Count);
        Assert.AreEqual(2, tasks.GroupBy(t => t.Week).Count());
        Assert.IsTrue(tasks.GroupBy(t => t.Week).Select(g => g.Select(t => t.Name).Distinct()).All(distinctNames => distinctNames.Count() == 1));
        var task = tasks.GroupBy(t => t.Week).OrderBy(g => g.Count()).First().OrderBy(t => t.ItemId).First();

    }

    [Test]
    public void CreateMany() => TestCreationOfTasks(fixture => fixture.CreateMany<Item>().ToList());

    [Test]
    public void CreateWithProperty() => TestCreationOfTasks(fixture => fixture.Create<TodoList>().Tasks);

    [Test]
    public void CreateAsList() => TestCreationOfTasks(fixture => fixture.Create<IList<Item>>());
}

我想不出有什么特别好的方法来解决这个问题。问题是 Item 是一个递归(树状)数据结构,而 却不容易扩展。

当您创建一个 ISpecimenBuilder 时,您告诉 AutoFixture 该对象将处理对特定对象的请求。这意味着您不能再使用 context 来请求这些对象,因为这将递归回同一个构建器,从而导致无限递归。

因此,一种选择是在构建器中构建对象 'by hand'。您仍然可以请求所有其他类型,但您必须避免请求导致递归的对象。

另一种选择是。这是一个概念证明:

public class ItemCollectionSpecimenCommand : ISpecimenCommand
{
    public void Execute(object specimen, ISpecimenContext context)
    {
        var @is = specimen as IEnumerable<Item>;
        if (@is == null)
            return;

        var items = @is.ToList();
        if (items.Count < 3)
            return;

        var week1 = context.Create<Week>();
        var week2 = context.Create<Week>();

        items[0].Week = week1;
        items[1].Week = week1;
        items[2].Week = week2;

        items.GroupBy(t => t.Week).ToList().ForEach(ConfigureNames);
    }

    private static void ConfigureNames(IEnumerable<Item> items)
    {
        string name = null;
        foreach (var item in items)
        {
            if (name == null)
                name = item.Name;
            else
                item.Name = name;
        }
    }
}

您可以像这样配置您的灯具:

var fixture = new Fixture();
fixture.Customizations.Add(
    SpecimenBuilderNodeFactory.CreateTypedNode(
        typeof(IEnumerable<Item>),
        new Postprocessor(
            new EnumerableRelay(),
            new CompositeSpecimenCommand(
                new AutoPropertiesCommand(),
                new ItemCollectionSpecimenCommand()))));

fixture.Behaviors.OfType<ThrowingRecursionBehavior>().ToList()
    .ForEach(b => fixture.Behaviors.Remove(b));
fixture.Behaviors.Add(new OmitOnRecursionBehavior());

这将通过重现测试 CreateWithPropertyCreateAsList,但不会通过 CreateMany

由于各种(历史)原因,CreateMany 的工作方式与 Create<IList<>> 之类的工作方式完全不同。如果你真的需要它也能为 CreateMany 工作,我会看看我能做些什么,但我不能保证这会成为可能。

看了几个小时后,这是我能想到的最好的。我已经一两年没有真正使用过 AutoFixture,所以我可能只是身材走样了,并且有更好的解决方案 可用......我只是可以'没想到...