Jeżeli w waszej aplikacji pobieracie/zapisujecie obiekty z/do CRM to na pewno wykorzystujecie CRM SDK.

Wszystko co robicie będzie się obracało wokół interfejsu IOrganizationService lub jednej z klas go implementujących CrmOrganizationServiceContext albo wykorzystujących takich jak OrganizationServiceContext. A jak do tego wykorzystujecie jeszcze narzędzie CrmSvcUtil do wygenerowania encji silnie typowanych to możecie jeszcze uzyskać coś takiego jak XrmServiceContext (lub jak to tam sobie nazwiecie), który rozszerzy implementację OrganizationServiceContext o nazwy kolekcji dodając słowo Set do encji: AccountSet zwróci IQueryable<Account> – jest to po prostu skrót do OrganizationServiceContext.CreateQuery.

W zależności od typu aplikacji i jej wielkości, albo będziecie bezpośrednio dla przykładu w MVC, w akcji kontrolera wywoływali kod typu:

var accounts = from x in _context.CreateQuery<Account>()
               where x.Name == "SOME_NAME"
               select new
               {
                   AccountName = "SOME_NAME",
                   AccountNo = x.AccountNumber
               };

Albo opakujecie to w repozytorium czy też coś w stylu Query pattern/ReadModel.

Jakkolwiek to zrobicie, od tego momentu kod który wykorzystuje bezpośrednio zapytania CRM jest tak naprawdę nie testowalny – można wykonywać testy integracyjne, ale to znów ktoś wam zmieni dane w CRM i wasze testy będą leżeć bo założyliście, że encja o ID X istnieje albo posiada wartość Z.

U nas zdarzyło się to około pół roku temu, kiedy decyzją odgórną CRM zaczął być traktowany jako internal tool, zaś external ludzie mają dostęp do danych poprzez z customizowane strony WWW. A jako, że od pewnego czasu po prostu nie jestem wstanie zostawić kodu bez jakiegokolwiek testu – czy to poprzez projektowanie przez TDD czy też by mieć pewność, że kod zadziała tak jak ja chcę – musiałem wymyślić sposób jak przetestować te nasze rozwiązania.

Pomysłów było wiele, poprzez stworzenie custom XrmServiceContext, który będzie zwracał nam IQueryable<T> gdzie T to typ encji (przez co dla testów można ustawić zwracanie własnych obiektów), poprzez wydzielenie specjalnej instancji CRM na której dane będą mogły jedynie modyfikować testy, zaś CRM będzie resetowany co X godzin.

Żaden mi jednak do gustu nie przypadł – CRM, bo a nuż jeden test zmienia coś w danych i nagle reszta przestaje działać, custom XrmServiceContext bo po prostu mi się nie chce pisać z palca wszystkiego co potrzebuje – a muszę z góry napisać wszystkie możliwości gdyż inaczej kod się nie będzie kompilował.

Do tego, chciałem móc to jakoś injectować do naszych read modeli by nie musieć ciągle pisać using co naprawdę jest denerwujące, kod nagle wygląda jak jedna wielka maszkarada do niczego się nie nadająca, a jednak coś robiąca – o, mniej więcej tak:

public class SomeReadModel : ISomeReadModel
{
    public SomeListViewModel GetSomeList()
    {
        var vm = new SomeListViewModel();

        using(var context = new OrganizationServiceContext(_service))
        {
            var list = from a in context.CreateQuery<Entity1>()
                       join b in context.CreateQuery<Entity2>() on a.ref_b.Id equals b.Id
                       orderby a.Name ascending
                       select new SomeListViewModel.ListItem
                       {
                           Id = a.Id,
                           Name = a.Name,
                           BName = b.Name
                       };

            vm.ListItems = list.ToList();

        }

        return vm;
    }

    private readonly IOrganizationService _service;

    public SomeReadModel(IOrganizationService service)
    {
        _service = service;
    }
}

To do czego doszedłem, wydaje mi się najlepszym z rozwiązań jakie można osiągnąć.

Na początku wyciągnąłem do interfejsu wszystkie metody z których korzystałem:

using System.Linq;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Client;

namespace Gutek.Utils.Crm
{
    public interface IOrgContext
    {
        IQueryable<TEntity> CreateQuery<TEntity>() where TEntity : Entity;

        SaveChangesResultCollection SaveChanges(SaveChangesOptions options);
        SaveChangesResultCollection SaveChanges();
        void ClearChanges();

        void AddRelatedObject(Entity source, Relationship relationship, Entity target);
        void AddObject(Entity entity);
        void Attach(Entity entity);

        void UpdateObject(Entity entity);
        void UpdateObject(Entity entity, bool recursive);

        void DeleteObject(Entity entity);
        void DeleteObject(Entity entity, bool recursive);

        IOrganizationService Service { get; }
    }
}

Następnie stworzyłem klasę implementująca mój interfejs i dziedziczącą bo OrganizationServiceContext (czyli coś w stylu custom XrmServiceContext):

using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Client;

namespace Gutek.Utils.Crm
{
    public class ImplOrgContext : OrganizationServiceContext, IOrgContext
    {
        private readonly IOrganizationService _service;

        public ImplOrgContext(IOrganizationService service)
            : base(service)
        {
            _service = service;
        }

        public IOrganizationService Service
        {
            get { return _service; }
        }
    }
}

Dodałem dodatkowo opcje dostępu do serwisu, jako iż przydaje się on przy prostych operacjach lub innego typu zapytaniach niż LINQ (o czym kiedy indziej).

Autofac zaś ustawiłem tak by zarządzał contextem per lifetime scope – problem using z głowy :)

Niby nic specjalnego z całym tym interfejsem i klasą, jednak siła tego rozwiązania wychodzi kiedy wykorzystamy do tego FakeItEasy.

Testy, które testują kod wykorzystujący CRM dziedziczą u mnie wszystkie z jednej klasy:

using FakeItEasy;

namespace Gutek.Utils.Crm.Tests
{
    public class crm_test_base
    {
        protected readonly IOrgContext _context;

        public crm_test_base()
        {
            _context = A.Fake<IOrgContext>();
        }
    }
}

Nie jest ona potrzebna jak widać, ale się przydaje z unifikowanie pewnych rzeczy jeżeli pracuje się z kilkoma osobami, oraz by mieć pewność iż interfejs jest fake a nie jest przekazywana instancja obiektu ImplOrgContext. Dodatkowo, mogę tutaj napisać kilka pomocnych metod, które nie koniecznie nadają się do rozszerzeń.

Teraz, by móc wykorzystać moje klasy w testach napisałem kilka extension methods, które podzieliłem na trzy grupy: get, intercept i save changes.

  • Metody get służą zwróceniu listy elementów – czy to będzie pusta lista, lista jedno elementowa czy też bardziej złożone rozwiązanie.
  • Metody intercept umożliwiają przechwycenie obiektu, na którym wykonywana jest jakaś metoda – jak update, delete itp.
  • Metody save changes umożliwiają zdefiniowanie zachowania metody SaveChanges – dość ważne przy usuwaniu, aktualizowaniu czy też tworzeniu elementów.

Rozszerzenia Get

using System;
using System.Collections.Generic;
using System.Linq;
using FakeItEasy;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;

namespace Gutek.Utils.Crm.Tests
{
    public static class crm_ext_get
    {
        // change single entity to IQueryable
        public static IQueryable<TEntity> ToQueryable<TEntity>(this TEntity @this)
        {
            var list = new List<TEntity>();

            list.Add(@this);

            return list.AsQueryable();
        }

        public static IList<TEntity> return_empty_list<TEntity>(this IOrgContext context)
            where TEntity : Entity, new()
        {
            var items = new List<TEntity>();

            A.CallTo(() => context.CreateQuery<TEntity>())
                .ReturnsLazily(x => items.AsQueryable());

            return items;
        }

        public static TEntity return_entity_of_id<TEntity>(this IOrgContext context
            , Guid id)
            where TEntity : Entity, new()
        {
            var item = new TEntity();
            item.Id = id;

            A.CallTo(() => context.CreateQuery<TEntity>())
                .ReturnsLazily(x => item.ToQueryable());

            return item;
        }

        public static TEntity return_entity_with_initialization<TEntity>(this IOrgContext context
            , Action<TEntity> initialize = null)
            where TEntity : Entity, new()
        {
            var item = new TEntity();
            item.Id = Randomizer.Guid();

            A.CallTo(() => context.CreateQuery<TEntity>())
                .ReturnsLazily(x => item.ToQueryable());

            if(initialize != null)
            {
                initialize(item);
            }

            return item;
        }

        public static TEntity return_entity<TEntity>(this IOrgContext context
            , TEntity entity)
            where TEntity : Entity, new()
        {
            A.CallTo(() => context.CreateQuery<TEntity>())
                .ReturnsLazily(x => entity.ToQueryable());

            return entity;
        }

        public static IList<TEntity> return_list<TEntity>(this IOrgContext context
            , IEnumerable<TEntity> list)
            where TEntity : Entity, new()
        {
            A.CallTo(() => context.CreateQuery<TEntity>())
                .ReturnsLazily(x => list.AsQueryable());

            return list.ToList();
        }

        public static IList<TEntity> return_random_list<TEntity>(this IOrgContext context
            , Action<TEntity> initialize = null)
            where TEntity : Entity, new()
        {
            var items = Randomizer.Array(() => new TEntity());

            if(initialize == null)
            {
                initialize = entity => { };
            }

            foreach(var item in items)
            {
                item.Id = Randomizer.Guid();
                initialize(item);
            }

            A.CallTo(() => context.CreateQuery<TEntity>())
                .ReturnsLazily(x => items.AsQueryable());

            return items;
        }

        public static IList<TEntity> return_random_list_with_different_first_entity<TEntity>(
            this IOrgContext context,
            Action<TEntity> firstEntityInitialize,
            Action<TEntity> initialize = null
            )
            where TEntity : Entity, new()
        {
            var items = Randomizer.Array(() => new TEntity());

            if(initialize == null)
            {
                initialize = entity => { };
            }

            var first = true;
            foreach(var item in items)
            {
                item.Id = Randomizer.Guid();
                initialize(item);

                if(first)
                {
                    firstEntityInitialize(item);
                    first = false;
                }
            }

            A.CallTo(() => context.CreateQuery<TEntity>())
                .ReturnsLazily(x => items.AsQueryable());

            return items;
        }
    }
}

Info: Kod wykorzystuje klasę Randomizer, stworzoną przez Procenta – o jej początkach możecie przeczytać tutaj – zaś jej finalna (lub ostatnia) wersja, znajduje się w Predica FimClient (więcej o kliencie tutaj) – Randomizer.

Rozszerzenia te możemy wykorzystać na przykład w ten sposób:

private readonly string _someId = "SOME_TEST_ID";
private readonly Guid _ref_id = Randomizer.Guid();

private void configure_random_list()
{
    var someList = _context.return_random_list_with_different_first_entity<some_entity>(some =>
    {
        // set id that we are looking for in query
        some.normal_id = _someId;
    },
    some =>
    {
        some.Id = Randomizer.Guid();
        some.normal_id = Randomizer.String();

        some.Name = Randomizer.String();
        some.belongs_to_account = new EntityReference(Account.EntityLogicalName, _ref_id);
    });
}

[Fact]
public void should_return_count_0_if_some_entities_does_not_exists()
{
    _context.return_empty_list<some_entity>();
    var response = _readModel.GetSomeEntities(_someId);

    Assert.NotNull(response);
    Assert.NotNull(response.Model);

    Assert.Equal(0, response.Model.Count);
}

[Fact]
public void should_return_list_of_some_entities_and_count()
{
    configure_random_list();

    var response = _readModel.GetSomeEntities(_someId);

    Assert.NotNull(response);
    Assert.NotNull(response.Model);

    Assert.True(response.Model.Count > 0, "Should return more then 0 some entities");
    Assert.NotEmpty(response.Model.SomeEntities);
}

Oczywiście przy bardziej złożonych systemach, kiedy musimy mieć powiązania pomiędzy kilkoma encjami, możemy się trochę namęczyć w tworzeniu danych testowych, ale wykorzystując proste Factory, reflection oraz Randomizer możemy w prosty sposób przypisać wszystkie własności tak jak my chcemy, baa nawet możemy stworzyć jak trzeba własne kolekcje obiektów i przypisać je do wartości zwracanych (coś w stylu XrmServiceContext o którym wyżej wspomniałem).

UWAGA: podczas zabawy natrafiłem na pewien problem, mianowicie przy porównywaniu wartości OptionSetValue znak równości (==) mi w zapytaniach nie działał, musiałem go zamienić na Equals (działa on po referencji jak i po wartości) i wtedy all śmigało. To jest przypadek kiedy normalnie działa a w teście nie działa ;)

Rozszerzenia Intercept

using System;
using System.Collections.Generic;
using System.Linq;
using FakeItEasy;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;

namespace Gutek.Utils.Crm.Tests
{
    public static class crm_ext_intercept
    {
        public static void intercept_update<TEntity>(this IOrganizationService @this, Action<TEntity> intercept)
            where TEntity : Entity, new()
        {
            A.CallTo(() => @this.Update(A<Entity>.That.IsInstanceOf(typeof(TEntity))))
                .WithAnyArguments()
                .Invokes(x => intercept(x.GetArgument<TEntity>(0)));
        }

        public static void intercept_update<TEntity>(this IOrgContext @this, Action<TEntity> intercept)
            where TEntity : Entity, new()
        {
            A.CallTo(() => @this.UpdateObject(A<Entity>.That.IsInstanceOf(typeof(TEntity))))
                .Invokes(x => intercept(x.GetArgument<TEntity>(0)));
        }

        public static void intercept_create<TEntity>(this IOrganizationService @this, Action<TEntity> intercept, Guid returnId)
            where TEntity : Entity, new()
        {
            A.CallTo(() => @this.Create(A<Entity>.That.IsInstanceOf(typeof(TEntity))))
                .WithAnyArguments()
                .Invokes(x => intercept(x.GetArgument<TEntity>(0)))
                .Returns(returnId);
        }

        public static void intercept_create<TEntity>(this IOrgContext @this, Action<TEntity> intercept)
            where TEntity : Entity, new()
        {
            A.CallTo(() => @this.AddObject(A<Entity>.That.IsInstanceOf(typeof(TEntity))))
                .Invokes(x => intercept(x.GetArgument<TEntity>(0)));
        }

        public static TEntity intercept_retrieve<TEntity>(this IOrganizationService @this, Action<string, Guid> intercept, Action<TEntity> initialize)
            where TEntity : Entity, new()
        {
            if(intercept == null)
            {
                intercept = (x, y) => { };
            }

            TEntity entity = new TEntity();
            initialize(entity);

            A.CallTo(() => @this.Retrieve(entity.LogicalName, A<Guid>._, A<ColumnSet>._))
                .Invokes(x => intercept(x.GetArgument<string>(0), x.GetArgument<Guid>(1)))
                .Returns(entity);

            return entity;
        }

        public static void intercept_delete(this IOrganizationService @this, Action<string, Guid> intercept)
        {
            A.CallTo(() => @this.Delete(null, Guid.Empty))
                .WithAnyArguments()
                .Invokes(x => intercept(x.GetArgument<string>(0), x.GetArgument<Guid>(1)));
        }

        public static void intercept_delete<TEntity>(this IOrgContext @this, Action<TEntity> intercept, bool recursive = false)
        {
            A.CallTo(() => @this.DeleteObject(A<Entity>.That.IsInstanceOf(typeof(TEntity))))
                .Invokes(x => intercept(x.GetArgument<TEntity>(0)));

            A.CallTo(() => @this.DeleteObject(A<Entity>.That.IsInstanceOf(typeof(TEntity)), A<bool>._))
                .Invokes(x => intercept(x.GetArgument<TEntity>(0)));
        }
    }
}

Możemy je wykorzystać na przykład w taki sposób:

[Fact]
public void should_set_id_and_proper_entity_of_the_object_that_is_going_to_be_deleted()
{
    var command = get_command();
    some_entity entity = null;

    fake_save(error: false);
    _context.intercept_delete<some_entity>(x => entity = x);

    _handler.Handle(command);

    Assert.Equal(some_entity.EntityLogicalName, entity.LogicalName);
    Assert.Equal(command.SomeCrmId, entity.Id);
}

Info: Kod wykorzystuje metodę fake_save, zobacz kolejny punkt.

Przykładowy kod, który jest podawany testowy wyżej:

using System;
using Crm.TypedEntities;
using X.Commands;
using X.Infrastructure;
using Microsoft.Xrm.Sdk.Client;
using NLog;

namespace App.Ui.Web.Commands
{
    public class SomeEntityDeleteCommand : ICommand
    {
        public Guid SomeCrmId { get; set; }
    }

    public class SomeEntityDeleteCommandHandler : IHandle<SomeEntityDeleteCommand>
    {
        public object Handle(SomeEntityDeleteCommand command)
        {
            if(command == null)
            {
                throw new ArgumentNullException("command", "Command for Delete cannot be null to execute CRM action");
            }

            var result = new CommandResponse();

            var entity = new some_entity();
            entity.Id = command.SomeCrmId;

            _context.Attach(entity);
            _context.DeleteObject(entity, true);

            var save = _context.SaveChanges(SaveChangesOptions.ContinueOnError);

            result.SetErrors(save);

            return result;
        }

        private readonly IOrgContext _context;

        public SomeEntityDeleteCommandHandler(IOrgContext context)
        {
            _context = context;
        }
    }
}

Rozszerzenia SaveChanges

Tak jak wspomniałem na początku, mając klasę bazową dla testów możemy dodać pewne metody, które mogą się nam przydać. Taki przypadek bardzo łatwo znaleźć i jest nim metoda SaveChanges, która zwraca nam SaveChangesResultCollection. Niestety ma ona swoje konstruktory, te które nas interesują ustawione jako internal (tak MS z tego słynie… i co produkt trzeba robić obejścia… wiem).

Dlatego by móc przeciążyć jej wywołanie stworzyłem sobie dodatkową klasę crm_save_test_base, która zawiera implementację metod niezbędnych do z fakowania zachowania na save:

public class crm_save_test_base : crm_test_base
{
    protected void fake_save(bool error = false)
    {
        var changes = CreateSaveChanges();

        if(error)
        {
            changes.Add(CreateSaveChangesResult(true));
        }

        A.CallTo(() => _context.SaveChanges(SaveChangesOptions.ContinueOnError))
            .Returns(changes);
    }

    public static SaveChangesResultCollection CreateSaveChanges()
    {
        ConstructorInfo ctor = typeof(SaveChangesResultCollection).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic)[0];

        SaveChangesResultCollection abc = (SaveChangesResultCollection)ctor.Invoke(new object[] { SaveChangesOptions.ContinueOnError });

        return abc;
    }

    public static SaveChangesResult CreateSaveChangesResult(bool error, string message = "SOME ERROR")
    {
        ConstructorInfo ctor = typeof(SaveChangesResult).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic)[1];

        Exception ex = null;
        if(error)
        {
            ex = new Exception(message);
        }

        SaveChangesResult abc = (SaveChangesResult)ctor.Invoke(new object[] { null, ex });

        return abc;
    }
}

Dodatki

Dzięki temu, że mamy taką a nie inną konstrukcję naszego kodu, możemy teraz wykonywać równierz takie testy:

[Fact]
public void should_execute_delete_object_and_save_changes()
{
    var command = get_command();

    fake_save(false);

    _handler.Handle(command);

    A.CallTo(() => _context.DeleteObject(A<Entity>.That.IsInstanceOf(typeof(some_entity)), true))
        .MustHaveHappened(Repeated.Exactly.Once);

    A.CallTo(() => _context.SaveChanges(SaveChangesOptions.ContinueOnError))
        .MustHaveHappened(Repeated.Exactly.Once);
}

Podsumowanie

Tak naprawdę, rozwiązanie problemu z UT dla projektów wykorzystujących CRM wcale nie jest takie trudne i skomplikowane – ale o to chodzi, przez prostotę do celu.

Kilka rozszerzeń zakłada pewne stałe, ale to też można zmienić. U nas po prostu nie było sensu robić tego na super generyczny sposób, bo narzuciłem pewną konwencję którą należy utrzymać. Ale jak wy potrzebujecie dodatkowych opcji to nic nie stoi na przeszkodzie by je dodać/poprawić.

Mam nadzieję, że komuś się przydadzą te klasy i lub metody.

Na koniec, jak wy sobie radzicie z testowaniem rozwiązań wykorzystujących CRM? Może są jakieś biblioteki i inne rozwiązania o których nie wiem, albo sami coś ciekawego napisaliście?