A few weeks ago I blogged about a proposed feature: enhance SpecFlow Assist comparison helpers with support for different comparison modes. The enchancment was based on introduction of an enumerator that would specify how a table should be compared with an instance or set: TheTableAndSetMatchWithoutRegardForOrder, TheTableAndSetAreAnExactMatch etc. In a discussion in SpecFlow Google group there came another suggestion: use LINQ instead of enumerator. It would open for use of any set transformation operation supported by LINQ.
Now that the feature implementation has been accepted and will become a part of next SpecFlow release, I would like first to show how it works and then explain how it is implemented.
How it works
So let’s look at the following scenario (you will never want to mix so many validations in a single scenario but I just want to take all at once):
Scenario: Match
When I have a collection
| Artist | Album |
| Beatles | Rubber Soul |
| Pink Floyd | Animals |
| Muse | Absolution |
Then it should match
| Artist | Album |
| Beatles | Rubber Soul |
| Pink Floyd | Animals |
| Muse | Absolution |
And it should match
| Artist | Album |
| Beatles | Rubber Soul |
| Muse | Absolution |
| Pink Floyd | Animals |
And it should exactly match
| Artist | Album |
| Beatles | Rubber Soul |
| Pink Floyd | Animals |
| Muse | Absolution |
But it should not match
| Artist | Album |
| Beatles | Rubber Soul |
| Queen | Jazz |
| Muse | Absolution |
And it should not match
| Artist | Album |
| Beatles | Rubber Soul |
| Muse | Absolution |
And it should not exactly match
| Artist | Album |
| Beatles | Rubber Soul |
| Muse | Absolution |
| Pink Floyd | Animals |
CompareToSet Table extension method only checks for equivalence of collections which is a reasonable default. But with LINQ-based operations each of the above comparisons can be expressed using a single line of code:
[When(@"I have a collection")]
public void WhenIHaveACollection(Table table)
{
var collection = table.CreateSet<Item>();
ScenarioContext.Current.Add("Collection", collection);
}
[Then(@"it should match")]
public void ThenItShouldMatch(Table table)
{
var collection = ScenarioContext.Current["Collection"] as IEnumerable<Item>;
Assert.IsTrue(table.RowCount == collection.Count() && table.ToProjection<Item>().Except(collection.ToProjection()).Count() == 0);
}
[Then(@"it should exactly match")]
public void ThenItShouldExactlyMatch(Table table)
{
var collection = ScenarioContext.Current["Collection"] as IEnumerable<Item>;
Assert.IsTrue(table.ToProjection<Item>().SequenceEqual(collection.ToProjection()));
}
[Then(@"it should not match")]
public void ThenItShouldNotMatch(Table table)
{
var collection = ScenarioContext.Current["Collection"] as IEnumerable<Item>;
Assert.IsFalse(table.RowCount == collection.Count() && table.ToProjection<Item>().Except(collection.ToProjection()).Count() == 0);
}
[Then(@"it should not exactly match")]
public void ThenItShouldNotExactlyMatch(Table table)
{
var collection = ScenarioContext.Current["Collection"] as IEnumerable<Item>;
Assert.IsFalse(table.ToProjection<Item>().SequenceEqual(collection.ToProjection()));
}
In a similar way we can implement containment validation:
Scenario: Containment
When I have a collection
| Artist | Album |
| Beatles | Rubber Soul |
| Pink Floyd | Animals |
| Muse | Absolution |
Then it should contain all items
| Artist | Album |
| Beatles | Rubber Soul |
| Muse | Absolution |
But it should not contain all items
| Artist | Album |
| Beatles | Rubber Soul |
| Muse | Resistance |
And it should not contain any of items
| Artist | Album |
| Beatles | Abbey Road |
| Muse | Resistance |
And here are corresponding step definitions:
[Then(@"it should contain all items")]
public void ThenItShouldContainAllItems(Table table)
{
var collection = ScenarioContext.Current["Collection"] as IEnumerable<Item>;
Assert.IsTrue(table.ToProjection<Item>().Except(collection.ToProjection()).Count() == 0);
}
[Then(@"it should not contain all items")]
public void ThenItShouldNotContainAllItems(Table table)
{
var collection = ScenarioContext.Current["Collection"] as IEnumerable<Item>;
Assert.IsFalse(table.ToProjection<Item>().Except(collection.ToProjection()).Count() == 0);
}
[Then(@"it should not contain any of items")]
public void ThenItShouldNotContainAnyOfItems(Table table)
{
var collection = ScenarioContext.Current["Collection"] as IEnumerable<Item>;
Assert.IsTrue(table.ToProjection<Item>().Except(collection.ToProjection()).Count() == table.RowCount);
}
ToProjection, ToProjectionOfSet and ToProjectionOfInstance
It’s so easy to compare tables to collections using CompareToSet<T>, why do we need to call ToProjection both for table and collection? The answer becomes clear once you take a look at Table class definition. A Table class does not implement IEnumerable, it is a composite of a header and collection of rows. So it’s impossible to call Except or SequenceEqual directly on an instance of a Table. But it’s not only support for IEnumerable that requires introduction of an adapter class: a collection to be compared to does not need to be a set of items of a type known at compile time. It can be generated as a result of execution of a LINQ statement, so collection elements will have anonymous type.
What if Artist and Album are properties of different entities? Look at this piece of code:
var collection = from x in ctx.Artists
where x.Name == "Muse"
join y in ctx.Albums on
x.ArtistId equals y.ArtistId
select new
{
Artist = x.Name,
Album = y.Name
};
A “collection” object represents a projection of a join of two tables, so if we want to compare a Table instance to this collection, we should be able to compare Table to a set of anonymous classes. This makes it tricky to implement an adapter class: we define a generic class of “T”, but T in the example above is an anonymous type.
So here is how it works. SpecFlow.Assist has a new generic class EnumerableProjection<T>. If a type “T” is known at compile time, “ToProjection” method converts a table or a collection straight to an instance of EnumerableProjection:
table.ToProjection<Item>();
But if we need to compare a table with the collection of anonymous types from the example above, we need to express this type in some way so ToProjection will be able to build an instance of specialized EnumerableProjection. This is done by sending a collection as an argument to ToProjection. And to support both sets and instances and avoid naming ambiguity, corresponding methods are called ToProjectionOfSet and ToProjectionOfInstance:
table.ToProjectionOfSet(collection);
table.ToProjectionOfInstance(instance);
Here are the definitions of SpecFlow Table extensions methods that convert tables and collections of IEnumerables to EnumerableProjection:
public static IEnumerable<Projection<T>> ToProjection<T>(this IEnumerable<T> collection, Table table = null)
{
return new EnumerableProjection<T>(table, collection);
}
public static IEnumerable<Projection<T>> ToProjection<T>(this Table table)
{
return new EnumerableProjection<T>(table);
}
public static IEnumerable<Projection<T>> ToProjectionOfSet<T>(this Table table, IEnumerable<T> collection)
{
return new EnumerableProjection<T>(table);
}
public static IEnumerable<Projection<T>> ToProjectionOfInstance<T>(this Table table, T instance)
{
return new EnumerableProjection<T>(table);
}
Note that last arguments of ToProjectionOfSet and ToProjectionOfInstance methods are not used in method implementation! Their only purpose is to bring information about “T”, so the EnumerableProjection adapter class can be built properly. Now we can perform the following comparisons with anomymous types collections and instances:
[Test]
public void Table_with_subset_of_columns_with_matching_values_should_match_collection()
{
var table = CreateTableWithSubsetOfColumns();
table.AddRow(1.ToString(), "a");
table.AddRow(2.ToString(), "b");
var query = from x in testCollection
select new { x.GuidProperty, x.IntProperty, x.StringProperty };
Assert.AreEqual(0, table.ToProjectionOfSet(query).Except(query.ToProjection()).Count());
}
[Test]
public void Table_with_subset_of_columns_should_be_equal_to_matching_instance()
{
var table = CreateTableWithSubsetOfColumns();
table.AddRow(1.ToString(), "a");
var instance = new { IntProperty = testInstance.IntProperty, StringProperty = testInstance.StringProperty };
Assert.AreEqual(table.ToProjectionOfInstance(instance), instance);
}
Powerful helpers, simple step definitions
SpecFlow is becoming preferred BDD tool for many .NET developers, it has been easy to use from the beginning, and recent enhancements like Intellisense suport for Gherkin makes it really addictive. SpecFlow.Assist table management helpers add to the efficiency of the product by simplifying collection instantiation and comparison. I believe adding LINQ support to Table extension methods will help developers write more compact scenario step definitions.
I have installed 64-bit Oracle 11g on my development machine to work on some samples using Entity Framework 4.1 (a.k.a. “code first”). If you ask me why I installed 64-bit version and not 32-bit, I will not answer. I don’t know. Perhaps because Oracle has always associated for me with something big and 64 bits are certainly greater than 32. Now I think it was a mistake, but on the other hand it made me explore something new.
An attempt to connect to the database failed with the following error message: “ ----> System.Exception : Unable to load D:\Oracle\Vagif\product\11.2.0\dbhome_1\bin\oci.dll. Please check that you use 32x version of Oracle client with 32x application.” I checked project platform target. “Any CPU”. I was using TestDriven.NET test runner.
I’ve changed build platform to “64x” and tests passed. I also ran tests built for “Any CPU” using 64-bit NUnit runner, and they passed too. What I wanted now is leave “Any CPU” option for all projects and be able to test them from within Visual Studio.
Resharper was the only once that worked like a charm, no matter what I did. It uses 64-bit task runner on 64-bit platforms, so it just works.
Although TestDriven.NET test runner failed initially, it was easy to re-configure it: just choose 64-bit option for the test runner in its settings page.
The only bad guy was built-in Visual Studio test runner: it runs only as 32-bit process, so without unsupported patching it as 64-bit process (described here) it can’t run tests in 64-bit mode.
UPDATE. I stand corrected. MsTest also supports 64-bit mode.