Mert Özen Her Satırda Daha İleri
Test ve Kalite Integration ve E2E Test — #07

Bookstore'u Uçtan Uca Test Etmek: API, Angular ve Veritabanını Birlikte Ayağa Kaldırmak

Mert Özen 14 Kas 2026 16 dk 1 görüntülenme
Bookstore'u Uçtan Uca Test Etmek: API, Angular ve Veritabanını Birlikte Ayağa Kaldırmak

Playwright'ın webServer'ı iki process başlatır: SQL Server container'ını açıp API'yi WebApplicationFactory.UseKestrel ile gerçek portta çalıştıran bir .NET host'u ve ng serve. Host'taki üç sürprizi, login'i setup project ve storageState ile bir kez yapmayı, test verisini API'den hazırlamayı, page object ile fixture'ı birlikte kullanmayı ve UseKestrel'in WithWebHostBuilder'la portu yok saymasını gerçek koşularla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Angular 22.2, Playwright 1.65, Microsoft.AspNetCore.Mvc.Testing 10.0.15, Testcontainers for .NET 4.16, WireMock.Net 2.31 ve SQL Server 2025 ile derlenip denenmiştir.

Önce Tahmin Et

6. yazıda Playwright'ın webServer ayarı, API'yi ve veritabanını ayağa kaldıran küçük bir .NET host'unu başlatıyordu. O host'un ilk hâli bir console projesiydi: top-level statement'larla SQL Server container'ını açıyor, sonra 1. yazıdaki factory'ye çok benzeyen bir WebApplicationFactory<Program>'yi Kestrel üzerinde başlatıyordu:

// Bookstore.E2E.Host/Program.cs (ilk hâli)
await using MsSqlContainer sql = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest").Build();
await sql.StartAsync();

await using E2EApiFactory api = new(connectionString);
api.UseKestrel(5280);
api.StartServer();

// Bookstore.E2E.Host/E2EApiFactory.cs
public sealed class E2EApiFactory(string connectionString) : WebApplicationFactory<Program>
{
    // ...
}

Build başarılı oldu, yalnız bir uyarı vardı:

E2EApiFactory.cs(6,84): warning CS0436: The type 'Program' in '.../Bookstore.E2E.Host/Program.cs' conflicts with the imported type 'Program' in 'Bookstore.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null'. Using the type defined in '.../Bookstore.E2E.Host/Program.cs'.

dotnet run ile çalıştırınca ne olur? API 5280'de mi açılır, bir hata mı alırsın, yoksa başka bir şey mi?

Cevap: Factory Kendi Program'ını Çalıştırdı

Bir dakikada şu satırlar birikti (Testcontainers log'undan kısalttım):

[testcontainers.org 00:00:01.35] Docker container cbc9cf883c51 created
[testcontainers.org 00:00:08.29] Docker container cbc9cf883c51 ready
[testcontainers.org 00:00:08.35] Docker container da22368ad75c created
[testcontainers.org 00:00:14.24] Docker container da22368ad75c ready
[testcontainers.org 00:00:14.26] Docker container 79a98f366488 created
[testcontainers.org 00:00:20.18] Docker container 79a98f366488 ready
[testcontainers.org 00:00:20.21] Docker container 2826fa527cfc created
[testcontainers.org 00:00:26.29] Docker container 2826fa527cfc ready
...

Her altı saniyede bir yeni SQL Server container'ı. Uyarı her şeyi söylüyordu: host projesinin top-level statement'ları da derleyicinin ürettiği bir Program class'ı demek, ve derleyici iki Program'dan yerel olanı seçti. WebApplicationFactory<TEntryPoint> çalıştıracağı uygulamayı TEntryPoint'in assembly'sinden buluyor; o assembly host'un kendisiydi. StartServer host'un entry point'ini çağırdı, o da yeni bir container açıp yeniden StartServer dedi. Process'i durdurduğumda container'ları Testcontainers'ın Ryuk container'ı (oturum bitince geride kalan container'ları silen yardımcı container) temizledi.

Düzeltme, tip argümanına API assembly'sinden bir tip vermek:

// Tip argümanı API assembly'sinden bir tip olmalı; bu projenin kendi Program'ı değil
public sealed class E2EApiFactory(string connectionString, FakeProviders providers, E2ETokenIssuer tokens)
    : WebApplicationFactory<BookstoreDbContext>

Test projelerinde bu sorun çıkmıyor, çünkü xUnit v3 test projesinin entry point'ini kendisi üretiyor ve ona Program adını vermiyor. Factory'yi test dışında, kendi Main'i olan bir process'te kullandığın anda Program adı iki yerde var demek.

Full-Stack Kurgusu: Üç Parça, Tek Komut

07-e2e-stack-tr.png

npx playwright test dediğinde çalışan her şey görselde. Playwright iki webServer başlatıyor: Bookstore.E2E.Host ve ng serve. Host, SQL Server container'ını açıp migration'ları uyguluyor, ödeme ve kargo için WireMock.Net sunucularını başlatıyor, sonra API'yi WebApplicationFactory.UseKestrel ile gerçek bir portta dinletiyor. Tarayıcı Angular dev server'ına gidiyor, Angular /api isteklerini proxy ayarıyla API'ye yönlendiriyor. Testler ise tarayıcı dışında, Playwright'ın APIRequestContext'iyle doğrudan API'ye konuşup test verisini ve token'ları hazırlıyor.

API'yi ayağa kaldırmanın üç yolu var ve hangisinin uygun olduğu nerede çalıştırdığına bağlı:

KurguNasılArtısıBedeli
Host process, UseKestrelwebServer dotnet run ile host'u başlatırtek komut; sahteler ve test kimliği integration testlerdeki gibi kodlaproduction'daki process değil, test factory'si
Ayrı processpublish edilmiş API + Docker'da SQL Server + ortam değişkenleriproduction'a en yakın; CI'da image'la çalışırsahteleri ve test kimliğini configuration'la vermek gerekir
Hepsi xUnit içindeMicrosoft.Playwright + factory + container aynı test process'indeyalnız .NET araçlarıAngular'ı başlatmak yine dışarıda kalır

Bookstore'da yerelde ve pull request CI'ında ilk kurguyu kullanıyorum: geliştirici bilgisayarında Docker ve .NET SDK'sı zaten var ve testin ihtiyaç duyduğu her şey kodda duruyor. Release öncesi, deploy edilecek container image'ının kendisini test etmek istediğinde ikinci kurgu daha anlamlı; o zaman test kimliği ve sahte sağlayıcı adresleri ortam değişkenleriyle veriliyor ve host'un yaptığı işi bir docker compose dosyası üstleniyor.

E2E Host'u: Testcontainers, UseKestrel ve Sınırdaki Sahteler

Host'un son hâli:

using System.Runtime.InteropServices;
using Bookstore.Api.Data;
using Bookstore.E2E.Host;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Testcontainers.MsSql;

// Playwright'ın webServer'ı bu process'i başlatır: SQL Server container'ı + Kestrel üzerinde API
const int ApiPort = 5280;

// WebApplicationFactory, API'nin content root'unu bulduğu MvcTestingAppManifest.json'u
// çalışma klasöründe arar; dotnet run ise çalışma klasörünü değiştirmez
Environment.CurrentDirectory = AppContext.BaseDirectory;

await using MsSqlContainer sql = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest").Build();
await sql.StartAsync();
string connectionString = new SqlConnectionStringBuilder(sql.GetConnectionString())
{
    InitialCatalog = "BookstoreDb"
}.ConnectionString;

// Şema ve başlangıç verisi API dinlemeye başlamadan önce hazır olmalı
DbContextOptions<BookstoreDbContext> options = new DbContextOptionsBuilder<BookstoreDbContext>()
    .UseSqlServer(connectionString)
    .Options;
await using (BookstoreDbContext db = new(options))
{
    await db.Database.MigrateAsync();
    db.Books.AddRange(
        new Book { Isbn = "978-0441172719", Title = "Dune", Price = 64.90m, Stock = 7 },
        new Book { Isbn = "978-0141439587", Title = "Emma", Price = 39.50m, Stock = 3 },
        new Book { Isbn = "978-0547928227", Title = "The Hobbit", Price = 52.00m, Stock = 0 },
        new Book { Isbn = "978-0141439549", Title = "Middlemarch", Price = 48.75m, Stock = 5 });
    // Test kimliği customer_id=1 bu müşteriye karşılık gelir
    db.Customers.Add(new Customer { Email = "ada@example.com", Name = "Ada" });
    await db.SaveChangesAsync();
}

using FakeProviders providers = new();
await using E2EApiFactory api = new(connectionString, providers, new E2ETokenIssuer());
api.UseKestrel(ApiPort);
api.StartServer();
Console.WriteLine($"Bookstore API listening on {api.ClientOptions.BaseAddress}");

TaskCompletionSource stopped = new();
using PosixSignalRegistration sigint = PosixSignalRegistration.Create(PosixSignal.SIGINT, Stop);
using PosixSignalRegistration sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, Stop);
await stopped.Task;
Console.WriteLine("Stopping: disposing API and SQL Server container");

void Stop(PosixSignalContext context)
{
    context.Cancel = true;
    stopped.TrySetResult();
}

UseKestrel .NET 10 ile WebApplicationFactory'ye geldi. Varsayılan TestServer bir socket açmıyor; tarayıcının bağlanabilmesi için gerçek bir porta ihtiyaç var. API referansına göre UseKestrel factory başlatılmadan önce, yani CreateClient ya da StartServer'dan önce çağrılmalı; sonradan çağrılırsa InvalidOperationException atıyor. Kestrel kullanıldığında factory.Server bir TestServer döndürmek yerine NotSupportedException atıyor ve CreateClient gerçek adrese giden sıradan bir HttpClient veriyor.

Kodun iki satırı, yolda çıkan iki hatanın düzeltmesi. İlki Environment.CurrentDirectory. İlk çalıştırmada API daha başlarken düştü:

Unhandled exception. System.IO.DirectoryNotFoundException: .../lab/Bookstore.Api/
   at Microsoft.Extensions.FileProviders.PhysicalFileProvider..ctor(String root, ExclusionFilters filters)
   ...
   at Microsoft.AspNetCore.Builder.WebApplication.CreateBuilder(String[] args)

Factory, API'nin content root'unu build sırasında üretilen MvcTestingAppManifest.json'dan okuyor ve bu dosyayı çalışma klasöründe arıyor. Test runner'lar çalışma klasörünü bin klasörü yapıyor; dotnet run --project ise komutu çalıştırdığın yerde bırakıyor. Dosya bulunamayınca factory, solution klasörünün altında assembly adıyla bir klasör deniyor ve bizim düzenimizde API src altında olduğu için orada bir şey yok.

İkincisi sıralama. Bir sonraki denemede host "Bookstore API listening on http://127.0.0.1:5280/" yazdı ama curl bağlanamadı. Log'u yukarıdan okuyunca neden belli oldu: 2. yazıda testlerde kapattığımız LowStockReportService burada çalışıyordu, migration'dan önce veritabanına gitti, "Cannot open database "BookstoreDb"" hatasıyla düştü ve BackgroundServiceExceptionBehavior.StopHost varsayılanı yüzünden API host'unu durdurdu. Process yaşamaya devam ettiği için dışarıdan bakınca her şey ayakta görünüyordu. Şimdi migration ve seed, factory başlamadan önce ayrı bir DbContext ile yapılıyor ve background service factory'de kaldırılıyor:

// Tip argümanı API assembly'sinden bir tip olmalı; bu projenin kendi Program'ı değil
public sealed class E2EApiFactory(string connectionString, FakeProviders providers, E2ETokenIssuer tokens)
    : WebApplicationFactory<BookstoreDbContext>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("E2E");
        builder.UseSetting("ConnectionStrings:Bookstore", connectionString);
        builder.UseSetting("Payments:BaseUrl", $"{providers.Payments.Url}/");
        builder.UseSetting("Payments:ApiKey", "e2e");
        builder.UseSetting("Shipping:BaseUrl", $"{providers.Shipping.Url}/");

        builder.ConfigureTestServices(services =>
        {
            // Rapor job'ı E2E'nin konusu değil; hata verirse host'u ve Kestrel'i durdurur
            ServiceDescriptor worker = services.Single(d =>
                d.ServiceType == typeof(IHostedService) && d.ImplementationType == typeof(LowStockReportService));
            services.Remove(worker);

            services.AddScoped<IOrderEmailSender, LoggingOrderEmailSender>();

            // JWT doğrulaması gerçek; yalnız token'ı imzalayan taraf değişiyor
            services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.Authority = null;
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidIssuer = E2ETokenIssuer.Issuer,
                    ValidAudience = "bookstore-api",
                    IssuerSigningKey = tokens.SigningKey
                };
            });
            services.AddSingleton(tokens);
            services.AddSingleton<IStartupFilter, E2ETokenEndpoint>();
        });
    }
}

Factory'nin geri kalanı 2. ve 4. yazılardan tanıdık: configuration UseSetting ile, dış servisler WireMock.Net ile, e-posta bir sahteyle. FakeProviders iki WireMock sunucusu başlatıp her ödeme isteğine onay, her kargo isteğine TRK-E2E-1 takip numarası dönüyor. E-posta gönderimini 4. yazıda Mailpit ile doğruladık; E2E (uçtan uca) testte onu tekrar etmek yerine gönderimi log'a yazan bir sahte kullanıyorum. Hata senaryoları (ödeme reddi, kargo zaman aşımı) burada yok; onların yeri integration testleri.

Login'i Bir Kez Yapmak: Setup Project ve storageState

Production'da Angular kullanıcıyı identity provider'a yönlendiriyor ve dönen access token'ı localStorage'a koyuyor; bir interceptor da onu her /api isteğine Authorization header'ı olarak ekliyor. Identity provider bir dış sistem, ödeme sağlayıcısı gibi. E2E'de onu sınırda taklit ediyorum: API'nin JWT (JSON Web Token; imzalı erişim token'ı) doğrulaması aynen çalışıyor, yalnız token'ı her koşuda rastgele üretilen bir test anahtarıyla host'un kendisi imzalıyor.

// Production'daki identity provider'ın yerini tutar: her koşuda yeni bir anahtarla token imzalar
public sealed class E2ETokenIssuer
{
    public const string Issuer = "bookstore-e2e";

    public SymmetricSecurityKey SigningKey { get; } = new(RandomNumberGenerator.GetBytes(32));

    public string Issue(TokenRequest request)
    {
        List<Claim> claims = [new("sub", $"customer-{request.CustomerId}"), new("customer_id", request.CustomerId.ToString())];
        claims.AddRange((request.Roles ?? []).Select(role => new Claim("role", role)));

        return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
        {
            Issuer = Issuer,
            Audience = "bookstore-api",
            Subject = new ClaimsIdentity(claims),
            Expires = DateTime.UtcNow.AddHours(1),
            SigningCredentials = new SigningCredentials(SigningKey, SecurityAlgorithms.HmacSha256)
        });
    }
}

// POST /e2e/token yalnız bu host'ta var; API'nin kendi pipeline'ının önüne eklenir
public sealed class E2ETokenEndpoint(E2ETokenIssuer issuer) : IStartupFilter
{
    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
    {
        app.Map("/e2e/token", branch => branch.Run(async context =>
        {
            TokenRequest? request = await context.Request.ReadFromJsonAsync<TokenRequest>();
            await context.Response.WriteAsJsonAsync(new { accessToken = issuer.Issue(request!) });
        }));
        next(app);
    };
}

Endpoint, API'nin koduna değil, IStartupFilter ile yalnız E2E host'unun pipeline'ına ekleniyor; production build'inde böyle bir yol yok. Anahtar process'in belleğinde doğup orada ölüyor, hiçbir dosyaya ya da config'e yazılmıyor.

Playwright tarafında login bir setup project'te bir kez yapılıyor:

import { test as setup } from '@playwright/test';
import { apiURL, customerStorageState } from './support/config';

setup('sign in as Ada', async ({ page, request }) => {
  // Production'da token identity provider'dan gelir; E2E host'u onun yerini tutuyor
  const response = await request.post(`${apiURL}/e2e/token`, { data: { customerId: 1 } });
  const { accessToken } = await response.json();

  await page.goto('/');
  await page.evaluate((token) => localStorage.setItem('bookstore.accessToken', token), accessToken);
  await page.context().storageState({ path: customerStorageState });
});
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: customerStorageState },
    dependencies: ['setup'],
  },
],

dependencies: ['setup'], chromium projesindeki testler başlamadan önce setup project'inin bitmesini sağlıyor. Setup'ın kaydettiği dosya, token'ı kısaltınca şöyle:

{
  "cookies": [],
  "origins": [
    {
      "origin": "http://localhost:4200",
      "localStorage": [
        {
          "name": "bookstore.accessToken",
          "value": "eyJhbGciOiJI..."
        }
      ]
    }
  ]
}

Her test bu dosyayla yeni bir browser context açıyor; yani her test token'a sahip ama cookie, localStorage ve bellekteki sepet yine teste özel. storageState cookie'leri, localStorage'ı ve isteğe bağlı olarak IndexedDB'yi taşıyor; sessionStorage'ı taşımıyor. Uygulaman token'ı sessionStorage'da tutuyorsa Playwright dokümanındaki gibi onu elle kaydedip her testte geri yüklemen gerekiyor. Dosya playwright/.auth/ altında ve .gitignore'da; içinde geçerli bir token var.

Test Verisini API'den Hazırlamak

Testin asıl konusu sipariş akışı. Kitabı ve kuponu da arayüzden, yönetim ekranlarından oluşturmak testi hem yavaşlatır hem de konusu dışındaki ekranlara bağlar. Veri, admin rolüyle API'den hazırlanıyor:

// Test verisi UI'dan değil, API'den ve her test için benzersiz adlarla hazırlanır
export class BookstoreData {
  private counter = 0;

  constructor(
    private readonly api: APIRequestContext,
    private readonly suffix: string,
  ) {}

  async book(title: string, price: number, stock = 10): Promise<TestBook> {
    const isbn = `978-${String(Math.floor(Math.random() * 1e10)).padStart(10, '0')}`;
    const response = await this.api.post('/api/books', {
      data: { isbn, title: `${title} ${this.unique()}`, price, stock },
    });
    expect(response.status(), await response.text()).toBe(201);
    return response.json();
  }

  async coupon(percentOff: number, validDays = 30): Promise<string> {
    const code = `SAVE${percentOff}-${this.unique()}`.toUpperCase();
    const validUntil = new Date(Date.now() + validDays * 24 * 60 * 60 * 1000).toISOString();
    const response = await this.api.post('/api/coupons', { data: { code, percentOff, validUntil } });
    expect(response.status(), await response.text()).toBe(201);
    return code;
  }

  private unique(): string {
    return `${this.suffix}${++this.counter}`;
  }
}

export const test = base.extend<TestFixtures, WorkerFixtures>({
  adminApi: [
    async ({ playwright }, use) => {
      const anonymous = await playwright.request.newContext({ baseURL: apiURL });
      const tokenResponse = await anonymous.post('/e2e/token', { data: { customerId: 0, roles: ['admin'] } });
      const { accessToken } = await tokenResponse.json();
      await anonymous.dispose();

      const api = await playwright.request.newContext({
        baseURL: apiURL,
        extraHTTPHeaders: { Authorization: `Bearer ${accessToken}` },
      });
      await use(api);
      await api.dispose();
    },
    { scope: 'worker' },
  ],
  data: async ({ adminApi }, use, testInfo) => {
    await use(new BookstoreData(adminApi, `w${testInfo.workerIndex}t${Date.now().toString(36)}-`));
  },
  catalog: async ({ page }, use) => {
    await use(new CatalogPage(page));
  },
  cart: async ({ page }, use) => {
    await use(new CartPage(page));
  },
});

adminApi worker scope'unda: her worker admin token'ını bir kez alıyor ve kendi testleri boyunca kullanıyor. data ise test scope'unda ve her test için worker numarası ile zamandan bir ek üretiyor; kitap başlıkları "Dune w1tmwfpok0f-1" gibi oluyor. Kupon için gereken POST /api/coupons endpoint'i bu yazıyla API'ye eklendi; PlaceOrderRequest'e de string? CouponCode = null geldi. 5. yazıdaki contract kontrolü bu değişikliği şöyle sınıflandırdı:

$ oasdiff changelog base.json contracts/Bookstore.Api.json
3 changes: 0 error, 0 warning, 3 info
info	[endpoint-added] at contracts/Bookstore.Api.json
	in API POST /api/coupons
		endpoint added

info	[endpoint-added] at contracts/Bookstore.Api.json
	in API GET /api/coupons/{code}
		endpoint added

info	[new-optional-request-property] at contracts/Bookstore.Api.json
	in API POST /api/orders
		added the new optional request property `couponCode`

Varsayılan değer sayesinde alan required listesine girmedi ve breaking change sayılmadı; 5. yazıdaki tuzağın tersi.

Page Object mi, Fixture mı?

İkisi birbirinin alternatifi değil. Page object bir ekranın locator'larını ve o ekrandaki işlemleri bir yerde topluyor; fixture ise testin neye ihtiyaç duyduğunu ve onun nasıl hazırlanıp temizleneceğini tanımlıyor. Bookstore'da page object'leri fixture olarak veriyorum, böylece test yalnız istediğini parametre listesinde yazıyor:

export class CartPage {
  readonly total: Locator;
  readonly error: Locator;

  constructor(private readonly page: Page) {
    this.total = page.getByText(/^Total:/);
    this.error = page.getByRole('alert');
  }

  async open(): Promise<void> {
    // Sepet bellekte duruyor; page.goto('/cart') uygulamayı yeniden yükleyip sepeti boşaltır
    await this.page.getByRole('link', { name: /^Cart/ }).click();
  }

  async applyCoupon(code: string): Promise<void> {
    await this.page.getByLabel('Coupon code').fill(code);
    await this.page.getByRole('button', { name: 'Apply' }).click();
  }

  async placeOrder(city: string): Promise<void> {
    await this.page.getByLabel('Shipping city').fill(city);
    await this.page.getByRole('button', { name: 'Place order' }).click();
  }
}
import { expect, test } from './fixtures';

test('a customer orders a book with a coupon', async ({ data, catalog, cart, page }) => {
  const book = await data.book('Dune', 64.9);
  const coupon = await data.coupon(10);

  await catalog.open();
  await catalog.search(book.title);
  await catalog.addToCart(book.title);

  await cart.open();
  await cart.applyCoupon(coupon);
  await expect(cart.total).toHaveText('Total: ₺58.41');
  await cart.placeOrder('Izmir');

  await expect(page.getByRole('heading', { name: 'Order placed' })).toBeVisible();
  await expect(page.getByText('₺58.41')).toBeVisible();
  await expect(page.getByText('TRK-E2E-1')).toBeVisible();
});

Page object'lerde assertion yok; test ne beklediğini kendisi söylüyor. Page object'in içine expect koymak, aynı method'u farklı beklentili testlerde kullanmayı zorlaştırıyor. Test bir iş akışı gibi okunuyor: veri hazırla, ara, sepete at, kuponu uygula, sipariş ver, sonucu gör. 64,90 TL'lik kitaba %10 kupon uygulanınca 58,41 TL; aynı tutarın sipariş sayfasında görünmesi, indirimin yalnız sepette hesaplanmadığını, API'den dönen siparişte de olduğunu gösteriyor. Bütün suite:

$ npx playwright test
Running 5 tests using 4 workers

  ✓  1 [setup] › e2e/auth.setup.ts:4:1 › sign in as Ada (375ms)
  ✓  5 [chromium] › e2e/catalog.spec.ts:13:1 › adding a book updates the cart link (652ms)
  ✓  4 [chromium] › e2e/catalog.spec.ts:24:1 › an out-of-stock book cannot be added (686ms)
  ✓  2 [chromium] › e2e/catalog.spec.ts:3:1 › search finds a book with a lowercase term (767ms)
  ✓  3 [chromium] › e2e/checkout.spec.ts:3:1 › a customer orders a book with a coupon (1.8s)

  5 passed (17.4s)

Üretimde Dikkat

Süreyi testler değil, başlangıç belirliyor. Yukarıdaki koşuda beş test toplam birkaç saniye sürdü; 17,4 saniyenin geri kalanı host'un ve ng serve'ün başlamasıydı. Ayrı terminallerde açtığımda API 12 saniyede (container, migration ve dotnet run'ın build kontrolüyle), Angular 2 saniyede hazırdı; sunucular açıkken aynı suite 3,0 saniyede bitti. Yerelde reuseExistingServer bu yüzden önemli. CI'da !process.env.CI ile kapalı; her koşu temiz bir veritabanıyla başlıyor.

Sunucuların çıktısını görmek gerektiğinde DEBUG=pw:webserver ortam değişkeni host'un ve ng serve'ün log'unu Playwright çıktısına katıyor. Host bir hata ile düşerse Playwright yalnız timeout dolunca "Timed out waiting" diyor; ilk bakılacak yer bu log.

Kapanışı Windows'ta ayrıca düşün. gracefulShutdown: { signal: 'SIGTERM' } Linux ve macOS'ta host'a SIGTERM (process'ten düzgünce kapanmasını isteyen sinyal) gönderiyor, host da container'ı ve factory'yi dispose ediyor. Playwright dokümanına göre Windows SIGTERM ve SIGINT (Ctrl+C'nin gönderdiği sinyal) sinyallerini desteklemediği için bu ayar orada yok sayılıyor ve process doğrudan sonlandırılıyor. SQL Server container'ı yine de kalmıyor: Testcontainers'ın Ryuk container'ı, host'un bağlantısı kopunca oturumun container'larını siliyor. Ryuk'u kapatan bir ortamda (bazı CI runner'ları) bu güvence de kalkar.

Portlar sabit: 5280 ve 4200. Aynı makinede iki koşu ya da açık kalmış bir host, ikinci koşuyu reuseExistingServer sayesinde sessizce eski sunucuya bağlar. CI'da iş başına ayrı makine kullanıldığı için sorun çıkmıyor; yerelde "testler benim değişikliğimi görmüyor" diyorsan önce açık kalmış bir host var mı bak.

Sık Düşülen Tuzaklar

UseKestrel'ı WithWebHostBuilder ile türetilmiş bir factory'de kullanmak. 2. yazıda test başına varyasyon için WithWebHostBuilder kullanmıştık. Aynı yolu Kestrel'le denedim:

await using (ApiFactory baseFactory = new())
{
    await using WebApplicationFactory<BookstoreDbContext> derived = baseFactory.WithWebHostBuilder(b => b.UseSetting("Feature:X", "on"));
    derived.UseKestrel(5282);
    derived.StartServer();
    Console.WriteLine($"derived: {derived.ClientOptions.BaseAddress}");
}
base:    http://127.0.0.1:5281/
derived: http://localhost:5000/
derived (UseKestrel on base): http://localhost/; Server=TestServer

Doğrudan factory'de UseKestrel(5281) beklendiği gibi çalıştı. Türetilmiş factory'de istenen port yok sayıldı ve Kestrel varsayılan 5000'de açıldı; Angular proxy'si 5282'ye gittiği için testler bağlantı hatası alırdı. UseKestrel'ı ana factory'de çağırıp sonra türetince de türetilmiş factory'nin istemcisi TestServer'a gitti, yine de log'da 5283 için bir "Now listening" satırı çıktı. Yazım anında 10.0.15'te davranış bu; dotnet/aspnetcore deposunda port'un türetilmiş factory'de yok sayılmasıyla ilgili açık bir issue (#65877) var. Kestrel kullanan host'ta varyasyonu WithWebHostBuilder ile değil, ayrı bir factory class'ı ya da constructor parametresiyle ver.

Testlerin birbirinin verisini görmesi. checkout.spec.ts'i eklediğim ilk koşuda 6. yazıdaki arama testi kaldı:

Error: expect(locator).toHaveCount(expected) failed

Locator:  getByRole('list', { name: 'Search results' }).getByRole('listitem')
Expected: 1
Received: 2
Timeout:  5000ms

Arama testi seed'deki "Dune"u "dune" ile arıyor ve tek sonuç bekliyordu. Aynı anda başka bir worker'da çalışan sipariş testi "Dune w1…" adlı bir kitap oluşturmuştu. İki test tek başına doğruydu; 1. yazıdaki integration testlerinde gördüğümüz paylaşılan veri sorunu, bu sefer paralel worker'larla. Düzeltme aynı: her test kendi verisini benzersiz adla oluşturuyor ve yalnız ona bakıyor. Katalog testleri artık data.book('Persuasion', 41.5) gibi kendi kitaplarını oluşturuyor. Seed'deki dört kitap uygulamayı elle gezerken işe yarıyor, testlerde kullanılmıyor.

E2E'de iş kuralı kombinasyonu denemek. Kupon süresi dolmuş mu, kod bilinmiyor mu, yüzde sınırları, yuvarlama: her biri için bir E2E testi, tarayıcı, Angular ve API'yle saniyeler demek. Bu kombinasyonlar integration testlerinde; CouponTests geçerli kuponla ödenen tutarı, süresi dolmuş ve bilinmeyen kuponla da ödeme denenmeden dönen 400'ü doğruluyor ve üçü birlikte milisaniyeler sürüyor. E2E'deki tek kupon testi yalnız parçaların birbirine bağlandığını gösteriyor: arayüzdeki kupon kutusu, API'deki indirim ve sipariş sayfasındaki tutar.

Bellekteki durumu page.goto ile kaybetmek. Sepet Angular'da bir signal'da duruyor. Sepete ekledikten sonra page.goto('/cart') demek uygulamayı baştan yüklüyor ve sepet boş açılıyor; test "Emma … hücresi görünmedi" diye 5 saniye sonra kalıyor. Kullanıcı sepete başlıktaki bağlantıyla gidiyor, test de öyle gitmeli. Sayfayı yenilemeye dayanıklı olmak bir gereksinimse o ayrı bir test olmalı.

Küçük Bir Deneme

★☆☆ Host'u dotnet run --project tests/Bookstore.E2E.Host ile ayrı bir terminalde başlat, sonra npx playwright test'i iki kez çalıştır. İkinci koşunun süresini birinciyle karşılaştır ve veritabanındaki kitap sayısının nasıl arttığını /api/books'tan kontrol et.

★★☆ Sipariş testine ikinci bir kitap ekle, sepette adedi 2 yap ve kuponlu toplamın kuruş yuvarlamasını kontrol et. Angular'daki indirim hesabı ile API'deki Math.Round aynı sonucu veriyor mu? Vermiyorsa hangi test bunu yakalamalı?

★★★ Host'a E2E_API_PORT ortam değişkenini okuyan bir ayar ekle, Angular proxy config'ini proxy.conf.mjs'e çevirip aynı değişkeni okut ve playwright.config.ts'te iki webServer'a aynı portu ver. Böylece aynı makinede iki koşu birbirine karışmadan çalışabiliyor mu?

Suite şimdilik yeşil ve hızlı. Ama 6. yazıdaki arama testi ilk koşularda iki kez kaldı ve ikinci kez çalıştırınca geçti; bunlardan biri yukarıdaki paylaşılan veriydi, diğerinin nedeni henüz ortada değil. Bir sonraki yazı tam bu konuda: flaky E2E testleri; paylaşılan veri, zaman, animasyon ve ağ kaynaklı belirsizlik, worker paralelliği ve sharding, retry'ın neyi gizlediği, page.route ile network mocking ve görsel karşılaştırma.