.Net core + NUnit testujeme a mockujeme

Mockování je důležitým konceptem při psaní jednotkových testů z několika důvodů:

  1. Izolace od závislostí: Mockování umožňuje oddělit testovanou třídu od jejích závislostí. To znamená, že při testování konkrétní třídy nemusíte brát v potaz implementaci jejích závislostí. Místo toho můžete vytvořit mock objekty, které simulují chování těchto závislostí.
  2. Opakovatelnost a konzistence: Při použití skutečných závislostí, jako jsou databáze nebo externí API, mohou testy být nekonzistentní a záviset na stavu těchto závislostí. Použití mocků umožňuje vytvořit konzistentní a opakovatelné testy, které nejsou závislé na externích faktorech.
  3. Rychlost testů: Skutečné závislosti mohou být pomalé nebo nedostupné v určitých situacích, což může zpomalit běh testů. Použití mocků umožňuje rychlejší spouštění testů, protože mocky jsou obvykle rychlejší než skutečné závislosti.
  4. Izolace chyb: Pokud test selže, mocky vám umožňují izolovat problém na konkrétní část kódu, kterou testujete. Bez mocků by selhání mohlo být způsobeno problémem v externí závislosti, což by mohlo být složité odhalit.
  5. Testování hraničních podmínek a chybových stavů: Mocky umožňují snadno simulovat různé situace, včetně chybových stavů, které mohou být obtížné dosáhnout s reálnými závislostmi.
  6. Zvýšení rychlosti vývoje: Testy s mocky umožňují rychlejší iterace při vývoji. Můžete psát testy pro nový kód, i když jeho závislosti nejsou ještě implementovány.
  7. Snížení nákladů na infrastrukturu: Pokud byste všechny testy spouštěli s reálnými závislostmi, mohlo by to vyžadovat drahou infrastrukturu, například databázový server. Mockování umožňuje testování bez skutečné infrastruktury.

Celkově mockování je důležitou technikou pro vytváření izolovaných a efektivních jednotkových testů, které vám pomáhají zajistit kvalitu kódu a snižovat rizika chyb.

Instalujeme Moq

Abychom mohli začít mockovat doinstalujeme Nuget do projektu s testy:

Install-Package Moq

Kód pro otestování

Mějme controller a service, které chceme otestovat. Service se do controlleri injectne přes DI:

ProductController.cs:

namespace WebAPiDi.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class TestController : ControllerBase
    {
        readonly IProductService _productService;

        public TestController(IProductService productService)
        {
            _productService = productService;
        }

        [HttpGet]
        public IActionResult Shop()
        {
            return Ok(_productService.Shop());
        }
    }
}

ProductService.cs:

namespace WebAPiDi.Services
{
    public class ProductService : IProductService
    {
        public IEnumerable<ProductDto> Shop()
        {
            return new List<ProductDto>()
            {
                new ProductDto() { Id = 1, Name = "Best product" }
            };
        }
    }
}

IProductService.cs:

namespace WebAPiDi.Services
{
    public interface IProductService
    {
        IEnumerable<ProductDto> Shop();
    }
}

productDto.cs:

namespace WebAPiDi.Dtos
{
    public class ProductDto
    {
        public int Id { get; set; }

        public required string Name { get; set; }
    }
}

Program.cs (konfigurace sávislostí):

builder.Services.AddScoped<IProductService, ProductService>();

Testujeme

V minulých dílech jsme si ukázali jak zprovoznit NUnit a Moq. Dnes už je jdeme rovnou používat.

Testujeme service

Začneme tím jednodušším – otestujme ProducService. Nepotřebujeme nic mockovat, pouze si uděláme instanci ProuctService a zavoláním metody Shop zkontrolujeme že vrátí 1 záznam, ve kterém zkontrolujeme vyplnění propert:

namespace Test.Services
{
    [TestFixture]
    public class ProductServiceTests
    {
        [Test]
        public void Shop_ReturnsListOfProducts()
        {
            // Arrange
            IProductService productService = new ProductService();

            // Act
            IEnumerable<ProductDto> products = productService.Shop();

            // Assert
            Assert.IsNotNull(products);
            Assert.IsInstanceOf<IEnumerable<ProductDto>>(products);
            Assert.That(products.Count(), Is.EqualTo(1));

            foreach (var product in products)
            {
                Assert.IsNotNull(product.Id);
                Assert.IsNotNull(product.Name);
            }
        }
    }
}

Při testování controlleru budeme muset použít mockování. Namockujeme se ProductService, čímž vytvoříme izovali od této service a můžeme si nasimulovat nejrůznější chování této service.

namespace Test.Controllers
{
    [TestFixture]
    public class TestControllerTests
    {
        [Test]
        public void Shop_ReturnsOkResultWithProducts()
        {
            // Arrange
            var productServiceMock = new Mock<IProductService>();
            productServiceMock.Setup(repo => repo.Shop()).Returns(new List<ProductDto>
            {
                new ProductDto() { Id = 68, Name = "Jarda Jagr" },
                new ProductDto() { Id = 88, Name = "pasta" }
            });

            var controller = new TestController(productServiceMock.Object);

            // Act
            var result = controller.Shop() as OkObjectResult;

            // Assert
            Assert.IsNotNull(result);
            Assert.That(result.StatusCode, Is.EqualTo(200));

            var products = result.Value as List<ProductDto>;
            Assert.IsNotNull(products);
            Assert.That(products.Count, Is.EqualTo(2));
        }

        [Test]
        public void Shop_ReturnsEmptyProducts()
        {
            // Arrange
            var productServiceMock = new Mock<IProductService>();
            productServiceMock.Setup(repo => repo.Shop()).Returns(new List<ProductDto>());

            var controller = new TestController(productServiceMock.Object);

            // Act
            var result = controller.Shop() as OkObjectResult;

            // Assert
            Assert.IsNotNull(result);
            Assert.That(result.StatusCode, Is.EqualTo(200));

            var products = result.Value as List<ProductDto>;
            Assert.IsNotNull(products);
            Assert.That(products.Count, Is.EqualTo(0));
        }
    }
}

Entity Framework Core – logování selectů a doby vykonávání

Časem narazíte na problémy pomalých odezev vašich aplikací. Budete chtít mít představu kdy a jak dlouho co trvalo – kdo neměří ten neřídí. Proto si dnes ukážeme jak logovat vygenerované SQL dotazy včetně jejich časi provedení. Entity Framework defaultně loguje příkazy, které vykonává, ovšem to dost často nestačí. Dnes si ukážeme jak logování rozšířit.

Zabudovaného logování v EF Core

Jedná se o základní jedochudou metodu. V Program.cs máte kód který přidává EF do DI:

//EF
builder.Services.AddDbContext<HulvatContext>(options =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection");
});

Tento kód stačí rozšířit o požadované logování:

//EF
builder.Services.AddDbContext<HulvatContext>(options =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))
    .UseLoggerFactory(LoggerFactory.Create(builder => builder.AddConsole()))
    .EnableSensitiveDataLogging();
});

Tím získáme SQL dotaz včetně doby vykonávání:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (10ms) [Parameters=[@__email_0='test@test.cz' (Size = 50)], CommandType='Text', CommandTimeout='30']
SELECT TOP(1) [u].[Id], [u].[DisplayName], [u].[Email], [u].[FisrtName], [u].[LastName] FROM [Users] AS [u]
WHERE [u].[Email] = @__email_0

Tímto způsobem budou SQL dotazy logovány do konzole. Můžete změnit cíl logování tím, že nahradíte AddConsole() za jiný logger.

EnableSensitiveDataLogging

Při logování SQL dotazů s citlivými daty (sensitive data), můžete použít metodu .EnableSensitiveDataLogging(). Tato metoda zajistí, že citlivá data, jako jsou hesla, nebudou logována.

.NET core – jak logovat s NLog

Nes si ukážeme jak zprovoznit logování přes knihovnu NLog. V této knihovně je možné udělat několik targertů a do každého targetu logovat různé informace. Například pouze chyby logovat do speciálního targetu a posílat si je například emailem, …

Instalace NLog

Install-Package NLog
Install-Package NLog.Web.AspNetCore

Konfigurace NLog

Vytvoříme soubor konfigurace pro NLog, který určuje, kam a jak mají být logy zpracovány. Vytvořte soubor s názvem „nlog.config“ ve vašem projektu (na úrovni souboru appsettings.json):

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  autoReload="true"
  internalLogLevel="info"
  internalLogFile="c:\\_temp\\internallog.txt">
  <extensions>
    <add assembly="NLog.Web.AspNetCore" />
  </extensions>
  <targets>
    <target name="logfile" xsi:type="File" fileName="c:\\_temp\\log.txt" layout="${longdate} ${level:uppercase=true} ${message}"/>
     <target xsi:type="ColoredConsole" name="logconsole" />
  </targets>
  <rules>
    <logger name="Microsoft.*" maxLevel="Info" final="true" />
    <logger name="*" minlevel="Info" writeTo="logfile" />
    <logger name="*" minlevel="Info" writeTo="logconsole" />
  </rules>
</nlog>

Tento konfigurační soubor nastaví logování do souboru „log.txt“ od úrovně „Info“ výše a zahrnuje všechny loggery (názvy hvězdičkou). Můžete dále upravit konfiguraci podle vašich potřeb. Osvědčilo se mi posálání chyb emailem.

Registrujeme NLog v aplikaci

Do souboru Program.cs přidáme:

var logger = NLog.LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();

//Nlog
builder.Logging.ClearProviders();
builder.Host.UseNLog();

Logujeme v aplikaci

Nyní máme 2 možnosti logování

Logování přes GetCurrentClassLogger

Definujeme a rovnou naplníme field logeru:

private static Logger _logger = LogManager.GetCurrentClassLogger();

který pak používáme:

_logger.Trace("Trace");
_logger.Debug("Debug");
_logger.Info("Info");
_logger.Warn("Warn");
_logger.Error("Error");

DI ILogger

Přes DI si injecteme logger:

private readonly ILogger _logger;

public UserService(ILogger<UserService> logger)
{

  _logger = logger;
}

Který pak používáme

_logger.LogTrace("Trace");
_logger.LogDebug("Debug");
_logger.LogInformation("Info");
_logger.LogWarning("Warn");
_logger.LogError("Error");

Entity framework – DI

V minulém díle jsme si ukázali jak rozject EF core nad MySQL databází – DB first dnes navážeme trochu obecne. Níže uvedené tipy je možno pužít pro libovolnou DB při přístupu DB first.

DBContext – odebrání connection stringu

Při defaultním generování database first je ve vygenerovaném DbContext souboru uložen connection string pro připojení do DB včetně citlivých údajů:

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see http://go.microsoft.com/fwlink/?LinkId=723263.
        => optionsBuilder.UseMySql("server=localhost;user=root;database=app_cz", Microsoft.EntityFrameworkCore.ServerVersion.Parse("5.7.17-mysql"));

Do zdrojového kódu nepatří žádné přístupové údaje, ty patří do konfiguračního souboru (appsettings.json) aby je bylo jednoduché kdykoliv změnit. Celou metodu protected override void OnConfiguring uvedenou výše tedy můžeme odstranit.

Do Program.cs přidáme:

//EF
// Replace with your server version and type.
// Use 'MariaDbServerVersion' for MariaDB.
// Alternatively, use 'ServerVersion.AutoDetect(connectionString)'.
// For common usages, see pull request #1233.
var serverVersion = new MySqlServerVersion(new Version(8, 0, 29));

// Replace 'YourDbContext' with the name of your own DbContext derived class.
builder.Services.AddDbContext<ComameEuContext>(
    dbContextOptions => dbContextOptions
        .UseMySql(builder.Configuration["ConnectionStrings:DefaultConnection"], serverVersion)
        // The following three options help with debugging, but should
        // be changed or removed for production.
        .LogTo(Console.WriteLine, LogLevel.Information)
        .EnableSensitiveDataLogging()
        .EnableDetailedErrors());

Pro případný Microsoft SQL Server je konfigurace trochu elegantnější:

//EF
builder.Services.AddDbContext(options =>
{
   options.UseSqlServer(builder.Configuration["ConnectionStrings:DefaultConnection"]);
});

A connectionstring uložíme do konfiguračního souboru appsettings.json:

{
  "ConnectionStrings": {
    "DefaultConnection": "server=localhost;user=root;database=app_cz"
  },
  "Logging": {
...

DI pro použití DbContext

Nyní zbývá dořešit poslední problém z minulé ukázky. DbContext je nejlepší injectount na požadované místo použití.

public class MyService
{
    private readonly AppCzContext _dbContext;

    public MyService(AppCzContext dbContext)
    {
        _dbContext = dbContext;
    }

    public IEnumerable<User> GetUsers()
    {
        return _dbContext.Users.ToList();
    }
}

Asynchronní dotazy

Další a dnešní poslední tip je pro používání asynchronních dotazů. Asynchronními dotazy zvyšujete výkon a odezvu vaší aplikace, efektivněji využíváte prostředky a minimalizujete blokování vláken. Takže příště už jen takto:

public async Task<IEnumerable<User>> GetUsersAsync()
{
    return await _dbContext.Users.ToListAsync();
}