Loading...

Separating Plugin Logic: A Guide to Testing Dataverse Plugins with IOC

Separating Plugin Logic: A Guide to Testing Dataverse Plugins with IOC

I’m not a pure TDD developer.  I frequently take my best guess at a Dataverse plugin, then apply TDD until everything works.  This can lead to situations where my “rough draft” plugin is complete, but when I go to write my first test, I realize that I have to test allot, and that’s going to be very painful.  The solution to this is to restructure your plugin code so you can test logic independently of each other.  I ran into having to do this recently and decided that maybe a guide of what I do could be helpful to others.  So, if you ever find yourself in this situation and need a little help, this is the guide for you!

Background

The business requirement in my example is to create a “Total Fees” record per year for contacts, which contained the sum of fees from a grandchild record, where the year was determined by the connecting child record.  This resulted in a data model like this:


The plugin would trigger a recalc of fees for a contact, if:

  1. A grandchild was added
  2. A grandchild was removed.
  3. A grandchild fees was updated
  4. A child was added
  5. A child was removed
  6. A child year was updated

And this is a simplistic view still, since there are plenty of situations where changes shouldn’t trigger a recalc (like the fees being updated from null to 0, or a fee getting added when there is no child id, etc).  For now, let’s abstract all that /* logic */ which gives us these methods in the plugin, with the “OnX” methods being called from the Execute automatically by the plugin base class depending on the context, each each “OnX” method calling the RecalcTotalsForContact method:

private void OnGrandchildChange(ExtendedPluginContext context) { /* logic */ }

private void OnGrandchildCreate(ExtendedPluginContext context) { /* logic */ }

private void OnChildChange(ExtendedPluginContext context) { /* logic */ }

private void OnChildCreate(ExtendedPluginContext context) { /* logic */ }

private void RecalcTotalsForContact(IExtendedPluginContext context, Guid contactId, int year)
{
    context.Trace("Triggering Recalc for Contact {0}, and Year {1}.", contactId, year);

    var yearStart = new DateTime(year, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
    var nextYearStart = yearStart.AddYears(1);
    var qe = QueryExpressionFactory.Create<Acme_Grandchild>(v => new { v.Acme_Fees });
    qe.AddLink<Acme_Child>(Acme_Grandchild.Fields.Acme_ChildId, Acme_Child.Fields.Id)
        .WhereEqual(
            Acme_Child.Fields.Acme_ContactId, contactId,
            new ConditionExpression(Acme_Child.Fields.Acme_Year, ConditionOperator.GreaterEqual, yearStart),
            new ConditionExpression(Acme_Child.Fields.Acme_Year, ConditionOperator.LessThan, nextYearStart));

    var totalFees = context.SystemOrganizationService.GetAllEntities(qe).Sum(v => v.Acme_Fees.GetValueOrDefault());
    var upsert = new Acme_ContactTotal
    {
        Acme_ContactId = new EntityReference(Contact.EntityLogicalName, contactId),
        Acme_Name = year + " Net Fees",
        Acme_Total = new Money(totalFees),
        Acme_Year = year.ToString()
    };
    upsert.KeyAttributes.Add(Acme_ContactTotal.Fields.Acme_ContactId, contactId);
    upsert.KeyAttributes.Add(Acme_ContactTotal.Fields.Acme_Year, year.ToString());

    context.SystemOrganizationService.Upsert(upsert);
}

Separating The Logic

When testing, we want to be able to test the “OnX” methods separately from the actual calculation logic in the RecaclTotalsForContact.  In order to do that we will need to be able to inject the calculation logic into the plugin, allowing it to run using a mock object that can be used to verify that the RecalcTotalsForContact was called correctly when testing, and using the actual logic when running on the Dataverse server.

There are 100 different ways to inject the logic into the plugin, but one of the simplest is to encapsulate the RecalcTotalsForContact logic into an interface and inject it into the IServiceProvider that is already in the plugin infrastructure.  Using this approach, the first step is to encapsulate the logic into an IContactTotalCalculator interface (Some purists will never put the interface and the implementation in the file, but if you’re only ever going to have one implementation, IMHO it makes finding the implementation much simpler to be in the same file):

public interface IContactTotalCalculator
{
    void RecalcTotalsForContact(IExtendedPluginContext context, Guid contactId, int year);
}

public class ContactTotalCalculator : IContactTotalCalculator
{
    public void RecalcTotalsForContact(IExtendedPluginContext context, Guid contactId, int year)
    {
        context.Trace("Triggering Recalc for Contact {0}, and Year {1}.", contactId, year);

        var yearStart = new DateTime(year, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
        var nextYearStart = yearStart.AddYears(1);
        var qe = QueryExpressionFactory.Create<Acme_Grandchild>(v => new { v.Acme_Fees });
        qe.AddLink<Acme_Child>(Acme_Grandchild.Fields.Acme_ChildId, Acme_Child.Fields.Id)
            .WhereEqual(
                Acme_Child.Fields.Acme_ContactId, contactId,
                new ConditionExpression(Acme_Child.Fields.Acme_Year, ConditionOperator.GreaterEqual, yearStart),
                new ConditionExpression(Acme_Child.Fields.Acme_Year, ConditionOperator.LessThan, nextYearStart));

        var totalFees = context.SystemOrganizationService.GetAllEntities(qe).Sum(v => v.Acme_Fees.GetValueOrDefault())
        var upsert = new Acme_ContactTotal
        {
            Acme_ContactId = new EntityReference(Contact.EntityLogicalName, contactId),
            Acme_Name = year + " Net Fees",
            Acme_Total = new Money(totalFees),
            Acme_Year = year.ToString()
        };
        upsert.KeyAttributes.Add(Acme_ContactTotal.Fields.Acme_ContactId, contactId);
        upsert.KeyAttributes.Add(Acme_ContactTotal.Fields.Acme_Year, year.ToString());

        context.SystemOrganizationService.Upsert(upsert);
    }
}

Then update the plugin to get the IContactTotalCalculator from the ServiceProvider, defaulting to the ContactTotalCalculator implementation if no implementation exists (which won’t on the Dataverse server):

private void RecalcTotalsForContact(IExtendedPluginContext context, Guid contactId, int year)
{
    var calculator = context.ServiceProvider.Get<IContactTotalCalculator>() ?? new ContactTotalCalculator();
    calculator.RecalcTotalsForContact(context, contactId, year);
}

With this simple change, The ContactTotalCalculater is now completely separate from the plugin and can be tested separately with ease!  The plugin triggering logic can now also be tested independently of the actual recalculation logic but there are a few more step required.  Here is a test helper method for the grand children logic that can be called multiple times with different pre-images and targets and the expected children that should be triggered to be recalculated:

private static void TestRecalcTriggered(
    IOrganizationService service,
    ITestLogger logger,
    MessageType message,
    Acme_Grandchild preImage,
    Acme_Grandchild target,
    string failMessage,
    params Acme_Child[] triggeredChildren)
{
    // CREATE LOGIC CONTACT TOTAL CALCULATOR MOCK THAT ACTUALLY DOES NOTHING
    var mockCalculator = new Moq.Mock<IContactTotalCalculator>();
    var plugin = new SumContactFeesPlugin();
    var context = new PluginExecutionContextBuilder()
        .WithFirstRegisteredEvent(plugin, p => p.EntityLogicalName == Acme_Grandchild.EntityLogicalName
                                               && p.Message == message)
        .WithTarget(target);
    if (preImage != null)
    {
        context.WithPreImage(preImage);
    }

    var serviceProvider = new ServiceProviderBuilder(service, context.Build(), logger)
        .WithService(mockCalculator.Object).Build(); // INJECT MOCK INTO SERVICE PROVIDER

    //
    // Act
    //
    plugin.Execute(serviceProvider);

    //
    // Assert
    //
    foreach (var triggeredChild in triggeredChildren)
    {
        mockCalculator.Verify(m =>
                m.RecalcTotalsForContact(It.IsAny<IExtendedPluginContext>(), triggeredChild.Acme_ContactId.Id, triggeredChild.Acme_Year.Year),
            failMessage);
    }

    // VERIFY MOCK CALLED THE EXPECTED # OF TIMES
    try
    {
        mockCalculator.VerifyNoOtherCalls();
    }
    catch
    {
        Assert.Fail(failMessage);
    }
}

Please note that I’m using Moq for my mocking framework and XrmUnitTest for my ServiceProviderBuilder.  You can use any mocking framework/Dataverse Testing framework that you’d like, they’ll all provide the same logic with similar effort.  The key concept is to inject the mock implementation into the IServiceProvider provided to the IPlugin Execute method, and then verify that it has been called the correct number of times with the correct arguments.

Published on:

Learn more
.Net Dust
.Net Dust

NULL

Share post:

Related posts

Build intelligent and scalable solutions with Microsoft Power Apps and Microsoft Power Platform 

In this blog post and the accompanying demo video, we want to highlight what is possible with Microsoft Power Apps and Microsoft Power Platfor...

2 days ago

Power Platform & M365 Dev Community Call – July 18th, 2024 – Screenshot Summary

Call Highlights   SharePoint Quicklinks: Primary PnP Website: https://aka.ms/m365pnp Documentation & Guidance SharePoint Dev Videos Issues...

2 days ago

Dynamics 365 & Power Platform 2024 Release Wave 2 Overview

As Microsoft have unveiled their planned improvements for October 2024, we take a look at the key innovations and changes. The release plans, ...

2 days ago

Microsoft Reactor: Activate contextualised data with Microsoft Graph, Power Platform & Copilot

Hey Friends! 👋So, we're already two weeks into this, but I wanted to tell you about a series running this month which I'm sp...

2 days ago

Power Platform – July 2024– Screenshot Summary

Community Call Highlights   Quicklinks: Power Platform Community: Power Apps Power Automate Power BI Power Pages M365 Platform Community: http...

3 days ago

Dynamics 365 and Power Platform – 2024 release wave 2 plans available now

Exciting news for Dynamics 365 and Power Platform users! Microsoft has unveiled the 2024 release wave 2 plans for these applications, which wi...

3 days ago

2024 Wave 2 Plans for Dynamics 365 and Power Platform

On July 16, 2024, Microsoft published the 2024 release wave 2 plans for Microsoft Dynamics 365 and Microsoft Power Platform. These plans are a...

3 days ago

2024 release wave 2 plans for Microsoft Dynamics 365 and Microsoft Power Platform now available 

On July 16, 2024, we published the 2024 release wave 2 plans for Microsoft Dynamics 365 and Microsoft Power Platform. The post 2024 release wa...

4 days ago
Stay up to date with latest Microsoft Dynamics 365 and Power Platform news!
* Yes, I agree to the privacy policy