W tym artykule chciałbym przedstawić bibliotekę Marten oraz narzędzie pomocne do testowania bazy danych jakim jest Testcontainers. Marten to biblioteka, która utworzy nam funkcjonalność tranzakcyjnej document db w Postgresql. Testcontainers pozwala na tworzenie kontenerów docker m. in. dla Postgresql
Aby zaprezentować Marten utworzyłem bardzo proste api do CRUD obiektów Car
.
public class Car { public Guid Id { get; set; } public string Name { get; set; } = string.Empty; }
Marten i Testcontainers dodajemy np. poniższymi poleceniami
dotnet add package Marten dotnet add package Testcontainers dotnet add package Testcontainers.PostgreSql
Musimy też dodać serwis Marten:
builder.Services.AddMarten(options => { options.Connection(builder.Configuration.GetConnectionString("Default")!); options.AutoCreateSchemaObjects = AutoCreate.All; });
Repozytorium dla obiektów klas Car
wygląda tak jak poniżej:
namespace TestContainersDemo.Api; public interface ICarsRepository { Task<Car> AddAsync(Car car, CancellationToken ct); Task<Car> UpdateAsync(Car car, CancellationToken ct); Task DeleteAsync(Guid id, CancellationToken ct); Task<Car?> GetAsync(Guid id, CancellationToken ct); Task<IReadOnlyList<Car>> GetAllAsync(CancellationToken ct); }
using Marten; namespace TestContainersDemo.Api; public class CarsRepository : ICarsRepository { private readonly IDocumentStore _store; public CarsRepository(IDocumentStore store) { _store = store; } public async Task<Car> AddAsync(Car car, CancellationToken ct) { await using var session = _store.LightweightSession(); session.Store(car); await session.SaveChangesAsync(ct); return car; } public async Task DeleteAsync(Guid id, CancellationToken ct) { await using var session = _store.LightweightSession(); session.Delete<Car>(id); await session.SaveChangesAsync(ct); } public async Task<IReadOnlyList<Car>> GetAllAsync(CancellationToken ct) { await using var session = _store.QuerySession(); return await session.Query<Car>().OrderBy(x => x.Name).ToListAsync(ct); } public async Task<Car?> GetAsync(Guid id, CancellationToken ct) { await using var session = _store.QuerySession(); return await session.LoadAsync<Car>(id, ct); } public async Task<Car> UpdateAsync(Car car, CancellationToken ct) { await using var session = _store.LightweightSession(); var entity = await session.LoadAsync<Car>(car.Id, ct); if (entity == null) { throw new Exception($"Not found car with {car.Id}"); } entity.Name = car.Name; await session.SaveChangesAsync(ct); return car; } }
Czas na kontroler:
using Microsoft.AspNetCore.Mvc; namespace TestContainersDemo.Api.Controllers; [ApiController] [Route("[controller]")] public class CarController : ControllerBase { private readonly ICarsRepository _carsRepository; private readonly ILogger<CarController> _logger; public CarController(ILogger<CarController> logger, ICarsRepository carsRepository) { _logger = logger; _carsRepository = carsRepository; } [HttpGet] public async Task<IActionResult> GetAllAsync(CancellationToken ct) { return Ok(await _carsRepository.GetAllAsync(ct)); } [HttpGet("{id}")] public async Task<IActionResult> GetAsync([FromRoute] Guid id, CancellationToken ct) { return Ok(await _carsRepository.GetAsync(id, ct)); } [HttpPost] public async Task<IActionResult> AddAsync([FromBody] Car car, CancellationToken ct) { return Ok(await _carsRepository.AddAsync(car, ct)); } [HttpPut("{id}")] public async Task<IActionResult> UpdateAsync([FromBody] Car car, [FromRoute] Guid id, CancellationToken ct) { car.Id = id; return Ok(await _carsRepository.UpdateAsync(car, ct)); } [HttpDelete("{id}")] public async Task<IActionResult> DeleteAsync([FromRoute] Guid id, CancellationToken ct) { await _carsRepository.DeleteAsync(id, ct); return Ok(); } }
No to teraz przyszedł czas na testy 🙂 Zaczynamy od deklaracji klasy Program
jako partial
(zgodnie z dokumentacją).
using Marten; using TestContainersDemo.Api; using Weasel.Core; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddMarten(options => { options.Connection(builder.Configuration.GetConnectionString("Default")!); options.AutoCreateSchemaObjects = AutoCreate.All; }); builder.Services.AddScoped<ICarsRepository, CarsRepository>(); var app = builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run(); public partial class Program { }
Testy integracyjne będą używać klasy IntegrationTestFactory
dziedziczącej od WebApplicationFactory
. Klasa ta jest potrzeba m. in. do nadpisania konfiguracji i utworzenia testowego kontenera z naszą bazą danych.
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Testcontainers.PostgreSql; namespace TestContainersDemo.Api.Tests; public class IntegrationTestFactory : WebApplicationFactory<Program>, IAsyncLifetime { private readonly PostgreSqlContainer _container; public IntegrationTestFactory() { _container = new PostgreSqlBuilder() .WithImage("postgres:16") .WithDatabase("test_db") .WithUsername("postgres") .WithPassword("postgres") .WithCleanUp(true) .Build(); } protected override void ConfigureWebHost(IWebHostBuilder builder) { var configurationValues = new Dictionary<string, string> { { "ConnectionStrings:Default", _container.GetConnectionString() } }; var configuration = new ConfigurationBuilder() .AddInMemoryCollection(configurationValues) .Build(); builder .UseConfiguration(configuration) .ConfigureAppConfiguration(configurationBuilder => { configurationBuilder.AddInMemoryCollection(configurationValues); }); } public Task InitializeAsync() => _container.StartAsync(); Task IAsyncLifetime.DisposeAsync() => _container.DisposeAsync().AsTask(); }
No to teraz przyszedł czas na testy integracyjne. Nasz test będzie polegał na przetestowaniu pełnego CRUD flow dla obiektu Car
. Innymi słowy zapiszemy nowy obiekt Car
zaktualizujemy go, sprawdzimy czy api go zwraca a na końcu go usuniemy.
using System.Net.Http.Json; namespace TestContainersDemo.Api.Tests; public class CarControllerTest : IClassFixture<IntegrationTestFactory> { private readonly IntegrationTestFactory _factory; public CarControllerTest(IntegrationTestFactory factory) => _factory = factory; [Fact] public async Task Should_Make_All_Car_CRUD_Operations() { var car = new Car { Id = new Guid("8e004033-618c-4136-8997-ea82eb9786e4"), Name = "Test car" }; var client = _factory.CreateClient(); var rs = await client.PostAsJsonAsync("/car", car); Assert.Equal(System.Net.HttpStatusCode.OK, rs.StatusCode); car.Name = "Test car 2"; rs = await client.PutAsJsonAsync($"car/{car.Id}", car); Assert.Equal(System.Net.HttpStatusCode.OK, rs.StatusCode); rs = await client.GetAsync($"car/{car.Id}"); Assert.Equal(System.Net.HttpStatusCode.OK, rs.StatusCode); var rsCar = await rs.Content.ReadFromJsonAsync<Car>(); Assert.NotNull(rsCar); Assert.Equal(rsCar.Id, car.Id); Assert.Equal(rsCar.Name, car.Name); rs = await client.GetAsync("car"); Assert.Equal(System.Net.HttpStatusCode.OK, rs.StatusCode); var rsCars = await rs.Content.ReadFromJsonAsync<IList<Car>>(); Assert.NotNull(rsCars); Assert.Single(rsCars); Assert.Equal(rsCars[0].Id, car.Id); Assert.Equal(rsCars[0].Name, car.Name); rs = await client.DeleteAsync($"car/{car.Id}"); Assert.Equal(System.Net.HttpStatusCode.OK, rs.StatusCode); rs = await client.GetAsync("car"); Assert.Equal(System.Net.HttpStatusCode.OK, rs.StatusCode); rsCars = await rs.Content.ReadFromJsonAsync<IList<Car>>(); Assert.NotNull(rsCars); Assert.Empty(rsCars); } }
Do tej pory starałem się pokazać jak używać Marten do przechowywania danych oraz Testcontainers do tworzenia kontenerów, które są nam pomocne przy testach integracyjnych (a tak naprawdę bazy danych). Na koniec chciałbym pokazać jak Marten przechowuje dane w bazie danych:
Uważam, że użycie Testcontainers do testów jest godne uwagi. Oczywiście jakimś minusem jest to, że używa docker. Jak wiadomo są też inne podejścia do testów bazy danych m. in. prawdziwa testowa baza lub baza InMemory. Osobiście uważam, że te podejścia też mają swoje minusy i plusy.
Czy Marten jest zamiennikiem dla baz nosql? No nie, bo to nie jest baza danych a biblioteka 😀 Tak na poważnie to jsonb
działa dość fajnie w postgresql więc jest to na pewno opcja chociaż do rozważenia.
Kod źródłowy znajdziesz tutaj