2

I have a need for customizing creation of a collection, with quite complicated relationships between the objects within it, and I can't figure out how to do it correctly.

For the sake of this issue, let's assume I'm working on a todo app. It has Items and SubItems, and the items have a week number indicating when they should be done:

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

Now, because this is what data usually looks like in the actual application, I want to create a collection of Items that has the following properties:

  • There are items that have the same name, but different weeks
  • There are items that have the same week but different name
  • There are sub-items that have the same name, but different parents

In order to do this, I've created a TodoItemSpecimenBuilder : ISpecimenBuilder which starts its Create method like this:

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

However, when I run tests with this specimen builder included in my context, I get lots (maybe 20 or 30) hits on the return statement before I enter even my setup code, and the first time I try to actually CreateMany<Item>(), it blows up with a cast exception because it can't cast OmitSpecimen to Item.

What am I doing wrong here?


Full sample code, compilable after installing NUnit and 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>>());
}
Tomas Aschan
  • 58,548
  • 56
  • 243
  • 402
  • Cross-posted from GitHub: https://github.com/AutoFixture/AutoFixture/issues/942 – Tomas Aschan Nov 27 '17 at 14:06
  • Related: https://stackoverflow.com/a/5398653/126014 – Mark Seemann Nov 27 '17 at 16:12
  • Thanks, @MarkSeemann! The full implementation of the specimen builder is actually doing something like that to create the collection; I just figured that since the business rules for collections of this particular type mandate the relations I'm trying to set up, a specimen builder would be a good place to centralize that logic (rather than having to repeat it for every test, or create some other factory method for it in an arbitrary location). Maybe you have some ideas on a better place to centralize that logic? – Tomas Aschan Nov 27 '17 at 16:15
  • Another note, before I even start looking at the particulars of this question. The requirements listed suggest to me that you're trying to address various, disparate edge cases with one big object. As a general observation, that may not be the most sustainable test strategy. If my assumption is correct, I'd suggest that you instead look into property-based testing with e.g. [FsCheck](https://fscheck.github.io/FsCheck). – Mark Seemann Nov 27 '17 at 16:15
  • We're actually using the generated objects to seed an in-memory database for end-to-end tests, so the main reason to uphold all the constraints at the same time is to ensure that we can make business assumptions about our data in the application code, and those assumptions will hold in under test runs as well. – Tomas Aschan Nov 27 '17 at 16:17
  • With the information given here, I can't repro the issue. Please provide an [MCVE](https://stackoverflow.com/help/mcve), including the full code of an `ISpecimenBuilder` causing the issue, and one or more tests that provokes it. – Mark Seemann Nov 27 '17 at 16:26
  • If I'm not mistaken, what are called 'constraints' here are actually the opposite: the lack of constraints. As far as I can tell, they basically just say: *all these relationships are legal according to the object model, because they can be expressed in the object model; they're also legal according to business rules.* It should be trivial to cover that with property-based tests... – Mark Seemann Nov 27 '17 at 16:31
  • @MarkSeemann Please see https://github.com/tlycken/MCVE_AutoFixtureCollectionSpecimenBuilder for an MCVE. I call them constraints, because the domain model (unfortunately) allows illegal states to be representable, so I can't just say that any representable state is valid from a business logic perspective. We're getting there, slowly, but there's quite a way to go... – Tomas Aschan Nov 27 '17 at 16:50
  • Please trim down the code further and post it here, instead of linking to an external site. See here for more information on why: https://meta.stackoverflow.com/q/294029/126014 – Mark Seemann Nov 28 '17 at 08:52
  • Thanks for wanting to look into this; I've pasted the code here, but I haven't found a way to trim it further without changing the outcome of the tests (not necessarily to success, but to something different than in my actual code base). – Tomas Aschan Nov 28 '17 at 09:40

1 Answers1

1

I can't think of any particularly good way to address this issue. The problem is that Item is a recursive (tree-like) data structure, and while AutoFixture does have some support for such, it's not easily extensible.

When you create an ISpecimenBuilder, you tell AutoFixture that this object is going to handle requests for particular objects. This means that you can no longer use the context to request those objects, because that'll recurse back into the same builder, causing an infinite recursion.

So, one option is to build up the objects 'by hand' from within the builder. You can still request all other types, but you'll have to avoid requesting objects that cause recursion.

Another option is to add a post-processor. Here's a proof of concept:

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

You can configure your fixture like this:

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

This'll pass the repro tests CreateWithProperty and CreateAsList, but not CreateMany.

For various (historical) reasons, the way that CreateMany works is quite different from the way that something like Create<IList<>> works. If you really need this to work for CreateMany as well, I'll see what I can do, but I can't promise that this'll be possible at all.

After having looked at this repro for a few hours, this is the best I can come up with. I haven't really used AutoFixture for a year or two now, so it's possible that I'm simply out of shape, and that a better solution is available... I just can't think of it...

Mark Seemann
  • 225,310
  • 48
  • 427
  • 736
  • Thanks for the great write-up! Working with a post processor here is probably a viable way forward - I'll have to play around with it for a while and see exactly how. But thank you - I really appreciate you taking the time to look at this. – Tomas Aschan Nov 28 '17 at 21:09