DEV Community

Cover image for Cell CMS - Organizando, reutilizando e testando consultas do EntityFramework Core
Rodolpho Alves
Rodolpho Alves

Posted on

Cell CMS - Organizando, reutilizando e testando consultas do EntityFramework Core

Cell CMS - Organizando, reutilizando e testando consultas do EntityFramework Core

Intro

No último post falamos sobre testes unitários e como tornar a escrita e manutenção deles prática. Mas também tocamos em um assunto controverso para alguns no universo .NET:

Devo utilizar EFCore.InMemory nos meus testes unitários ou devo abstrair uma UnitOfWork e repositórios?

A ideia do post de hoje é apresentar, brevemente, os dois lados e uma solução que apesar de não ser aderente à Orientação a Objeto é uma solução prática, testável e escalável.

Lembrando que boa parte dos snippets aqui estão implementados no Cell CMS, disponível lá no GitHub!

GitHub logo rodolphocastro / cell-cms

CMS leve, self-contained e prático de utilizar! Feito por desenvolvedores e para desenvolvedores!

Cell CMS

Branch Status Descrição
Master Build and Test Ciclo estável, recomendado para produção
Develop Build and Test Ciclo em desenvolvimento, recomendado para entusiastas

Cell CMS é um content management system que visa ser:

  • Leve
  • Auto Contido (self-contained)
  • Prático de Utilizar

Nosso foco é em disponibilizar um CMS que desenvolvedores possam facilmente referenciar em seus aplicativos, sites e sistemas.

📚 Instruções

Utilizando uma Versão publicada

WIP, iremos suportar imagens Docker e executáveis

Compilando

Você precisará ter instalado em seu ambiente o SDK 5.0.101 do Dotnet.

Uma vez configurado basta executar dotnet build .\cell-cms.sln na raiz do repositório.

Testando

Execute dotnet test .\cell-cms.sln na raiz do repositório.

Caso queira capturar informações de cobertura de testes utilize:

dotnet test --no-restore --collect:"XPlat Code Coverage" .\cell-cms.sln

Configurações

Autenticação/Autorização

O CellCMS utiliza o Azure Active Directory como provider de identidade, então você terá de configurar sua instância do AAD conforme explicado neste post.

As seguintes variáveis de ambiente…

Cover pega no unDraw.co

Motivação para Abstrair o EF Core

A principal motivação para abstrair o EFCore é simples:

É mais fácil fazer Mocks/Stubs de Interfaces, classes abstratas e métodos virtuais, portanto elas são mais testáveis

Devido à essa preocupação que o próprio projeto do EntityFramework Core criou o Provider InMemory! Afinal, como esperar que seu framework seja amplamente adotado por projetos de grande porte se tudo que utiliza ele deixa de ser testável?

Mas, vamos às abordagens práticas!

Abordagem "clássica" - UnitOfWork + Repositories

A UnitOfWork é um dos "Enterprise Patterns". A ideia é bem simples:

Você precisa de uma classe que faça a gestão centralizada do estado do banco de dados durante um processo de negócio.

Ou seja, é função da UnitOfWork:

  1. Prover acesso aos objects que estão armazenados nas tabelas do seu banco
  2. Prover mecanismos para salvar alterações (Commit)
  3. Prover mecanismos para reverter alterações (Rollback)

Normalmente junto à UnitOfWork vemos um segundo padrão chamado de Repository. A ideia de um Repository é:

Centralizar as consultas (Queries) a algum object, de maneira que possamos utilizar lógicas adicionais (como um Cache) em um único lugar e minimizar a quantidade de código replicado dentro do sistema.

Soa familiar com o que um DbSet<T> e um DbContext faz, não? Pois é! Mas chega de foreshadowing.

Como são patterns eles normalmente serão introduzidos como dependências da sua classes através de suas interfaces, normalmente chamadas:

  1. IUnitOfWork, IProjetoUnitOfWork
  2. IMeuObjetoRepository, GenericRepository<T>

Agora vamos pensar em suas vantagens e desvantagens, sempre considerando que estamos utilizando o EntityFramework Core como nosso ORM.

Caso você queira saber mais sobre os Enterprise Patterns: Dê uma olhada no livro Patterns of Enterprise Application Architecture do Martin Fowler

Vantagens

As principais vantagens são:

  1. Abstração: Permite que utilizemos Mocks e Stubs de maneira extremamente prática
  2. Amplamente conhecido: Esse pattern é quase tão conhecido como Singletons
  3. Mantém o padrão de Orientação a Objeto
  4. As interfaces podem ser exportadas facilmente para outros projetos: Se você quiser fazer uma reutilização máxima de código você poderia declarar um repositório no seu projeto de Domínio e deixar que os diferentes runtimes façam a implementação.

Para mim as principais vantagens são #1 e #4. Especialmente se pensarmos em utilizar uma abordagem que preza pela reutilização: A mesma interface pode ser implementada no frontend com acesso a uma API REST e no backend com acesso a um banco relacional!

Desvantagens

  1. Mais uma classe para Manutenção
  2. Difícil de adicionar novas funcionalidade: Como você deve alterar a assinatura da Interface (ou criar uma nova) cada nova query é um novo método
  3. Difícil de compor queries: Se você permite múltiplos filtros terá de ter métodos com inúmeros parâmetros!
  4. Utilização de generics orientados a Copy-Paste: Essa acho que é o maior downside que vi para a abordagem até hoje! Inúmeras vezes você vê o mesmo código colado em diferentes projetos para criar um GenericRepository<T> que alivia alguns dos problemas acima
  5. Quebra do próprio Pattern: Para tentar balancear com a nova dependência é comum ver Repositories que retornam DTOs, fazem alterações no banco, etc...

Para mim as principais desvantagens são as #1, #2 e #5. Eu mesmo já fiz repositories que fazem mais que consultar e já tive de quebrar interfaces por causa de um parâmetro a mais de filtro.

Exemplos

O seguinte snippet contém uma implementação (feita para exemplo apenas, bem incompleta!) de uma Unit of Work para o Cell CMS:

/// <summary>
/// Descreve métodos para uma UnitOfWork do CellCMS.
/// </summary>
public interface ICellCMSUnitOfWork
{
    IRepository<Feed> Feeds { get; }

    Task CommitChanges();
}

/// <summary>
/// Descreve métodos para um repositório.
/// </summary>
/// <typeparam name="T"></typeparam>
public interface IRepository<T> where T: class
{
    Task<IEnumerable<T>> ListAll();
}

/// <summary>
/// Implementação genérica de um repository.
/// </summary>
/// <typeparam name="T"></typeparam>
public class GenericRepository<T> : IRepository<T> where T : class
{
    private DbSet<T> _set;

    public GenericRepository(CellContext context)
    {
        _set = context.Set<T>();
    }
    public async Task<IEnumerable<T>> ListAll() => await _set.ToListAsync();
}

/// <summary>
/// Simulação de uma Unit of Work para o CELL CMS.
/// </summary>
public class CellCMSUnitOfWork : ICellCMSUnitOfWork
{
    private readonly CellContext _context;
    public IRepository<Feed> Feeds { get; private set; }
    public CellCMSUnitOfWork(CellContext context)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        Feeds = new GenericRepository<Feed>(context);
    }

    public Task CommitChanges() => _context.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

Abordagem funcional - Extension Methods

Primeiramente um disclaimer: Essa solução é boa para o universo .NET, onde temos suporte a Extension Methods.

Um extension method é um método que adiciona funcionalidade a uma classe/interface existente sem precisar alterar o código original.

Boa parte das operações LINQ são extension methods, assim como maior parte das opções do próprio EntityFrameworkCore! Sem acesso ao código podemos confiar no Intellisense para mostrar quando estamos lidando com um método que foi adicionado a um tipo por extension method, note o ícone diferente nesta imagem:

Extension Method icon on intellisense

Extension Methods 101

Mas Rodolpho, como posso fazer um extension method?

É mais simples do que parece! Você só precisa:

  1. De uma static class onde seu método será armazenado
  2. De acesso aos métodos do Tipo a ser estendido
  3. Criar um método static onde o primeiro parâmetro recebe a keyword especial this.

Por exemplo, os seguintes extension method permitem que eu serialize um objeto para JSON, recupere o mesmo objeto a partir de um JSON e realize uma cópia do objeto através de serialização:

Note que consegui estender um generic T e uma string e, nas últimas 3 linhas, eles são chamados como se fosse realmente parte do tipo Feed e string.

public static class MyExtensions
{
    /// <summary>
    /// Serializa um objeto para um JSON.
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="obj"></param>
    /// <returns></returns>
    public static string Dump<T>(this T obj) where T : new()
    {
        return JsonSerializer.Serialize(obj);
    }

    /// <summary>
    /// Restaura um objeto de a partir de um JSON.
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="t"></param>
    /// <returns></returns>
    public static T Restore<T>(this string t) where T : new()
    {
        return JsonSerializer.Deserialize<T>(t);
    }

    /// <summary>
    /// Clona um objeto.
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="from"></param>
    /// <returns></returns>
    public static T Clone<T>(this T from) where T : new()
    {
        return from.
            Dump().
            Restore<T>();
    }
}

// Usando os extension methods
var cobaia = new Feed();
var copy = cobaia.Clone();
Debug.Assert(copy != null);
Enter fullscreen mode Exit fullscreen mode

Como o Visual Studio mostraria que estou usando uma extension:

Utilizando um extension method com Intellisense

Vantagens

  1. Testável por unidade: Como fazemos extensão de um IQueryable<T> podemos testar através de uma List<T>
  2. Testável por integração: Novamente, como estendemos IQueryable<T> podemos testar através de um DbSet<T> do EFCore
  3. Armazenável próximo ao Model: Se você curte Clean Code, podemos deixar estas queries no mesmo arquivo que a classe do Model!
  4. Permite composição: Podemos chamar filtros após filtros conforme nossa necessidade, sempre precisar de um novo método para cada nova combinação de filtros
  5. Não duplica o pattern UoW + Repository em sua aplicação.

Todos são pontos bem legais, na minha opinião, mas vou dar uma ênfase no #5 e faço isso pois o próprio EntityFramework Core é uma implementação deste pattern!

O DbContext que herdamos é uma UnitOfWork de cara por nos permitir criar transações, realizar commits e rollbacks. E cada DbSet<T> também é repositório pois nos permite interagir com o objetos armazenados no banco!

A própria documentação do EntityFrameworkCore explica:

Uma instância do DbContext representa uma sessão com o banco de dados e pode ser utilizado para consultar e salvar instâncias de suas entidades. O DbContext é uma combinação dos patterns Unit Of Work e Repository. (tradução livre pelo autor)

Com base nisso podemos concluir que:

Uma UnitOfWork sobre um DbContext é uma abstração feita sobre uma abstração!

Desvantagens

  1. Menos reutilizável longe do EFCore: Como dependemos do IQueryable<T> fica mais complicado reutilizar essas queries em um mobile Xamarin, por exemplo
  2. Pouca familiaridade com extension methods: Apesar de estarem ai a anos nem todo desenvolvedor já teve de escrever seu próprio extension method
  3. Dependendo do namespace podem causar confusão: Extension methods são carregados por namespace de uma única vez. Você não consegue escolher quais métodos serão importados.

Exemplos

O seguinte snippet contém algumas das queries do Cell CMS que migrei para este padrão e alguns testes mostrando como elas podem ser utilizadas, como se comportam com os diferentes datasets e chains!

/// <summary>
/// Queries relacionadas a Feeds.
/// </summary>
public static class FeedQueries
{
    /// <summary>
    /// Obtem todos os feeds.
    /// </summary>
    /// <param name="feeds"></param>
    /// <returns></returns>
    public static IQueryable<Feed> AllFeeds(this IQueryable<Feed> feeds) => feeds;

    /// <summary>
    /// Filtra feeds com base no ID.
    /// </summary>
    /// <param name="feeds"></param>
    /// <param name="id"></param>
    /// <returns></returns>
    public static IQueryable<Feed> WithId(this IQueryable<Feed> feeds, int id)
    {
        return feeds
            .Where(f => f.Id.Equals(id));
    }

    /// <summary>
    /// Filtra feeds com base no Nome.
    /// </summary>
    /// <param name="feeds"></param>
    /// <param name="name"></param>
    /// <returns></returns>
    public static IQueryable<Feed> FilterByNome(this IQueryable<Feed> feeds, string name)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            return feeds;
        }

        return feeds
            .Where(f => f.Nome.Contains(name, System.StringComparison.InvariantCultureIgnoreCase));
    }
}

// Testes
[Theory, CreateData]
[Trait(TraitsConstants.Label.Name, TraitsConstants.Label.Values.Domain)]
public void WithNome_EmptyData_ReturnsEmpty(string nome)
{
    // Arrange
    var subject = _emptyResult.AsQueryable();

    // Act
    var result = subject
        .FilterByNome(nome);

    // Assert
    result.Should().BeEmpty();
}

[Theory, CreateData]
[Trait(TraitsConstants.Label.Name, TraitsConstants.Label.Values.Domain)]
public void WithNome_WithDataAndValidName_ReturnsData(IEnumerable<Feed> feeds, Feed extraFeed)
{
    // Arrange
    var nome = extraFeed.Nome;
    var subject = feeds
        .Concat(new List<Feed>() { extraFeed })
        .AsQueryable();

    // Act
    var result = subject
        .FilterByNome(nome);

    // Assert
    result.Should().NotBeEmpty();
}

[Theory, CreateData]
[Trait(TraitsConstants.Label.Name, TraitsConstants.Label.Values.Domain)]
public void WithNome_WithDataAndInvalidName_ReturnsData(IEnumerable<Feed> feeds, [Frozen] string fixedName)
{
    // Arrange
    var nome = $"{fixedName}{Guid.NewGuid()}";
    var subject = feeds.AsQueryable();

    // Act
    var result = subject
        .FilterByNome(nome);

    // Assert
    result.Should().BeEmpty();
}

[Theory, CreateData]
[Trait(TraitsConstants.Label.Name, TraitsConstants.Label.Values.Domain)]
public void WithIdAndNome_WithDataAndValidNameAndValidID_ReturnsData(IEnumerable<Feed> feeds, Feed extraFeed)
{
    // Arrange
    var nome = extraFeed.Nome;
    var id = extraFeed.Id;
    var subject = feeds
        .Concat(new List<Feed>() { extraFeed })
        .AsQueryable();

    // Act
    var result = subject
        .WithId(id)
        .FilterByNome(nome);

    // Assert
    result.Should().NotBeEmpty();
}
Enter fullscreen mode Exit fullscreen mode

Conclusão

Eu, pessoalmente, sempre utilizei o padrão UnitOfWork + Repository. Porém, ao descobrir que os extension methods poderiam me dar a mesma funcionalidade mas com um menor overhead passei a preferir extension methods.

Caso ainda não esteja convencido dê uma olhada nos testes e refactors feitos no Cell CMS, que agora está seguindo este padrão, para basear sua decisão!

No fundo essa decisão é uma questão de gosto pessoal. Eu prefiro ser pragmático e minimizar a quantidade de coisas que preciso dar manutenção e testar. Mas para seu projeto/equipe pode ser que o padrão UnitOfWork seja melhor aceito!

O post de hoje vai ficando por aqui, espero que tenha dado o que pensar! O próximo post provavelmente será sobre Analyzers ou Configuração de um Ambiente Windows para desenvolvimento!

Fiquem ligados para o próximo, obrigado por lerem e até então!

Discussion (0)