Levelwise
فارسی
تست

تست Integration با WebApplicationFactory و Testcontainers

تست Integration چند بخش واقعی سیستم را با هم اجرا می‌کند. با WebApplicationFactory کل برنامه ASP.NET Core را داخل تست بالا می‌آوریم. با Testcontainers یک دیتابیس واقعی داخل Docker می‌سازیم تا تست همان رفتاری را ببیند که Production می‌بیند.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۴ دقیقهمثال ثبت سفارش در فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

مشکل: همه تست‌ها سبزند، ولی Production خراب است

در فروشگاه اینترنتی ما یک API برای ثبت سفارش داریم. صدها تست واحد داریم و همه سبزند. ولی بعد از deploy این خطاها پیش می‌آیند:

  • کوئری LINQ در دیتابیس واقعی به SQL ترجمه نمی‌شود و خطا می‌دهد.
  • یک ستون جدید در Migration فراموش شده است.
  • یک سرویس در DI ثبت نشده است و برنامه هنگام اولین درخواست خطا می‌دهد.
  • اسم یک فیلد در JSON خروجی عوض شده است و اپلیکیشن موبایل کار نمی‌کند.
  • قانون یکتا بودن (Unique Constraint) در دیتابیس، سفارش تکراری را رد می‌کند، ولی کد ما این خطا را مدیریت نمی‌کند.

هیچ کدام از این‌ها در تست واحد دیده نمی‌شود. چون تست واحد دیتابیس، DI و HTTP را عمداً کنار می‌گذارد.

ایده: قطعه‌های واقعی را با هم اجرا کن

تست Integration چند بخش واقعی را با هم اجرا می‌کند. در یک API معمولی یعنی:

  1. درخواست HTTP واقعی با همان مسیر، همان Middleware و همان قانون‌های Validation.
  2. همان تنظیمات DI که برنامه در Production استفاده می‌کند.
  3. دیتابیس واقعی با همان نوع و نسخه‌ای که در Production داریم.

فقط چیزهایی را جایگزین می‌کنیم که در اختیار ما نیستند، مثل درگاه پرداخت بانک.

همه این‌ها با هم اجرا می‌شوندکد تستHttpClientTestServerMiddlewareEndpointDIEF Coreکوئری و ساخت جدول‌هاDockerPostgresدرگاه پرداخت بیرونیبا یک نسخه جعلی جایگزین می‌شودبدون پورت شبکه، داخل همان پروسه تست
فقط درگاه پرداخت بیرونی جایگزین می‌شود. بقیه مسیر واقعی است.

ابزار اول: WebApplicationFactory

کلاس WebApplicationFactory در بسته Microsoft.AspNetCore.Mvc.Testing است. این کلاس:

  1. کل برنامه را از روی Program اجرا می‌کند. همان کدی که در Production اجرا می‌شود.
  2. یک سرور داخل حافظه (TestServer) می‌سازد. هیچ پورت شبکه‌ای باز نمی‌شود. برای همین سریع است و تست‌ها با هم تداخل پورت ندارند.
  3. یک HttpClient آماده می‌دهد. با متد CreateClient، درخواست مستقیم به همین سرور داخل حافظه می‌رود.
  4. اجازه می‌دهد چند سرویس را عوض کنیم. با متد ConfigureTestServices، مثلاً درگاه پرداخت واقعی را با یک Fake جایگزین می‌کنیم.

ابزار دوم: Testcontainers

برای دیتابیس دو راه اشتباه رایج است: provider داخل حافظه EF Core یا SQLite به جای دیتابیس اصلی. هر دو مشکل دارند:

  1. رفتار متفاوت. provider داخل حافظه قانون‌های یکتا بودن و کلید خارجی را مثل دیتابیس واقعی چک نمی‌کند.
  2. ترجمه متفاوت کوئری. کوئری‌ای که در تست کار می‌کند، ممکن است در PostgreSQL خطا بدهد یا برعکس.
  3. امکانات متفاوت. ستون JSON، تراکنش و قفل‌ها در هر دیتابیس فرق دارند.
خود مستندات EF Core می‌گوید: برای تست کدی که با دیتابیس کار می‌کند، provider داخل حافظه را پیشنهاد نمی‌کند. چون نتیجه تست ممکن است با دیتابیس واقعی فرق داشته باشد.

کتابخانه Testcontainers یک دیتابیس واقعی را داخل یک کانتینر Docker بالا می‌آورد. کانتینر یک پورت تصادفی می‌گیرد و رشته اتصال را به ما می‌دهد. آخر کار هم کانتینر را حذف می‌کند. تنها پیش‌نیاز این است که Docker روی سیستم و روی سرور CI در دسترس باشد.

کد: Factory مشترک برای تست‌ها

این کلاس هم برنامه را بالا می‌آورد و هم دیتابیس را. در این مثال از xUnit v3 استفاده می‌کنیم. در این نسخه، متدهای رابط IAsyncLifetime نوع ValueTask برمی‌گردانند.

public sealed class ShopApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _db =
        new PostgreSqlBuilder("postgres:17").Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        // Point the app to the database inside the container.
        builder.UseSetting("ConnectionStrings:Shop", _db.GetConnectionString());

        builder.ConfigureTestServices(services =>
        {
            // The real bank gateway is outside our control: use a fake.
            services.RemoveAll<IPaymentGateway>();
            services.AddSingleton<IPaymentGateway, AlwaysApprovePaymentGateway>();
        });
    }

    public async ValueTask InitializeAsync()
    {
        await _db.StartAsync();

        using var scope = Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<ShopDbContext>();
        await db.Database.MigrateAsync();
    }

    public override async ValueTask DisposeAsync()
    {
        await base.DisposeAsync();
        await _db.DisposeAsync();
    }
}

چند نکته درباره این کد:

  • متد Migrate همان Migration های واقعی پروژه را اجرا می‌کند. پس اگر یک Migration جا مانده باشد، همین‌جا معلوم می‌شود.
  • اول کانتینر بالا می‌آید، بعد برنامه. چون برنامه رشته اتصال را لازم دارد.
  • آخر کار، اول برنامه بسته می‌شود و بعد دیتابیس.

حالا تست. رابط IClassFixture باعث می‌شود همه تست‌های این کلاس از یک Factory و یک کانتینر استفاده کنند:

public sealed class OrdersApiTests(ShopApiFactory factory) : IClassFixture<ShopApiFactory>
{
    [Fact]
    public async Task Posting_an_order_saves_it_and_returns_201()
    {
        var ct = TestContext.Current.CancellationToken;
        var client = factory.CreateClient();

        var response = await client.PostAsJsonAsync(
            "/orders", new { ProductId = 7, Quantity = 2 }, ct);

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);

        var order = await client.GetFromJsonAsync<OrderDto>(response.Headers.Location, ct);
        Assert.Equal(2, order!.Quantity);
    }

    [Fact]
    public async Task Order_with_zero_quantity_returns_400()
    {
        var ct = TestContext.Current.CancellationToken;
        var client = factory.CreateClient();

        var response = await client.PostAsJsonAsync(
            "/orders", new { ProductId = 7, Quantity = 0 }, ct);

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    }
}

این تست از بیرون، مثل یک کاربر واقعی، به API نگاه می‌کند. به کلاس‌های داخلی کاری ندارد. پس اگر کد داخلی را بازنویسی کنیم، تست نمی‌شکند.

جدا نگه داشتن تست‌ها از هم

بالا آوردن کانتینر چند ثانیه طول می‌کشد. پس برای هر تست یک کانتینر جدید نمی‌سازیم. ولی اگر همه تست‌ها یک دیتابیس داشته باشند، داده یک تست ممکن است تست دیگر را خراب کند.

زمان ←بالا آمدن کانتینریک بار، کندMigrationتست ۱پاکتست ۲پاکتست ۳حذف کانتینرآخر کارهر تست با دیتابیس تمیز شروع می‌شود
کانتینر یک بار ساخته می‌شود. داده بین تست‌ها پاک می‌شود.

سه راه رایج داریم:

  1. هر تست داده خودش را بسازد و فقط آن را چک کند. مثلاً با یک شناسه یکتا. ساده‌ترین راه است و برای تست‌های موازی هم خوب است.
  2. پاک کردن جدول‌ها بین تست‌ها. کتابخانه Respawn این کار را سریع انجام می‌دهد. جدول‌ها را خالی می‌کند، ولی ساختار را نگه می‌دارد.
  3. هر تست داخل یک تراکنش که آخرش Rollback می‌شود. سریع است، ولی وقتی کد خودش تراکنش باز می‌کند یا درخواست از چند Connection رد می‌شود، کار نمی‌کند.
برای پروژه‌های بزرگ: اگر چند کلاس تست دارید، با Collection Fixture در xUnit یک کانتینر را بین همه آن‌ها مشترک کنید. تست‌های داخل یک Collection پشت سر هم اجرا می‌شوند، پس روی یک دیتابیس با هم تداخل ندارند.

سرویس‌های بیرونی

درگاه پرداخت، سرویس پیامک و API های شرکت‌های دیگر در اختیار ما نیستند. اگر در تست صدایشان بزنیم:

  • تست کند می‌شود.
  • اگر آن سرویس قطع باشد، تست ما بی‌دلیل قرمز می‌شود.
  • شاید واقعاً پول جابه‌جا شود یا پیامک برود.

پس دو راه داریم. یا رابط آن سرویس را در ConfigureTestServices با یک Fake عوض می‌کنیم، مثل کد بالا. یا اگر می‌خواهیم کد HTTP خودمان هم تست شود، یک سرور HTTP جعلی مثل WireMock.Net بالا می‌آوریم که جواب‌های آماده می‌دهد.

قانون‌های مهم

  1. دیتابیس تست همان نوع و نسخه Production باشد. اگر Production روی PostgreSQL نسخه ۱۷ است، تست هم همان باشد.
  2. از بیرون تست کن. از راه HTTP، نه با صدا زدن مستقیم کلاس‌های داخلی.
  3. کانتینر را به اشتراک بگذار، نه داده را. یک کانتینر برای یک گروه تست، ولی هر تست با داده قابل پیش‌بینی.
  4. فقط مرزهای بیرونی را جایگزین کن. اگر دیتابیس را هم Fake کنی، دیگر تست Integration نیست.
  5. تعداد را معقول نگه دار. این تست‌ها کندتر از تست واحد هستند. مسیرهای اصلی و حالت‌های مهم خطا را پوشش بده. همه ترکیب‌های قانون‌های کسب‌وکار را در تست واحد بگذار.
  6. در CI هم اجرا شوند. تستی که فقط روی لپ‌تاپ یک نفر اجرا می‌شود، کسی را نجات نمی‌دهد.

اشتباه‌های رایج

اشتباه نتیجه راه درست
استفاده از provider داخل حافظه EF Core تست سبز است، ولی کوئری در Production خطا می‌دهد. دیتابیس واقعی با Testcontainers.
یک کانتینر جدید برای هر تست تست‌ها خیلی کند می‌شوند. یک کانتینر برای کلاس یا Collection.
وابستگی تست‌ها به داده هم با تغییر ترتیب اجرا، تست‌ها تصادفی قرمز می‌شوند. داده یکتا برای هر تست یا پاک کردن بین تست‌ها.
صدا زدن سرویس بیرونی واقعی تست ناپایدار است و شاید پول واقعی جابه‌جا شود. Fake یا سرور HTTP جعلی.
رشته اتصال ثابت به یک دیتابیس مشترک تیم تست‌های دو نفر داده هم را خراب می‌کنند. هر اجرا کانتینر خودش.
تست همه قانون‌های کسب‌وکار از راه HTTP مجموعه تست کند و شکننده می‌شود. قانون‌ها در تست واحد، مسیر اصلی در Integration.

چه چیزی را با تست Integration بسنجیم؟

مناسب

  • کوئری‌ها و Migration های EF Core.
  • مسیر کامل یک Endpoint، از درخواست تا ذخیره در دیتابیس.
  • تنظیمات DI، احراز هویت و Middleware.
  • شکل دقیق JSON ورودی و خروجی.

نامناسب

  • همه حالت‌های یک قانون تخفیف. تست واحد سریع‌تر و دقیق‌تر است.
  • رفتار سرویس‌های شرکت‌های دیگر.
  • جریان‌های طولانی در چند سرویس. برای آن‌ها تست E2E یا تست قرارداد (Contract Test) مناسب‌تر است.

خلاصه در شش خط

  1. تست واحد دیتابیس، DI و HTTP را نمی‌بیند. تست Integration همین‌ها را با هم اجرا می‌کند.
  2. کلاس WebApplicationFactory کل برنامه را داخل حافظه بالا می‌آورد و یک HttpClient می‌دهد.
  3. کتابخانه Testcontainers دیتابیس واقعی را در Docker می‌سازد. provider داخل حافظه جایگزین خوبی نیست.
  4. کانتینر را بین تست‌ها مشترک کن، ولی داده هر تست را جدا نگه دار.
  5. فقط سرویس‌های بیرونی را با Fake جایگزین کن.
  6. مسیرهای اصلی را با Integration و جزئیات قانون‌ها را با تست واحد پوشش بده.