05: Testy jednostkowe rozszerzeń aplikacji na platformie Common Data Service

Witam w kolejnym odcinku cyklu poświęconego programistycznym wzorcom projektowym stosowanych przy tworzeniu aplikacji na platformie…

No właśnie, jeszcze dwa lata temu w tytule tego tekstu znalazłby się „Dynamics CRM”. Gdyby artykuł powstał w zeszłym roku – informowałby z kolei o aplikacjach powstających na platformie „Dynamics 365 Customer Engagement”. Mamy już jednak rok 2020, a firma Microsoft kilka miesięcy temu wprowadziła ostateczne rozdzielenie aplikacji tworzonych pod brandem „Dynamics 365” (Dynamics 365 Sales, Dynamics 365 Customer Service, itp.) od samej platformy do ich budowy (która to powstała w oparciu o „silnik” dawnego CRM-a aktualnie określanego jako Common Data Service). Będziemy więc w tym miejscu mówić o rozszerzeniach aplikacji Power Apps sterowanych modelem (wybaczcie, ale inne określenie angielskiego „model-driven” nie przychodzi mi do głowy) i zbudowanych właśnie w oparciu platformę Common Data Service.

Przejdźmy jednak do tematu. Z poprzednich rozdziałów cyklu pamiętacie na pewno opisywaną trójwarstwową architekturę rozszerzeń typu Plugin oraz Workflow Activity.

Dla przypomnienia – na powyższym obrazku warstwa dostępu do danych jest odpowiedzialna za komunikację z platformą Common Data Service za pomocą starej i dobrej usługi OrganizationService. Warstwa ta odpowiada za szeroko pojętą persystencję i umożliwia oddzielenie operacji wykonywanych na repozytorium danych od jego fizycznej implementacji. Przeznaczenia warstwy logiki biznesowej nie trzeba nikomu wyjaśniać. Natomiast warstwa „wykonania” („execution”) to po prostu zbiór klas obsługujących zdarzenia zachodzące w systemie (pluginy) lub uruchamianych przez silnik procesów (workflow activity).

Przykładowa implementacja

Spójrzmy teraz na przykładowy kod w warstwie dostępu do danych. Poniższy interfejs opisuje zbiór operacji na obiektach typu Zespół (Team):

public interface ITeamRepository : IRepository<Team>
{
    Team GetSalesTeam();
}

Powyższy interfejs dziedziczy po interfejsie IRepository<T>, który opisuje operacje wspólne dla wszystkich typów encji (CRUD oraz inne operacje specyficzne dla platformy CDS). Dodatkowo klasa implementująca omawiany kontrakt musi posiadać metodę GetSalesTeam zwracającą rekord, który reprezentuje zespół sprzedażowy w naszym systemie. Przykładowa implementacja może wyglądać w następujący sposób:

 public class TeamRepository : RepositoryBase<Team>, ITeamRepository
{
   public Team GetSalesTeam()
   { 
      var query = this.ServiceContext.TeamSet.Where<Team>(t => t.Name == "My super sales team");
         return query.FirstOrDefault<Team>(); 
   }
}

Osoby zainteresowane implementacją klas bazowych wykorzystywanych w omawianym przykładzie odsyłam do kodu, który został dołączony do tego artykułu. Link do niego znajdziecie na dole strony.

Przejdźmy teraz do implementacji logiki biznesowej wykorzystującej przedstawione repozytorium danych. W poniższym przykładzie widzimy przykład kodu (z uwagi na czytelność pozbawiony został on części odpowiedzialnej za obsługę błędów, logowanie przebiegu aplikacji oraz kilku innych rzeczy) odpowiedzialnego za stworzenie zadania i przypisanie go do zespołu sprzedażowego w momencie, w którym w systemie znajdzie się namiar na firmę posiadającą więcej niż 30 pracowników. Stosowny interfejs oraz implementację usługi odpowiedzialnej za tworzenie ww. zadania znajdziecie poniżej:

 public interface INewLeadService : IService
 {
     void TryCreateTaskForLargeEmployeesNumberLeads(Lead lead); 
 }
 public class NewLeadService : ServiceBase, INewLeadService
 {
     private readonly ITaskRepository taskRepository;
     private readonly ITeamRepository teamRepository; 
     public NewLeadService(IRepositoryFactory repositoryFactory) : base(repositoryFactory)
     {
         taskRepository = repositoryFactory.Get<Task, ITaskRepository>();
         teamRepository = repositoryFactory.Get<Team, ITeamRepository>();
     }

     public void TryCreateTaskForLargeEmployeesNumberLeads(Lead lead)
     {
         if (lead.NumberOfEmployees > 30)
         {
             var team = teamRepository.GetSalesTeam();
             if (team != null)
             {
                 taskRepository.Create(new Task
                 {
                     Subject = "Important lead. Please do sales actions ASAP!",
                     RegardingObjectId = lead.ToEntityReference(), 
                     OwnerId = team.ToEntityReference()
                 });
                 this.taskRepository.SaveChanges(); 
             }                
         }
     }
 }

Metoda TryCreateTaskForLargeEmployeesNumberLeads sprawdza ilość pracowników firmy, której namiary trafiły do systemu. Następnie próbuje pobrać z systemu dane zespołu sprzedażowego (za pomocą opisywanego wcześniej repozytorium) oraz utworzyć (za pomocą kolejnej abstrakcji pochodzącej z klasy z warstwy dostępu do danych) zadanie przypisane go tego zespołu.

Spojrzymy teraz, w jaki sposób uruchamiany jest omawiany powyżej serwis z poziomu warstwy wykonania (execution):

public class HandleImportantLeadPlugin : PluginBase
{ 
    public override bool IsContextValid(IPluginExecutionContext context)
    {
        if(context.PrimaryEntityName == Lead.EntityLogicalName && (context.MessageName == "Create" || context.MessageName == "Update"))
        {
            return true; 
        }
        else
        {
            return false; 
        }
    }

    public override void Execute(IPluginExecutionContext pluginExecutionContext, Container container)
    {
        var target = this.GetTargetEntity<Lead>(pluginExecutionContext);
        var factory = container.GetInstance<IServicesFactory>(); 

        var newLeadService = factory.Get<INewLeadService>();
        newLeadService.TryCreateTaskForLargeEmployeesNumberLeads(target); 
    }

    public override void RegisterDependencies(Container container)
    {
        container.Register<ITaskRepository, TaskRepository>();
        container.Register<ITeamRepository, TeamRepository>();
        container.Register<INewLeadService, NewLeadService>();
    }
}

W powyższym przykładzie – kod uruchamiany jest w momencie utworzenia lub aktualizacji nowego rekordu typu Lead (Namiar) w systemie. Sprawdzenie warunków odbywa się za pomocą metody IsContextValid. Wewnątrz metody Execute następuje pobranie zmian z kontekstu, pobranie instancji fabryki serwisów za pomocą kontenera DI (wstrzykiwania zależności), następnie utworzenie instancji właściwego serwisu oraz uruchomienie jego metody TryCreateTaskForLargeEmployeesNumberLeads przyjmującej rekord namiaru jako argument wywołania.

Zwróćcie uwagę na przeciążoną metodę RegisterDependencies. Wewnątrz tej metody ma miejsce rejestracja obiektów za pomocą kontenera Dependency Injection. Dzięki takiemu podejściu – będziemy w stanie przetestować kod z każdej warstwy niezależnie od siebie za pomocą testów jednostkowych.

Testowanie warstwy dostępu do danych

Istnieje kilka podejść do automatyzacji testów warstwy dostępu do danych. Niektóre szkoły twierdzą, że – żeby test miał sens i faktycznie gwarantował prawidłowe działanie kodu – powinien działać on na identycznym repozytorium danych jak w środowisku produkcyjnym. W naszym przypadku oznaczałoby to konieczność posiadania dedykowanej instancji usługi Common Data Service lub też zainstalowanego lokalnie systemu Dynamics 365 Customer Engagement przeznaczonego do uruchamiania testów. Podejście to wymusza dodatkowo konieczność pisania kodu „sprzątającego” po uruchamianych testach. Nie chcemy przecież, żeby po każdorazowym uruchomieniu procesu testowania nasza baza była pełna „śmieciowych”, niepotrzebnych rekordów, które co gorsza, mogłyby wpłynąć na wyniki kolejnych uruchomień naszych testów.

Osobiście uważam, że sensownym kompromisem pomiędzy jakością testów a wygodą oraz czasem ich tworzenia jest emulacja fizycznego repozytorium danych w pamięci komputera. W przypadku platformy Dynamics 365 / Common Data Service istnieje kilka przydatnych frameworków pozwalających na stworzenie obiektów zastępujących fizyczną instancję kontekstu systemu oraz serwisu OrganizationService w pamięci komputera. Obecnie najpopularniejszym z nich jest Fake Xrm Easy (https://github.com/jordimontana82/fake-xrm-easy). To, w jaki sposób może być on dla nas przydatny, pokażę w poniższym przykładzie.

public class TaskRepositoryTest : RepositoryTestBase
{
    [TestMethod]
    public void Create_SingleTaskCreated()
    {
        var expectedEntitiesCount = 1;

        var service = GetFakedOrganizationService();
        CreateTaskWithRepository(service, "Test subject");
        var taskCollection = GetTasks(service);

        Assert.IsNotNull(taskCollection?.Entities);
        Assert.AreEqual(expectedEntitiesCount, taskCollection.Entities.Count);
    }

    [TestMethod]
    public void Create_TaskHasValidName()
    {
        var expectedSubject = "Important lead. Please do sales actions ASAP!";

        var service = GetFakedOrganizationService();
        CreateTaskWithRepository(service, expectedSubject);
        var taskCollection = GetTasks(service); 

        Assert.IsNotNull(taskCollection?.Entities?[0].Attributes);
        Assert.AreEqual(expectedSubject, taskCollection.Entities[0].Attributes["subject"]);
    }

    private IOrganizationService GetFakedOrganizationService()
    {
        var context = new XrmFakedContext();
        return context.GetOrganizationService();
    }

    private void CreateTaskWithRepository(IOrganizationService service, string subject)
    {
        var taskRepository = new TaskRepository();
        taskRepository.Initialize(GetServiceFactoryMockedObject(service), Guid.NewGuid());

        taskRepository.Create(new Task
        {
            Subject = subject,
            RegardingObjectId = new EntityReference(Lead.EntityLogicalName, Guid.NewGuid()),
            OwnerId = new EntityReference(Team.EntityLogicalName, Guid.NewGuid())
        });

        taskRepository.SaveChanges();
    }

    private EntityCollection GetTasks(IOrganizationService service)
    {
        return service.RetrieveMultiple(new QueryExpression() { EntityName = Task.EntityLogicalName, ColumnSet = new ColumnSet(true) });
    }
}

Powyższe testy do działania wykorzystują utworzony w pamięci za pomocą frameworka Fake Xrm Easy obiekt implementujący interfejs IOrganizationService. Testujemy w tym przypadku metodę Create z klasy TaskRepository. Powinna ona utworzyć w systemie rekord encji Task. Po zakończeniu działania wspomnianej metody pobieramy z systemu (ponownie wykorzystując do tego fałszywą instancję OrganizationService’u) kolekcję zadań i sprawdzamy, czy składa się ona dokładnie z 1 rekordu (test Create_SingleTaskCreated) oraz czy ma on właściwą, oczekiwaną przez nas nazwę (test Create_TaskHasValidName).

Kolejny prezentowany przykład pokazuje, w jaki sposób możemy przetestować metodę GetSalesTeam z klasy TeamRepository.

[TestClass]
public class TeamRepositoryTest : RepositoryTestBase
{
    [TestMethod]
    public void GetSalesTeam_TeamExists_ReturnTeam()
    {
        var context = new XrmFakedContext();
        context.ProxyTypesAssembly = Assembly.GetAssembly(typeof(Team));
        var team1 = new Team() { Id = Guid.NewGuid(), Name = "My super sales team" };
        var team2 = new Team() { Id = Guid.NewGuid(), Name = "Another not so super team" };

        context.Initialize(new List<Entity>() {
            team1, team2
        });

        var teamRepository = new TeamRepository();
        teamRepository.Initialize(GetServiceFactoryMockedObject(context), Guid.NewGuid()); 

        var salesTeam = teamRepository.GetSalesTeam();
        Assert.IsNotNull(salesTeam); 
    }

    [TestMethod]
    public void GetSalesTeam_TeamDoesNotExist_ReturnNull()
    {
        var context = new XrmFakedContext();
        context.ProxyTypesAssembly = Assembly.GetAssembly(typeof(Team));
        var team1 = new Team() { Id = Guid.NewGuid(), Name = "My super sales team not existing anymore" };
        var team2 = new Team() { Id = Guid.NewGuid(), Name = "Another not so super team" };

        context.Initialize(new List<Entity>() {
            team1, team2
        });

        var teamRepository = new TeamRepository();
        teamRepository.Initialize(GetServiceFactoryMockedObject(context), Guid.NewGuid());

        var salesTeam = teamRepository.GetSalesTeam();
        Assert.IsNull(salesTeam);
    }
}

Oczywiście nic nie stoi na przeszkodzie, aby do mockowania kontekstu uruchomieniowego oraz OrganizationService’u wykorzystać jeden z popularnych .NET-owych frameworków przeznaczonych do tego celu (np. Moq). Wykorzystanie dedykowanego dla CDS-u rozwiązania (np. prezentowanego Fake Xrm Easy lub innego) ułatwia jednak na tyle sprawę, że osobiście gorąco polecam jego stosowanie. W końcu, po co ponownie wymyślać koło?

Testy logiki biznesowej

[TestClass]
public class NewLeadServiceTest
{
   [TestMethod]
    public void TryCreateTaskForLargeEmployeesNumberLeads_NumberOfEmployeesOver30_TaskCreated()
    {
        var taskRepositoryMock = new TaskRepositoryMock(); 

        var repositoryFactoryMock = new Mock<IRepositoryFactory>();
        repositoryFactoryMock.Setup(pa => pa.Get<Team, ITeamRepository>()).Returns( () =>
        {
            //Team Repository stub
            Mock<ITeamRepository> teamRepositoryMock = new Mock<ITeamRepository>();
            teamRepositoryMock.Setup(s => s.GetSalesTeam()).Returns(new Team() { Id = Guid.NewGuid(), Name = "Sales team" });
            return teamRepositoryMock.Object; 
        });
        repositoryFactoryMock.Setup(pa => pa.Get<Common.Entities.Task, ITaskRepository>()).Returns(() => taskRepositoryMock);

        var service = new NewLeadService(repositoryFactoryMock.Object);
        service.TryCreateTaskForLargeEmployeesNumberLeads(new Lead() {NumberOfEmployees = 31});

        Assert.IsTrue(taskRepositoryMock.CreateExecuted);
        Assert.IsTrue(taskRepositoryMock.SaveChangesExecuted);
    }

    [TestMethod]
    public void TryCreateTaskForLargeEmployeesNumberLeads_NumberOfEmployeesBelow30_TaskNotCreated()
    {
        var taskRepositoryMock = new TaskRepositoryMock();

        var repositoryFactoryMock = new Mock<IRepositoryFactory>();
        repositoryFactoryMock.Setup(pa => pa.Get<Team, ITeamRepository>()).Returns(() =>
        {
            //Team Repository stub
            Mock<ITeamRepository> teamRepositoryMock = new Mock<ITeamRepository>();
            teamRepositoryMock.Setup(s => s.GetSalesTeam()).Returns(new Team() { Id = Guid.NewGuid(), Name = "Sales team" });
            return teamRepositoryMock.Object;
        });
        repositoryFactoryMock.Setup(pa => pa.Get<Common.Entities.Task, ITaskRepository>()).Returns(() => taskRepositoryMock);

        var service = new NewLeadService(repositoryFactoryMock.Object);
        service.TryCreateTaskForLargeEmployeesNumberLeads(new Lead() { NumberOfEmployees = 29 });

        Assert.IsFalse(taskRepositoryMock.CreateExecuted);
        Assert.IsFalse(taskRepositoryMock.SaveChangesExecuted);
    }

    [TestMethod]
    public void TryCreateTaskForLargeEmployeesNumberLeads_SalesTeamDoesNotExist_TaskNotCreated()
    {
        var taskRepositoryMock = new TaskRepositoryMock();

        var repositoryFactoryMock = new Mock<IRepositoryFactory>();
        repositoryFactoryMock.Setup(pa => pa.Get<Team, ITeamRepository>()).Returns(() =>
        {
            //Team Repository stub
            Mock<ITeamRepository> teamRepositoryMock = new Mock<ITeamRepository>();
            teamRepositoryMock.Setup(s => s.GetSalesTeam()).Returns(() => null); 
            return teamRepositoryMock.Object;
        });
        repositoryFactoryMock.Setup(pa => pa.Get<Common.Entities.Task, ITaskRepository>()).Returns(() => taskRepositoryMock);

        var service = new NewLeadService(repositoryFactoryMock.Object);
        service.TryCreateTaskForLargeEmployeesNumberLeads(new Lead() { NumberOfEmployees = 31 });

        Assert.IsFalse(taskRepositoryMock.CreateExecuted);
        Assert.IsFalse(taskRepositoryMock.SaveChangesExecuted);
    }
}

W przypadku testowania klas z warstwy BusinessLogic jesteśmy zainteresowani testowaniem samej logiki. Nie chcemy zastanawiać się nad tym, czy nasze repozytoria działają prawidłowo (w końcu, dlatego sprawdzamy ich działanie w oddzielnych testach jednostkowych) oraz czy zwracane dane są poprawne. Interesuje nasz tylko implementacja logiki reprezentującej proces biznesowy. Testy prezentowane powyżej korzystają z obiektu klasy TaskRepositoryMock, który reprezentuje „fałszywe” repozytorium umieszczone w pamięci oraz obiektu „zaślepki” (stub) implementującej interfejs ITeamRepository utworzonej za pomocą biblioteki Moq. Sprawdzają one, czy w zależności od przebiegu wykonania kodu w różnych przypadkach – metody obiektu klasy TaskRepositoryMock zostały uruchomione.

Implementacja klasy TaskRepositoryMock może wyglądać następująco:

//Task repository mock
private class TaskRepositoryMock : ITaskRepository
{
    public bool CreateExecuted { get; private set; }
    public bool SaveChangesExecuted { get; private set; }

    public void Create(Common.Entities.Task entity)
    {
        CreateExecuted = true;
    }

    public void SaveChanges()
    {
       SaveChangesExecuted = true;
    }
}

Automatyczne testy rozszerzeń

Dochodzimy w tym miejscu do testów warstwy wykonania kodu. W tym przypadku do przetestowania mamy zazwyczaj 2 metody. Pierwsza grupa testów (uruchamiająca metodę: IsContextValid) sprawdza, czy kod rozszerzenia zostanie uruchomiony w zależności od zadanych warunków wejściowych:

[TestClass]
public class HandleImportantLeadPluginTest
{
    [TestMethod]
    public void IsValid_ValidContext_ReturnsTrue()
    {
        Mock<IPluginExecutionContext> pluginContextMock = new Mock<IPluginExecutionContext>();
        pluginContextMock.SetupGet(c => c.PrimaryEntityName).Returns(Lead.EntityLogicalName);
        pluginContextMock.SetupGet(c => c.MessageName).Returns("Create");

        var plugin = new HandleImportantLeadPlugin(String.Empty, String.Empty);
        var isValid = plugin.IsContextValid(pluginContextMock.Object);
        Assert.IsTrue(isValid); 
    }

    [TestMethod]
    public void IsValid_InvalidEntity_ReturnsFalse()
    {
        Mock<IPluginExecutionContext> pluginContextMock = new Mock<IPluginExecutionContext>();
        pluginContextMock.SetupGet(c => c.PrimaryEntityName).Returns(Account.EntityLogicalName);
        pluginContextMock.SetupGet(c => c.MessageName).Returns("Create");

        var plugin = new HandleImportantLeadPlugin(String.Empty, String.Empty);
        var isValid = plugin.IsContextValid(pluginContextMock.Object);
        Assert.IsFalse(isValid);
    }

    [TestMethod]
    public void IsValid_InvalidMessage_ReturnsFalse()
    {
        Mock<IPluginExecutionContext> pluginContextMock = new Mock<IPluginExecutionContext>();
        pluginContextMock.SetupGet(c => c.PrimaryEntityName).Returns(Lead.EntityLogicalName);
        pluginContextMock.SetupGet(c => c.MessageName).Returns("Delete");

        var plugin = new HandleImportantLeadPlugin(String.Empty, String.Empty);
        var isValid = plugin.IsContextValid(pluginContextMock.Object);
        Assert.IsFalse(isValid);
    }
}

Powyższa grupa testów weryfikuje wartość zwracaną przez metodę IsValid w zależności od zawartości przekazywanego do niej kontekstu. Rzeczywistą instancję tego ostatniego zastępujemy zaślepka utworzona za pomocą frameworka Moq.

Kolejny test weryfikuje, czy metoda TryCreateTaskForLargeEmployeesNumberLeads została uruchomiona w czasie wykonywania rozszerzenia. Na początku rejestrujemy za pomocą kontenera DI zaślepkę fabryki serwisów zwracającą obiekt klasy NewLeadServiceMock (zamiast NewLeadService jak przy faktycznym uruchomieniu pluginu). Następnie konfigurujemy obiekt reprezentujący kontekst, który przekazujemy wraz z kontenerem do metody Execute rozszerzenia. Na końcu sprawdzamy, czy odpowiednia metoda z klasy implementującej interfejs INewServiceMock została uruchomiona.

public void Execute_ExecuteRequiredMethods() 
{
    var newLeadServiceMock = new NewLeadServiceMock(); 
    Mock<IServicesFactory> servicesFactory = new Mock<IServicesFactory>();
    servicesFactory.Setup(f => f.Get<INewLeadService>()).Returns(newLeadServiceMock); 

    var container = new Container();
    container.Register<IServicesFactory>(() => servicesFactory.Object); 

    Mock<IPluginExecutionContext> pluginContextMock = new Mock<IPluginExecutionContext>();
    pluginContextMock.SetupGet(c => c.PrimaryEntityName).Returns(Lead.EntityLogicalName);
    pluginContextMock.SetupGet(c => c.MessageName).Returns("Create");
    pluginContextMock.SetupGet(c => c.InputParameters).Returns(new ParameterCollection()
    {
        new KeyValuePair<string, object>(PluginBase.TargetAttributeName, new Lead())
    }); 

    var plugin = new HandleImportantLeadPlugin(String.Empty, String.Empty);
    plugin.Execute(pluginContextMock.Object, container);

    Assert.IsTrue(newLeadServiceMock.TryCreateTaskForLargeEmployeesNumberLeadsExecuted); 
}
private class NewLeadServiceMock : INewLeadService
{
    public bool TryCreateTaskForLargeEmployeesNumberLeadsExecuted { get; private set; }

    public void TryCreateTaskForLargeEmployeesNumberLeads(Lead lead)
    {
        TryCreateTaskForLargeEmployeesNumberLeadsExecuted = true; 
    }
}

W naszym scenariuszu testowanie kodu prezentowanego rozszerzenia może wydawać się nadmiarową czynnością (zerowa złożoność cyklometryczna metody Execute i brak możliwości, by testowany cel nie został osiągnięty, innej niż wystąpienie nieoczekiwanego wyjątku). Pamiętajcie jednak, że celem tego artykułu nie jest omawianie rzeczywistych przypadków, ale prezentacja technik oraz sposobów testowania kodu uruchamianego na platformie Common Data Service i wykorzystującego architekturę wielowarstwową.

Na koniec szybka „polecanka”. Wszystkim osobom, zainteresowanym pisaniem testów jednostkowych na platformie .NET, gorąco polecam książkę „The art of Unit Testing” napisaną przez Roya Osherove. Wspomniana pozycja będzie przydatna zarówno dla programistów, architektów, a także dla osób decyzyjnych, które chciałyby wprowadzić automatyzację testowania w swoich projektach (i np. szukają w tym celu argumentów do dyskusji ze sponsorami 😊). Książka jest dostępna na Amazonie i w wielu innych internetowych sklepach.

To byłoby wszystko na dzisiaj. Pełen kod rozszerzeń oraz ich testów, które zostały zaprezentowanych w tym artykule, znajdziecie pod adresem: https://github.com/gashupl/dyn365devbestpractices/tree/master/XrmLabs.Blog.Dyn365BestPractices/Chapter05

Total Views: 2064 ,
This Article Has 1 Comment
  1. Pingback: dotnetomaniak.pl

Comments are now closed.