Test - bu dasturiy ta'minotni ishlab chiqishning muhim qismi, chunki u yuqori sifatli va barqaror dasturiy ta'minotni yetkazib berishimizga yordam beradi. Testlarning bir nechta uslublari mavjud, ularning har biri ma'lum bir maqsadga xizmat qiladi va eng muhimlaridan biri integratsiya testidir. Bu uslub dasturning turli komponentlari o'rtasidagi o'zaro ta'sirni test qilishni o'z ichiga oladi.

Ushbu turdagi testlarni yozish juda muhim bo'lsada, murakkab va vaqt talab qiladigan jarayon bo'lishi mumkin, ayniqsa testdan o'tayotgan kod dastur infratuzilmasi (ma'lumotlar bazalari, message bus, cache va hokazo…) bilan o'zaro bog`liq bo'lsa.

Ushbu maqolada men .NET 7, EF Core va Postgres-da integratsion test qilish uchun Testcontainers-dan qanday foydalanish mumkinligini ko'rsatmoqchiman.

 

Testcontainers va test senariylari

Testcontainers - bu ma'lumotlar bazasi, xabar brokerlari, veb-brauzerlar yoki Docker konteynerida ishlashi mumkin bo'lgan barcha yechimlarni bir martalik, yengil nusxasini  taqdim etishga mo’ljallangan ochiq manbali platforma. Testcontainers Java, Go, NodeJs kabi bir nechta tillarni qo'llab-quvvatlaydi. Ushbu maqolada men uning C# dagi .NET Testcontainers deb nomlangan Nuget kutibxonasidan foydalanaman.

 

EF asosiy test strategiyalari va efemer  konteynerlar

EF Core-ni test qilishni bir necha hil strategiyalari mavjud:

Men bu usullarni inkor qilmoqchi emasman va ushbu yondashuvlar ba'zi holatlarda foydali bo'lishi ham mumkin. Shunday bo’lsada, ayrim hollarda muammolarga duch kelishingiz mumkin:

  • Bir xil LINQ so'rovida kichik harf sezgirligidagi (case sensitivity) farqlar tufayli turli provayderlar uchun turli natijalarni berishi mumkin.
  • Checklangan SQL so’rovlarni qo'llab-quvvatlashi. Masalan, jsonb turidagi ustun bilan bog`liq SQL so’rovlarni EF Core inmemory-da bajarish imkoniyati yo’qligi.  
  • Tranzaktsiyalarni test qilishdagi checklovlar
  • Test uchun alohida ma’lumotlar bazasida bir xil ma’lumotlardan to’lib ketishi va bu ma’lumotlarni o’z vaqtida tozalab turish yoki bir vaqtning o’zida bir nechta test jarayonini ishga tushirishdagi ziddiyatlar

Shu kabi muammolar tufayli, dasturingiz murakkabligiga qarab, bu qiyinchiliklar ertami-kechmi noto'g'ri salbiy test natijalariga olib keladi (funktsionallik buzilgan, ammo test xatosiz bajariladi yoki ba'zi xususiyatlarni tekshirilmagan holda qoldiradi va hokazo).

Efemer konteynerlardan foydalanish sizni yuqoridagi muammodan xalos qiladi. Birinchidan, murakkab DBMS o'rnatishga hojat yo'q, ikkinchidan, sizning testlaringiz har doim ma'lum bir holatdan boshlanadi, chunki har bir test yangi konteynerdan foydalanishi mumkin.

 

Amaliy qism

 

Test konteynerlarini yaratish

Avval kerakli kutubxonalarni o`rnatib olamiz:

dotnet add package Testcontainers

 

Men PostreSQL-ni ishlatganim uchun Testcontainers.PostgreSql ni ham o`rnataman:

dotnet add package Testcontainers.PostgreSql

 

Bazaviy integratsion test klassinig ko’rinishi:

public class IntegrationTestBase
{
    private PostgreSqlContainer _postgreSqlContainer = null!;

    public TestWebApplicationFactory TestApp { get; private set; } = null!;

    [OneTimeSetUp]
    public async Task InitializeAsync()
    {
        _postgreSqlContainer = new PostgreSqlBuilder()
            .WithImage("postgres:14.7")
            .WithCleanUp(true)
            .Build();

        await _postgreSqlContainer.StartAsync();

        TestApp = new TestWebApplicationFactory(_postgreSqlContainer.GetConnectionString());
    }

    [OneTimeTearDown]
    public Task DisposeAsync()
    {
        return _postgreSqlContainer.DisposeAsync().AsTask();
    }
}

Ushbu maqolada test framework-i sifatida NUnit ishlatilgan. 

OneTimeSetUp - Ushbu atribut test klassidagi har qanday testlardan oldin bir marta chaqiriladi.

OneTimeTearDown - Ushbu atribut test klassidagi barcha testlardan keyin bir marta chaqiriladi.

Ya`ni, test klassimiz ishga tushishidan oldin biz test konteynerini yaratib ishga tushiramiz va barcha testlar tugaganidan so’ng biz konteynerni o’chiramiz. Bu amalni har bir testni alohida konteynerda ishga tushirmoqchi bo’lsangiz OneTimeSetUp o’rniga SetUp atributini ishlatishingiz mumkin.

 

Endi dasturni xotirada ishga tushirish uchun yaratilgan WebApplicationFactory-ni ko’rib chiqamiz.

WebApplicationFactory - bu test web-host va test web-server yordamida haqiqiy ilovangizning xotiradagi versiyasini ishga tushirish imkonini beradi.

public class TestWebApplicationFactory : WebApplicationFactory<Program>
{
    private readonly string _connectionString;

    public TestWebApplicationFactory(string connectionString)
    {
        _connectionString = connectionString;
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // Remove existing AppDbContext
            var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            if (descriptor != null) services.Remove(descriptor);

            // Add AppDbContext for testing
            services.AddDbContext<AppDbContext>(options => options.UseNpgsql(_connectionString));

            // Apply migrations
            var serviceProvider = services.BuildServiceProvider();
            using var scope = serviceProvider.CreateScope();
            var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            context.Database.Migrate();
        });
    }
}

Yuqorida aytib o'tilganidek, WebApplicationFactory sizga xuddi producation muhitida bo'lgani kabi dasturni xotirada ishga tushirishga imkon beradi. Bu shuni anglatadiki, agar siz ushbu DbContext-ni test konteyneridagi ma’lumotlar bazasi bilan ishlaydigan boshqasiga almashtirmasangiz, EF Core production ma'lumotlar bazasiga ulanadi. Bu holat agar connectionString-ni statik holatda saqlaganda kuzatiladi. Buning o’rniga muhit o’zgaruvhilarini o’zgartirish orqali boshqarish ma’qul deb o’ylayman. Masalan, test konteyneri yaratish jarayonida, unga kerakli o’zgaruchilarni ko’rsatib o’tsa bo’ladi:

_postgreSqlContainer = new PostgreSqlBuilder()
    …
  	.WithEnvironment("ConnectionString", "<some connection string>")
	.Build;

Ammo tushunarli bo’lishi yuqoridagi DBContext-ni almashtirish usulini qoldirdim va buning uchun WebApplicationFactory-dan meros olgan holda o'z fabrikamizni yaratib oldik.

Keyin DI konteyneridan ma'lumotlar bazasi kontekstini olib tashlaymiz, test konteyneridagi ma’lumotlar bazasi uchun yangisini ro'yxatdan o'tkazdik va ma'lumotlar bazasi sxemasi to'g'ri ishga tushirilganligiga ishonch hosil qilish uchun context.Database.Migrate() chaqirdik.

 

Test senariylarini qo'shish

Bazaviy klasimiz tayyor bo’lganidan keyin endi IntegrationTestBase-dan meros olgan holda integration testlarni qo’shsak bo’ladi:

[TestFixture]
public class CustomersIntegrationTests : IntegrationTestBase
{
    [Test]
    public async Task GetCustomers_ShouldReturnCustomerList()
    {
        // Arrange
        var client = TestApp.CreateClient();

        // Act
        var response = await client.GetAsync("/customers");

        // Assert
        response.EnsureSuccessStatusCode();
    }

    [Test]
    public async Task CreateCustomers_ShouldReturnsNewCustomer()
    {
        // Arrange
        var client = TestApp.CreateClient();

        var newCustomer = new Customer
        {
            Id = 1,
            Name = "Paxlavon Maxmud",
            Address = new()
            {
                Country = "UZ",
                City = "Xorazm"
            }
        };

        // Act
        var response = await client.PostAsJsonAsync("/customers", newCustomer);

        // Assert
        response.EnsureSuccessStatusCode();
        var result = await response.Content.ReadFromJsonAsync<Customer>();
        Assert.AreEqual(result!.Address!.Country!, "UZ");
    }
}

Asosiy dasturimiz ko’rinishi judda sodda u 2 ta xaridorlar ro'yxati va yangi xaridor qo'shish metodlaridan iborat:

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddDbContext<AppDbContext>(options => 
        options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();

app.MapGet("/customers", async (AppDbContext dbContext) =>
{
    return await dbContext.Customers.ToListAsync();
});

app.MapPost("/customers", async (Customer customer, AppDbContext dbContext) =>
{
    await dbContext.Customers.AddAsync(customer);
    await dbContext.SaveChangesAsync();

    return customer;
});

app.Run();

public partial class Program { }

E’tibor bering public partial class Program { } bu WebApplicationFactory fabrikamiz to’g’ri ishga tushishi uchun qo’shilgan.

 

Testni ishga tushirish

Testni ishga tushirish uchun dotnet test buyrug’idan foydalanish mumkin. Agar siz lokal moshinangizda ishga tushirsangiz, Docker Desctop’da 2 ta konteyner yaratilganini va test tugashi bilan ularni o'chishini kuzatasiz.

Ko’rib turganingizdek testni ishga tushirish juda sodda va uni Continuous Integration jarayonida ham hech qanday qo’shimcha harakatlarsiz bajarsangiz bo’ladi. Men qo’shimcha misol sifatida github actions-dan foydalanib har bir push jarayonida testni ishga tushirilishini bu yerda ko’rsatib o'tdim.

 

Ushbu loyiha uchun toʻliq manba kodini bu yerdan topishingiz mumkin: https://github.com/idilshod87/testcontainers-demo.

 

Foydalanilgan havolalar