Os testes de integração desempenham um papel crucial no processo de desenvolvimento de software com .NET. Eles visam verificar a interação e a integridade do sistema na totalidade, garantindo que os diferentes componentes funcionem harmoniosamente em conjunto. Ao contrário dos testes unitários, que se concentram em verificar unidades de código isoladas, os testes de integração avaliam a interação entre os diversos elementos do sistema, como serviços externos, bancos de dados, recursos de rede e outros. Esses testes são escritos utilizando ferramentas de testes como xUnit, NUnit ou MSTest, permitindo simular cenários reais e validar o comportamento correto do sistema em diferentes situações. Ao automatizar e incorporar esses testes em pipelines de integração contínua, os desenvolvedores podem garantir a qualidade contínua do software e identificar rapidamente problemas de integração, garantindo a estabilidade e confiabilidade do sistema em produção.

No post de hoje iremos criar uma web api em .NET, que irá fazer um disparo de e-mail após um endpoint ser chamado. Utilizaremos o mailhog para ser o servidor local de e-mails e utilizaremos o xUnit como ferramenta para os testes de integração.

Vamos inicialmente criar um diretório raiz para nossa solução.

mkdir AzureDevbOpsIntegrationTesting

Após o diretório ter sido criado, iremos criar uma solution chamada App.Api e um projeto de mesmo nome.

dotnet new sln --name App.Api
dotnet new webapi -n App.Api

Devemos adicionar o projeto à solution.

dotnet sln App.Api.sln add App.Api/App.Api.csproj

Para testar o projeto localmente, vamos criar um docker compose com apenas um container definido. Esse arquivo deve estar no diretório raiz e se chamará docker-compose.yaml.

version: "3.8"
services:
  mailHog:
    container_name: "smtpmailhog"
    ports:
      - "8025:8025"
      - "1025:1025"
    image: "mailhog/mailhog:latest"

Este projeto possuirá apenas um endpoint, que irá receber uma instância da classe User e fará o envio de um e-mail (para um endereço fixo) com os valores das propriedades desse usuário.

Temos a classe User:

namespace App.Api.Models;
public class User
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }
    public int Age { get; private set; }

    public User(string name, int age)
    {
        Id = Guid.NewGuid();
        Name = name;
        Age = age;
    }
}

Com as dependências e a classe User prontas, podemos adicionar o seguinte pacote NuGet ao projeto App.Api:

dotnet add package RazorEngineCore --version 2022.8.1

Esse é um pacote que utilizaremos para poder gerar HTML por meio de arquivos razor (.cshtml). Utilizaremos essa ferramenta para podermos criar os corpos dos e-mails que serão enviados pela nossa aplicação.

Vamos criar o serviço ITemplateService e sua implementação:

namespace  App.Api.Services.Template;

public interface ITemplateService
{
   public string GetTemplateAsString(string templateName, object model);
}
namespace  App.Api.Services.Template;
using RazorEngineCore;

public class TemplateService : ITemplateService
{
       public string GetTemplateAsString(string templateName, object model)
       {
              var path = Path.Combine(System.AppDomain.CurrentDomain.BaseDirectory, templateName);
            
              IRazorEngine razorEngine = new RazorEngine();
              IRazorEngineCompiledTemplate template = razorEngine.Compile(File.ReadAllText(path));

            return template.Run(model);
       }
}

Com o serviço pronto, vamos agora criar o arquivo razor que possuirá o conteúdo do corpo do e-mail que será enviado. Esse arquivo se chamará UserCreatedTemplate.cshtml.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <style>
        .body {
            background-color: lightgray;
            padding: 30px 0px;
        }

        .content {
            width: 500px;
            margin: 0px auto;
            background-color: white;
            padding: 20px;
            border-radius: 10px;
        }

        .title {
            font-weight: bold;
        }

        .table {
            color: gray;
            border-collapse: collapse;
            width: 95%;
            margin: 0px auto;
        }

            .table tr {
                border-bottom: 1pt solid lightgray;
                height: 40px;
            }

            .table td {
                text-align: left;
            }

                .table td:last-child {
                    text-align: right;
                }
    </style>
</head>
<body>
    <main>
        <div class="body">
            <div class="content">
                <p class="title">@Model.Title</p>

                <table class="table">
                    <tbody>
                        <tr>
                            <td>&nbsp;User Id</td>
                            <td>&nbsp;@Model.Id</td>
                        </tr>
                        <tr>
                            <td>&nbsp;User Name</td>
                            <td>&nbsp;@Model.Name</td>
                        </tr>
                        <tr>
                            <td>&nbsp;User Age</td>
                            <td>&nbsp;@Model.Age</td>
                        </tr>
                        <tr>
                            <td>&nbsp;Created At</td>
                            <td>&nbsp;@Model.CreatedAt</td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>
    </main>
</body>
</html>

Devemos adicionar o seguinte trecho de código ao arquivo App.Api.csproj:

  <ItemGroup>
    <Content Update="Services\Template\Templates\UserCreatedTemplate.cshtml">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Content>
  </ItemGroup>

O serviço para criação dos templates está pronto. É hora de implementarmos o serviço IEmailService e sua implementação.

namespace App.Api.Services.Email;

public interface IEmailService
{
    public Task SendAsync(string to, string subject, string htmlBody);
}
using System.Net;
using System.Net.Mail;

namespace App.Api.Services.Email;
class EmailService : IEmailService
{
    private readonly IConfiguration _configuration;

    public EmailService(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    public async Task SendAsync(string to, string subject, string htmlBody)
    {
        var host = _configuration.GetSection("Application:StmpHost").Value;
        var userName = _configuration.GetSection("Application:StmpUserName").Value;
        var password = _configuration.GetSection("Application:StmpPassword").Value;
        var from = _configuration.GetSection("Application:StmpSenderAddress").Value;
        var port = Convert.ToInt32(_configuration.GetSection("Application:StmpPort").Value);
        var enableSsl = Convert.ToBoolean(_configuration.GetSection("Application:StmpEnableSsl").Value);

        var smtpClient = new SmtpClient(host)
        {
            Port = port,
            Credentials = new NetworkCredential(userName, password),
            EnableSsl = enableSsl,
        };

        var mailMessage = new MailMessage
        {
            From = new MailAddress(from!),
            Subject = subject,
            Body = htmlBody,
            IsBodyHtml = true,
        };

        mailMessage.To.Add(to);

        await smtpClient.SendMailAsync(mailMessage);
    }
}

Para abstrairmos a forma com que o corpo em HTML é gerado e o e-mail é enviado, criaremos outro serviço que servirá como um facade. Recomendo a leitura desse post sobre o facade design pattern!

using App.Api.Models;

namespace App.Api.Services.ApplicationEmail;

public interface IApplicationEmailService
{
    public Task SendUserCreatedEmail(string to, User user);
}
using App.Api.Models;
using App.Api.Services.Email;
using App.Api.Services.Template;

namespace App.Api.Services.ApplicationEmail;

// This is a facade service
public class ApplicationEmailService : IApplicationEmailService
{
    private readonly IEmailService _emailService;
    private readonly ITemplateService _templateService;
    
    public ApplicationEmailService(IEmailService emailService, ITemplateService templateService)
    {
        _emailService = emailService;
        _templateService = templateService;
    }

    public async Task SendUserCreatedEmail(string to, User user)
    {
        var subject = this.CreateEmailSubject(user);
        var htmlBody = this.CreateEmailBody(user);

        await _emailService.SendAsync(to, subject, htmlBody);
    }

    private string CreateEmailBody(User user)
    {
        var template = Path.Combine("Services", "Template", "Templates","UserCreatedTemplate.cshtml");

        return _templateService.GetTemplateAsString(template, new
            {
                Title = $"New User - {user.Name}",
                Id = user.Id,
                Name = user.Name,
                Age = user.Age,
                CreatedAt = DateTime.UtcNow.ToString("dd/MM/yyyy - hh:mm")
            });
    }

    private string CreateEmailSubject(User user)
    {
        return $"New User - {user.Name}";
    }
}

Todos os serviços foram criados. É hora de criarmos o endpoint dentro controladora UserController.cs.

using App.Api.Models;
using App.Api.Services.ApplicationEmail;
using Microsoft.AspNetCore.Mvc;

namespace App.Api.Controllers;

[ApiController]
[Route("[controller]")]
public class UserController : ControllerBase
{
    private readonly IApplicationEmailService _applicationEmailService;

    public UserController(IApplicationEmailService applicationEmailService)
    {
        _applicationEmailService = applicationEmailService;
    }

    [HttpPost(Name = "AddUser")]
    public async Task<IActionResult> Post(User user)
    {
        // ...
        // Do some things
        // ...

        // Send email to admin
        var adminEmail = "test@tests.com";
        await _applicationEmailService.SendUserCreatedEmail(adminEmail, user);

        return Ok();
    }
}

Observe que os serviços são injetados via dependency injection, tanto em outros serviços, quanto na controladora acima.

Precisamos ir ao arquivo Program.cs e configurar a injeção de dependência dos serviços criados.

builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<ITemplateService, TemplateService>();
builder.Services.AddScoped<IApplicationEmailService, ApplicationEmailService>();

Além disso, vamos adicionar algumas configurações ao appsettings.json:

  "Application": {
    "StmpHost": "localhost",
    "StmpPort": "1025",
    "StmpEnableSsl": "false",
    "StmpSenderAddress": "test@mailhog.local",
    "StmpUserName": "user",
    "StmpPassword": "password"
  },

Finalizamos o desenvolvimento da web api! Temos a visão geral dos aquivos criados:

Vamos testar o projeto localmente. Vamos levantar todas as dependências por meio do arquivo docker-compose.yaml e executar o projeto com o comando dotnet run.

docker compose up -d
dotnet run

Vamos fazer um POST através da interface do swagger.

Após a execução da chamada, podemos acessar a interface do mailhog e verificar que o email foi enviado corretamente.

http://localhost:8025

Tudo ocorreu conforme o planejado! É hora de criarmos o projeto de testes.

Iremos criar um projeto de testes (xUnit) chamado App.IntegrationTests no diretório raiz da nossa solução.

dotnet new xunit -n App.IntegrationTests

Além disso, precisamos adicionar esse novo projeto de testes à solution e adicionar a referência de App.Api ao App.IntegrationTests:

dotnet sln App.Api.sln add App.IntegrationTests/App.IntegrationTests.csproj 
dotnet add App.IntegrationTests/App.IntegrationTests.csproj reference App.Api/App.Api.csproj  

Feito isso, já podemos adicionar os seguintes pacotes NuGet ao projeto de tests:

dotnet add package Microsoft.AspNetCore.Mvc.Testing --version 7.0.8
dotnet add package FluentAssertions --version 6.11.0
dotnet add package Microsoft.Extensions.DependencyInjection --version 7.0.0

Precisaremos ainda adicionar uma linha de código a classe Program.cs do projeto App.Api.

public partial class Program { }

Para verificarmos que os e-mails foram enviados ao mailhog corretamente, vamos criar um serviço local (no projeto App.IntegrationTests) chamado ILocalEmailServerService.

using App.IntegrationTests.Services.Models;

namespace App.IntegrationTests.Services
{
    public interface ILocalEmailServerService
    {
        public Task<EmailsInfo> GetEmailsInfos();
        public Task DeletAllAsync();
    }
}
using System.Text.Json;
using App.IntegrationTests.Services.Models;

namespace App.IntegrationTests.Services
{
    public class LocalEmailServerService : ILocalEmailServerService
    {
        private readonly HttpClient _client;

        public LocalEmailServerService()
        {
            _client = new HttpClient();
            _client.BaseAddress = new Uri("http://localhost:8025");
        }

        public async Task<EmailsInfo> GetEmailsInfos()
        {
            var response = await this.GetAllEmailsAsync();

            return new EmailsInfo
            {
                Total = response.Total,
                Subjects = response.Items!
                    .SelectMany(x => x.Content!.Headers!.Subject!)
            };
        }


        public async Task DeletAllAsync()
        {
            await _client.DeleteAsync("/api/v1/messages");
        }

        private async Task<Response> GetAllEmailsAsync()
        {
            var resultString = await _client.GetStringAsync("/api/v2/messages?limit=50");
            var result = JsonSerializer.Deserialize<Response>(resultString)!;
            return result;
        }        
    }
}

Temos as classes auxiliares necessárias para o serviço acima funcionar corretamente:

using System.Text.Json.Serialization;

namespace App.IntegrationTests.Services.Models
{
	public class Response
    {
        [JsonPropertyName("total")]
        public int Total { get; set; }

        [JsonPropertyName("count")]
        public int Count { get; set; }

        [JsonPropertyName("Start")]
        public int start { get; set; }

        [JsonPropertyName("items")]
        public List<Item>? Items { get; set; }
    }

    public class Item
    {
        public string? ID { get; set; }

        public Content? Content { get; set; }
    }

    public class Headers
    {
        public List<string>? Subject { get; set; }
    }

    public class Content
    {
        public Headers? Headers { get; set; }
        public string? Body { get; set; }
        public int Size { get; set; }
        public object? MIME { get; set; }
    }
}

namespace App.IntegrationTests.Services.Models
{
	public class EmailsInfo
	{
        public int Total { get; set; }
        public IEnumerable<string> Subjects { get; set; } = default!;
    }
}

Precisamos implementar a classe CustomWebApplicationFactory que herda de WebApplicationFactory.

using App.IntegrationTests.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;

namespace App.IntegrationTests
{
	public class CustomWebApplicationFactory<TProgram>
    : WebApplicationFactory<TProgram> where TProgram : class

    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services =>
            {
                services.AddSingleton<ILocalEmailServerService, LocalEmailServerService>();
            });

            builder.UseEnvironment("Development");
        }
    }
}

Vamos criar uma classe base para os testes de integração das controladoras (nesse caso, temos apenas uma controladora).

using System;
using App.IntegrationTests.Services;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;

namespace App.IntegrationTests
{
	public abstract class BaseTestingController<T> :
        IAsyncLifetime,
        IClassFixture<CustomWebApplicationFactory<T>> where T : class

    {
        protected readonly HttpClient _client;
        protected readonly CustomWebApplicationFactory<T> _factory;
        private IServiceScope? _serviceScope;

        public BaseTestingController(
            CustomWebApplicationFactory<T> factory)
        {
            _factory = factory;
            _client = factory.CreateClient(new WebApplicationFactoryClientOptions
            {
                AllowAutoRedirect = false
            });
        }

        public W GetRequiredService<W>() where W : class
        {
            if (_serviceScope is null)
            {
                var scope = _factory.Services.CreateScope();
                _serviceScope = scope;
            }

            return _serviceScope.ServiceProvider.GetRequiredService<W>();
        }

        public async Task ReinitializeEmailServerForTestsAsync()
        {
            var localEmailServerService = GetRequiredService<ILocalEmailServerService>();
            await localEmailServerService.DeletAllAsync();
        }

        public async Task DisposeAsync()
        {
            await ReinitializeEmailServerForTestsAsync();
            _serviceScope?.Dispose();
        }

        public async Task InitializeAsync()
        {
            await ReinitializeEmailServerForTestsAsync();
        }
    }
}

Observe que tanto ao inicializar quanto ao finalizar um teste específico, estamos chamando ReinitializeEmailServerForTestsAsync(), limpando a caixa de e-mails do servidor local. Garantindo assim, que todo teste será executado sem nenhum
"lixo"remanescente de outros testes.

Vamos criar a classe UserControllerTests.cs que herda de BaseTestingController. Essa classe possuirá apenas um teste de integração que realiza as seguintes tarefas:

  • Realiza um POST no endpoint "/User";
  • Verifica que a resposta da chamada foi OK;
  • Verifica que um, e apenas um, e-mail foi enviado e que esse e-mail possui o Subject esperado.
using System.Text;
using System.Text.Json;
using App.Api.Models;
using App.IntegrationTests.Services;
using FluentAssertions;

namespace App.IntegrationTests;

public class UserControllerTests : BaseTestingController<Program>
{
    public UserControllerTests(CustomWebApplicationFactory<Program> factory) : base(factory)
    {
    }

    [Fact]
    public async Task Post_WithValidUser_ShouldReturnOkResultAndSendEmail()
    {
        // Arrange
        var localEmailServerService = GetRequiredService<ILocalEmailServerService>();

        var url = "/User";
        var user = new User("Talles Valiatti", 29);

        var content = new StringContent(JsonSerializer.Serialize(user), Encoding.UTF8, "application/json");

        var expectedEmailSubject = $"New User - {user.Name}";
        var expectedEmailCount = 1;

        // Act
        var response = await _client.PostAsync(url, content);

        // Assert
        var emailsInfo = await localEmailServerService.GetEmailsInfos();
        response.EnsureSuccessStatusCode();

        emailsInfo.Total.Should().Be(expectedEmailCount);
        emailsInfo.Subjects.Should().AllBe(expectedEmailSubject);
    }

}

Temos a disposição final dos arquivos do projeto de testes de integração.

Vamos executar e seguinte comando no projeto de testes e verificar que tudo está correto:

dotnet test

Chegamos a parte final do nosso post!

Precisaremos criar um repositório no Azure DevOps e levar toda a nossa solução para lá. Recomendo a leitura da documentação oficial.

No diretório raiz do nosso projeto vamos criar o arquivo azure-pipelines.yml com o seguinte conteúdo:

trigger:
- master

pool:
  vmImage: 'ubuntu-latest'

variables:
  buildConfiguration: 'Release'

steps:
- script: |
    docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
  displayName: 'Start mailhog'
    
- task: UseDotNet@2
  inputs:
    version: '7.x'
  
- task: DotNetCoreCLI@2
  displayName: 'dotnet build'
  inputs:
    command: 'build'
    projects: '**/*.csproj'
  
- task: DotNetCoreCLI@2
  displayName: 'dotnet test'
  inputs:
    command: 'test'
    projects: 'App.IntegrationTests/App.IntegrationTests.csproj'
    arguments: '--configuration $(buildConfiguration) --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura'
  
- task: PublishCodeCoverageResults@1
  displayName: 'Publish code coverage report'
  inputs:
    codeCoverageTool: 'Cobertura'
    summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml'

- task: DotNetCoreCLI@2
  displayName: 'Create Artifact'
- task: DotNetCoreCLI@2
  inputs:
    command: publish
    publishWebProjects: True
    arguments: '--configuration $(BuildConfiguration) --output $(Build.ArtifactStagingDirectory)'
    zipAfterPublish: True
    projects: 'App.Api'

- task: PublishPipelineArtifact@1
  displayName: 'Publish Artifact'
  inputs:
    targetPath: '$(Build.ArtifactStagingDirectory)' 
    artifactName: 'App.Api'

Esse pipeline realiza as seguintes tarefas:

  • Criar um container do mailhog;
  • Configura o .NET 7 como sdk padrão;
  • Faz o build da solução;
  • Executa o teste de integração e salva o resultado;
  • Exporta o resultado dos testes de integração;
  • Exporta um arquivo Zip com os executáveis do projeto App.Api. Isso apenas é realizado caso todos os testes sejam realizados com sucesso.

Com o pipeline pronto, podemos subir essa alteração. Além disso, precisamos criar um pipeline. Veja a documentação oficial!

Com o pipeline pronto, podemos executá-lo.

Observe que tudo ocorreu sem falhas, onde todos os testes passaram (apenas um) e ao final do processo, foram gerados os resultados dos testes e um artefato com o zip dos executáveis do projeto App.Api.

Você já pode baixar o projeto por esse link, e não esquece de me seguir no LinkedIn!

Até a próxima, abraços!

💡
Podemos te ajudar com uma revisão 100% gratuita do seu ambiente cloud.
Share this post