Skip to main content
Version: v2.5

Unit test of Manual Rules and Bags

We highly recommend that you create unit tests for your manual Rules and Bags.

Unit testing is a widely used industry practice that both helps you achieve improved quality but also is an efficient way to initially develop and test your implementation without having to run an actual migration of a Business Object in order to test your code.

You can use any test framework and any mocking library to create unit tests. In the samples below, we have used

XUnit
for unit test
FakeItEasy
for mocking
FluentAssertions
for easy-to-read test assertion

In order to be able to unit test Bags and Rules, you need to mock the appropriate context interface. There are 3 different interfaces to consider:

EngineInterface
Source Engine
  • IItemExporterContext
Target Engine
  • IItemImportContext
  • IItemImportContextMapping (contains a couple of methods only available to Mapping Rules)

These interfaces contain the properties and methods available to Bags and Rules. In order to unit test a given Bag/Rule, you only need to mock the members on the interface that is actually used.

As an example, let's look at the Target Engine Mapping Rule GetFrequencyId from the Hopp Academy Hopp Software Training course.

GetFrequencyId is a manual rule, and the implementation looks like this:

using System;
using System.Linq;

namespace MigFx.Engine.Project.Target
{
partial class MappingRules
{
public override Decimal? GetFrequencyId(FlagHandler flag, string frequencyShortName)
{
// FrequencyShortName is missing
if (string.IsNullOrWhiteSpace(frequencyShortName))
{
flag(2);
return null;
}

var row = Valueset.Frequencies.Where(r => r.FrequencyShortName == frequencyShortName)
.Select(r => new { r.FrequencyId })
.FirstOrDefault();

if (row != null)
{
return row.FrequencyId;
}
else
{
flag(1);
return null;
}
}
}
}

The rule takes a FlagHandler delegate to use to raise flags and a frequencyShortName argument

  • If the frequencyShortName is null or empty, the rule uses the flagHandler delegate to raise flag 2 and then returns null
  • If not, the rule looks up the frequencyShortName in the Valueset Frequencies
  • If a row is not found, the rule raises flag 1 and returns null
  • If a row is found, the rule raises no flags and returns the FrequencyId from the row.

In the following, we will look at 3 unit tests for the GetFrequencyId mapping rule:

  • ShouldRaiseFlag2IfFrequencyShortNameNotProvided
  • ShouldRaiseFlag1IfFrequencyNotFound
  • ShouldReturnFoundValueIfSuccessful

A couple of things to note about all manual rules and bags

  • In the Target Engine, Manual Rules and Bags exist in the namespace MigFx.Engine.Project.Target
  • In the Source Engine, Manual Rules and Bags exist in the namespace MigFx.Source.Project
  • Manual Rules are in fact virtual methods in an abstract, generated base class that are overridden in a generated, derived class marked as partial
  • Each rule is implemented in a separate file that is a partial implementation of this derived class
    • For example, the GetFrequencyId rule above is a method inside the partial class MappingRules

In order to test the GetFrequencyId rule, you need to instantiate an instance of the MappingRules class and call the GetFrequencyId method.

Since this is a Target Engine Mapping Rule, the constructor for the MappingRules class takes a single parameter of type IItemImportContextMapping.

ShouldRaiseFlag2IfFrequencyShortNameNotProvided

This is the simplest test, since this execution path in the rule does not access any members on the IItemImportContextMapping so a minimal of mocking is required.

[Fact]
public void ShouldRaiseFlag2IfFrequencyShortNameNotProvided()
{
// Arrange
var ctx = A.Fake<IItemImportContextMapping>();
var sut = new MappingRules(ctx);
var flags = new List<int>();

// A local function to act as FlagHandler
bool flagHandler(int flag)
{
flags.Add(flag);
return true;
};

// Act
var result = sut.GetFrequencyId(flagHandler, null);

// Assert
result.Should().BeNull("Rule should return null");
flags.Count.Should().Be(1, "Should raise exactly 1 flag");
flags.Should().Contain(f => f == 2, "Should raise flag 2");
}

// Arrange

  • Using FakeItEasy, create a mock context (a 'fake') of the IItemImportContextMapping
  • Get a sut (System under Test) by constructing an instance of the MappingRules class, passing in the fake context to the constructor.
  • Create a flags list of int to keep track of the flags raised by the rule
  • Create a local method with the same signature as the FlagHandler delegate to be passed to the rule. This method stores all flags raised by the rule in the flags list

// Act

  • Test the rule by calling the rule method on the sut with the local flagHandler and a null value for the frequencyShortName
  • Store the return value in the result variable

// Assert

  • Using FluentAssertions, assert that
    • The rule returns a null value
    • Exactly one flag is raised
    • The flag number 2 is raised

ShouldRaiseFlag1IfFrequencyNotFound

For this test, the rule will attempt to lookup a row in the Frequencies Valueset. So, this Valueset must be mocked.

Since the test is that rule should not find anything, it is sufficient to mock an empty Valueset.

[Fact]
public void ShouldRaiseFlag1IfFrequencyNotFound()
{
// Arrange
var valuesets = A.Fake<IValuesets>();

A.CallTo(() => valuesets.Frequencies)
.Returns(Enumerable.Empty<Valueset.IFrequencies>()
.AsQueryable());

var ctx = A.Fake<IItemImportContextMapping>();

A.CallTo(() => ctx.Valueset).Returns(valuesets);

var sut = new MappingRules(ctx);

var flags = new List<int>();

// A local function to act as FlagHandler
bool flagHandler(int flag)
{
flags.Add(flag);
return true;
};

// Act
var result = sut.GetFrequencyId(flagHandler, "should not be found");

// Assert
result.Should().BeNull("Rule should return null");
flags.Count.Should().Be(1, "Should raise exactly 1 flag");
flags.Should().Contain(f => f == 1, "Should raise flag 1");
}

// Arrange

  • Using FakeItEasy
    • Create a fake IValuesets instance. The IValuesets interface is generated and contains a get property for each Valueset
    • Mock the Frequencies property on the fake IValuesets to return an empty collection of Valueset rows. The Valueset.IFrequencies interface is created by the engine generator and contains get properties for the columns of the Valueset
    • Create a fake IItemImportContextMapping instance
    • Mock a call to the Valueset property on the fake context to return the fake IValuesets created earlier
  • Get a sut (System under Test) by constructing an instance of the MappingRules class, passing the fake context.
  • Create a flags list of int to keep track of the flags raised by the rule
  • Create a local method with the same signature as the FlagHandler delegate to be passed to the rule. This method stores all flags raised by the rule in the flags list

// Act

  • Test the rule by calling the rule method on the sut with the local flagHandler and a dummy value for the frequencyShortName
  • Store the return value in the result variable

// Assert

  • Using FluentAssertions, assert that
    • The rule returns a null value
    • Exactly one flag is raised
    • The flag number 1 is raised

ShouldReturnFoundValueIfSuccessful

For this test, the rule will again attempt to lookup a row in the Frequencies Valueset and this Valueset must be mocked.

Since the test is that the rule should now successfully find a row, the mocked Valueset must contain a row to find - plus a couple of other rows to ensure it actually finds the correct one.

[Fact]
public void ShouldReturnFoundValueIfSuccessful()
{
// Arrange
var frequencyName = "Test";
var frequencyId = 1000;
var frequencies = new List<Valueset.FrequenciesRow>
{
new() { FrequencyId = frequencyId - 1, FrequencyShortName = frequencyName + "_A" },
new() { FrequencyId = frequencyId, FrequencyShortName = frequencyName }, // Row to find
new() { FrequencyId = frequencyId + 1, FrequencyShortName = frequencyName + "_B" }
};

var valuesets = A.Fake<IValuesets>();

A.CallTo(() => valuesets.Frequencies)
.Returns(frequencies.AsQueryable());

var ctx = A.Fake<IItemImportContextMapping>();

A.CallTo(() => ctx.Valueset)
.Returns(valuesets);

var sut = new MappingRules(ctx);

var flags = new List<int>();

// A local function to act as FlagHandler
bool flagHandler(int flag)
{
flags.Add(flag);
return true;
};

// Act
var result = sut.GetFrequencyId(flagHandler, frequencyName);

// Assert
flags.Count.Should().Be(0, "Should raise no flags");
result.Should().Be(frequencyId, "Should return the frequencyId found in the Valueset");
}

// Arrange

  • Set up a couple of variables to contain the frequencyShortName to lookup and the expected frequencyId to be found
  • Using FakeItEasy
    • Create a fake IValuesets instance. The IValuesets interface is created by the engine generator and contains a get property for each Valueset

    • Create collection of Valueset rows. One of the rows has the correct value for frequencyShortName and frequencyId.

      The Valueset.FrequenciesRow class is generated and contains getter and setter properties for the Valueset columns

    • Mock a call to the Frequencies property on the fake IValuesets to return collection of Valueset rows created above

    • Create a fake IItemImportContextMapping instance

    • Mock a call to the Valueset property on the fake context to return the fake IValuesets created earlier

  • Get a sut (System under Test) by constructing an instance of the MappingRules class, passing the fake context.
  • Create a flags list of int to keep track of the flags raised by the rule
  • Create a local method with the same signature as the FlagHandler delegate to be passed to the rule. This method stores all flags raised by the rule in the flags list

// Act

  • Test the rule by calling the rule method on the sut with the local flagHandler and the correct frequencyShortName stored above
  • Store the return value in the result variable

// Assert

  • Using FluentAssertions, assert that
    • The rule does not raise any flags
    • The rule returns the expected frequencyId from the correct Valueset row

Testing a Rule or Bag that uses a Parser

The rules above read Valuesets. A Rule or Bag can just as well read a Parser. A Parser is faked through the fake context, in the same way. The Parser interfaces are public, so a test can fake them.

A Parser is a nested structure. To reach a value inside it, the test fakes each step the Rule walks, one step at a time.

The CheckInterestLines Bag from the WorkshopDemo Target Engine is a good example. It reads the InterestLine children of an Interest through the Interface Parser, and raises flag 1 when their limits are not in ascending order. The listing below leaves out the bookkeeping that maintains the interest-line count, so the parser read and the flag stand clear:

public override bool CheckInterestLines(FlagHandler flag)
{
var interest = InterfaceItem.As().Account.Interest.Parse();

var interestLines = interest.Children.InterestLine
.Select(l => new
{
SeqNo = l.Fields.InterestLineSeqNo.GetValueOrDefault(),
Limit = l.Fields.Limit.GetValueOrDefault()
})
.OrderBy(l => l.SeqNo);

var lastLimit = decimal.MinValue;

foreach (var line in interestLines)
{
if (lastLimit > line.Limit)
{
flag(1);
break;
}

lastLimit = line.Limit;
}

return true;
}

The Bag reaches the Parser through InterfaceItem.As().Account.Interest.Parse(). It then reads interest.Children.InterestLine, and for each line its Fields.

The Parser interfaces live in the MigFx.Engine.Project.Target.Parsers.Interface namespace, so the test file needs a using for it.

ShouldRaiseNoFlagWhenLimitsAscend

When the limits ascend with the sequence, the Bag should raise no flag.

The test fakes every step of the path the Bag walks. Each step returns a Parser interface, so each step is a fake of its own, wired to the next with A.CallTo:

[Fact]
public void ShouldRaiseNoFlagWhenLimitsAscend()
{
// Arrange
var ctx = A.Fake<IItemImportContext>();

// Fake each step of InterfaceItem.As().Account.Interest.Parse()
var interfaceItem = A.Fake<IInterfaceItem>();
var accessor = A.Fake<IInterfaceItem.IAs>();
var accountProvider = A.Fake<IAccountParserProvider>();
var interestProvider = A.Fake<IAccountParserProvider.IInterestParserProvider>();
var interestParser = A.Fake<IAccountParserProvider.IInterestParserProvider.IParser>();
var children = A.Fake<IAccountParserProvider.IInterestParserProvider.IChildren>();

A.CallTo(() => ctx.InterfaceItem).Returns(interfaceItem);
A.CallTo(() => interfaceItem.As()).Returns(accessor);
A.CallTo(() => accessor.Account).Returns(accountProvider);
A.CallTo(() => accountProvider.Interest).Returns(interestProvider);
A.CallTo(() => interestProvider.Parse(A<bool>._)).Returns(interestParser);
A.CallTo(() => interestParser.Children).Returns(children);

// Fake two InterestLine Parsers, with ascending limits
var firstFields = A.Fake<IAccountParserProvider.IInterestParserProvider.IInterestLineParserProvider.IFields>();
A.CallTo(() => firstFields.InterestLineSeqNo).Returns(1m);
A.CallTo(() => firstFields.Limit).Returns(1000m);
var first = A.Fake<IAccountParserProvider.IInterestParserProvider.IInterestLineParserProvider.IParser>();
A.CallTo(() => first.Fields).Returns(firstFields);

var secondFields = A.Fake<IAccountParserProvider.IInterestParserProvider.IInterestLineParserProvider.IFields>();
A.CallTo(() => secondFields.InterestLineSeqNo).Returns(2m);
A.CallTo(() => secondFields.Limit).Returns(2000m);
var second = A.Fake<IAccountParserProvider.IInterestParserProvider.IInterestLineParserProvider.IParser>();
A.CallTo(() => second.Fields).Returns(secondFields);

A.CallTo(() => children.InterestLine).Returns(new[] { first, second });

var sut = new Bags(ctx).InterestLines;

var flags = new List<int>();

bool flagHandler(int flag)
{
flags.Add(flag);
return true;
};

// Act
sut.CheckInterestLines(flagHandler);

// Assert
flags.Should().BeEmpty("Ascending limits should raise no flag");
}

// Arrange

  • Using FakeItEasy, create a fake IItemImportContext, the context the Bag is constructed with.
  • Fake each step of InterfaceItem.As().Account.Interest.Parse(). Every step returns a Parser interface, so each gets its own fake, and an A.CallTo wires each step to return the next.
  • A<bool>._ matches the throwIfInvalid argument that Parse takes.
  • Fake two InterestLine Parsers. Each one needs a fake IFields, with InterestLineSeqNo and Limit set, wired to the Parser through its Fields property.
  • Wire the two Parsers as the Children.InterestLine sequence.
  • Get the sut by taking the InterestLines Bag from a Bags collection built on the fake context. A Bag is reached through Bags; its own constructor is internal. Set up a flags list and a local flagHandler, as in the earlier tests.

// Act

  • Run the Bag method on the sut, passing the local flagHandler.

// Assert

  • Assert that no flag was raised.

That is a long arrangement, and most of it is the path to the Parser rather than the test data. FakeItEasy can take that part over. When an A.CallTo names a chain of members, FakeItEasy fakes each step of the chain on its own. The six fakes and six calls for the path collapse into one:

A.CallTo(() => ctx.InterfaceItem.As().Account.Interest.Parse(A<bool>._).Children.InterestLine)
.Returns(new[] { first, second });

The InterestLine Parsers, first and second, are still built by hand. Only the path that leads to them stops needing a fake at every step.

ShouldRaiseFlag1WhenLimitsDoNotAscend

When a limit drops below the one before it, the Bag should raise flag 1. This test uses the shorter arrangement:

[Fact]
public void ShouldRaiseFlag1WhenLimitsDoNotAscend()
{
// Arrange
var ctx = A.Fake<IItemImportContext>();

// Two InterestLine Parsers; the second has a lower limit than the first
var firstFields = A.Fake<IAccountParserProvider.IInterestParserProvider.IInterestLineParserProvider.IFields>();
A.CallTo(() => firstFields.InterestLineSeqNo).Returns(1m);
A.CallTo(() => firstFields.Limit).Returns(3000m);
var first = A.Fake<IAccountParserProvider.IInterestParserProvider.IInterestLineParserProvider.IParser>();
A.CallTo(() => first.Fields).Returns(firstFields);

var secondFields = A.Fake<IAccountParserProvider.IInterestParserProvider.IInterestLineParserProvider.IFields>();
A.CallTo(() => secondFields.InterestLineSeqNo).Returns(2m);
A.CallTo(() => secondFields.Limit).Returns(1000m);
var second = A.Fake<IAccountParserProvider.IInterestParserProvider.IInterestLineParserProvider.IParser>();
A.CallTo(() => second.Fields).Returns(secondFields);

A.CallTo(() => ctx.InterfaceItem.As().Account.Interest.Parse(A<bool>._).Children.InterestLine)
.Returns(new[] { first, second });

var sut = new Bags(ctx).InterestLines;

var flags = new List<int>();

bool flagHandler(int flag)
{
flags.Add(flag);
return true;
};

// Act
sut.CheckInterestLines(flagHandler);

// Assert
flags.Should().Contain(f => f == 1, "A limit that does not ascend should raise flag 1");
}

The two InterestLine Parsers are built by hand, as before. The path to them is now the single chained A.CallTo. Once the Bag orders the lines by InterestLineSeqNo, the limits no longer ascend, so the Bag raises flag 1.

For the Parsers themselves, and the other Parsers a Rule or Bag can use, see Parsing migration data in manual code.