.NET'te BDD: Reqnroll 3 ile Gherkin Senaryolarını Bookstore API'sine Bağlamak
SpecFlow 31 Aralık 2024'te sona erdi; .NET'te Gherkin senaryolarını çalıştırmanın güncel yolu Reqnroll 3 ve xUnit v3 için Reqnroll.xunit.v3 paketidir. Bookstore'un kurumsal sipariş senaryolarını .NET 10'da proje kurulumu, step definition'lar, context injection, WebApplicationFactory, hata anında teşhis yapan hook, HTML formatter ile living documentation ve tag ile filtreleme üzerinden API'ye bağlıyor; Türkçe kültürde gözlenen bir tuzağı da gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Reqnroll 3.3.4 (Reqnroll.xunit.v3), xUnit v3 4.0.3 ve Microsoft.AspNetCore.Mvc.Testing 10.0.15 ile derlenip denenmiştir.
Önce Tahmin Et
Önceki yazının feature dosyası hazır; şimdi onu Bookstore API'sine bağlayacak kodu yazma sırası. Ayşe işe başlamadan önce internette "SpecFlow xUnit WebApplicationFactory" diye arıyor ve 2021 tarihli, oldukça popüler bir blog yazısı buluyor. Yazıda SpecFlow.xUnit paketi kuruluyor, step'ler TechTalk.SpecFlow namespace'inden geliyor ve adımlar arasında veri ScenarioContext.Current["order"] ile taşınıyor.
İki soru. Bu yazıyı adım adım izleyen Ayşe, .NET 10 üzerinde çalışan bir test projesi elde eder mi? Edemezse, sorun paket adında mı, namespace'te mi, yoksa adımlar arasında veri taşıma biçiminde mi?
SpecFlow'dan Reqnroll'a
Cevabın ilk yarısı tarihte. .NET'te Gherkin senaryolarını, yani BDD (Behaviour-Driven Development; davranış odaklı geliştirme) spesifikasyonlarını çalıştırmanın uzun yıllar boyunca standart aracı SpecFlow'du. Projenin sahibi Tricentis, Aralık 2024'te kısa bir süre önceden haber vererek SpecFlow'un 31 Aralık 2024'te sona ereceğini duyurdu. Reqnroll ekibinin 9 Ocak 2025 tarihli duyurusuna göre 1 Ocak 2025'te projenin GitHub repository'leri silindi ve destek sayfası kapatıldı; NuGet paketleri ise nuget.org silmeye izin vermediği için yerinde duruyor, ama yeni .NET sürümleri için güncelleme almayacak. Aynı duyuruya göre SpecFlow en fazla .NET 7'yi destekliyordu.
Reqnroll, SpecFlow'un kod tabanı üzerine 2024'ün başında başlatılmış bir topluluk fork'u. "SpecFlow" adı Tricentis'in tescilli markası olduğu için proje yeni bir adla devam ediyor. Kod tabanı ortak olduğu için geçiş görece kolay ama zahmetsiz değil: paket adları Reqnroll.* oldu, namespace TechTalk.SpecFlow yerine Reqnroll. xUnit v3 desteği 29 Eylül 2025'te çıkan 3.1 sürümüyle Reqnroll.xunit.v3 paketi olarak geldi; yazının yazıldığı gün NuGet'teki son sürüm 3.3.4.
Cevabın ikinci yarısı veri taşıma biçiminde. Ayşe blog yazısındaki ScenarioContext.Current satırını Reqnroll projesine kopyaladığında derleyici şunu söylüyor:
warning CS0618: 'ScenarioContext.Current' is obsolete: 'Please get the ScenarioContext via Context Injection - https://go.reqnroll.net/doc-migrate-sc-current'
Statik bir "şu anki senaryo" fikri, senaryolar paralel çalışmaya başladığında anlamını yitirir; Reqnroll bu yüzden context injection'ı öneriyor. Aşağıda kullandığımız yaklaşım tam olarak bu.
Proje Kurulumu
Çözümde iki proje var: Bookstore.Api ve onu test eden Bookstore.Specs. 07. yazıdaki feature dosyası Bookstore.Specs/Features klasöründe duruyor. Test projesinin dosyası:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.15" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.12.0" />
<PackageReference Include="Reqnroll.xunit.v3" Version="3.3.4" />
<PackageReference Include="xunit.v3" Version="4.0.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="..\..\src\Bookstore.Api\Bookstore.Api.csproj" />
</ItemGroup>
</Project>
Reqnroll.xunit.v3 hem Reqnroll'u hem de feature dosyalarından test class'ları üreten MSBuild generator'ını getiriyor; ayrıca bir generator paketi eklemeye gerek yok. xUnit v3 test projeleri bir executable olarak derlendiği için OutputType değeri Exe. Microsoft.NET.Test.Sdk ve xunit.runner.visualstudio, Reqnroll dokümanının xUnit v3 için listelediği paketler; Visual Studio'nun Test Explorer'ı testleri bu ikisi üzerinden bulur. Using Include="Xunit" satırı, step definition'larda Assert'i her dosyada using yazmadan kullanmayı sağlıyor.
İlk dotnet test denemesi ise kırmızı:
$ dotnet test
Microsoft.Testing.Platform.MSBuild.targets(395,5): error : Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. If you use dotnet test, you should opt-in to the new dotnet test experience. For more information, see https://aka.ms/dotnet-test-mtp-error
Hata, xUnit v3 4.0.3 ile projeye gelen Microsoft Testing Platform entegrasyonundan geliyor: .NET 10 SDK'da dotnet test, bu platformu kullanan projeleri eski VSTest yoluyla çalıştırmıyor ve yeni çalışma biçiminin açıkça seçilmesini istiyor. Seçim, çözümün kökündeki global.json dosyasıyla yapılıyor:
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
Son parça Reqnroll'un kendi ayar dosyası. Feature dosyalarının dilini ve yazının ilerisinde konuşacağımız HTML raporunu burada tanımlıyoruz:
{
"$schema": "https://schemas.reqnroll.net/reqnroll-config-latest.json",
"language": {
"feature": "en-US"
},
"formatters": {
"html": {
"outputFilePath": "reports/institutional-orders.html"
}
}
}
Reqnroll dokümanı feature dili için en gibi nötr bir kültür yerine en-US gibi belirli bir kültürü öneriyor. Ayrı bir language.binding ayarı verilmezse step argümanları da bu kültürle çevriliyor; bunun Türkiye'deki bir makinede neden önemli olduğuna Üretimde Dikkat bölümünde döneceğiz.
Step Definition ve Binding
Bir step definition, Gherkin'deki bir adımın metnini bir C# method'una bağlar. [Binding] attribute'u taşıyan class'lar Reqnroll tarafından taranır; [Given], [When] ve [Then] attribute'larındaki metin, adımla eşleşen kalıptır. Katalog adımı şöyle:
using System.Globalization;
using Bookstore.Api.Catalog;
using Bookstore.Specs.Support;
using Reqnroll;
namespace Bookstore.Specs.StepDefinitions;
[Binding]
public sealed class CatalogSteps(BookstoreApi api)
{
[Given("the catalog contains these books:")]
public void GivenTheCatalogContainsTheseBooks(DataTable table)
{
foreach (DataTableRow row in table.Rows)
{
api.AddBook(new Book(
row["ISBN"],
row["Title"],
decimal.Parse(row["List price"], CultureInfo.InvariantCulture),
int.Parse(row["Stock"], CultureInfo.InvariantCulture),
decimal.Parse(row["Campaign discount"].TrimEnd('%'), CultureInfo.InvariantCulture) / 100m));
}
}
}
Tablodaki her satır bir Book kaydına dönüşüyor. Sayıları CultureInfo.InvariantCulture ile açıkça çevirmek, tablonun "250.00" yazımının makinenin kültüründen bağımsız okunmasını sağlıyor. Sipariş adımları:
using System.Globalization;
using Bookstore.Api.InstitutionalOrders;
using Bookstore.Specs.Support;
using Reqnroll;
namespace Bookstore.Specs.StepDefinitions;
[Binding]
public sealed class InstitutionalOrderSteps(InstitutionalOrderDriver orders)
{
[When("the institution orders {int} copies of {string}")]
public Task WhenTheInstitutionOrdersCopiesOf(int copies, string title) =>
orders.PlaceByTitleAsync([(title, copies)]);
[When("the institution orders:")]
public Task WhenTheInstitutionOrders(DataTable table) =>
orders.PlaceByTitleAsync(table.Rows.Select(r =>
(r["Title"], int.Parse(r["Copies"], CultureInfo.InvariantCulture))));
[When("the institution submits this ISBN list:")]
public Task WhenTheInstitutionSubmitsThisIsbnList(DataTable table) =>
orders.PlaceAsync(table.Rows.Select(r =>
new OrderLineRequest(r["ISBN"], int.Parse(r["Copies"], CultureInfo.InvariantCulture))));
[Then("the order is accepted")]
public void ThenTheOrderIsAccepted() => orders.AcceptedOrder();
[Then("the order is rejected")]
public void ThenTheOrderIsRejected() => Assert.NotEmpty(orders.RejectionReasons());
[Then("the order is rejected because {string}")]
public void ThenTheOrderIsRejectedBecause(string reason) =>
Assert.Contains(orders.RejectionReasons(), r => r.Contains(reason, StringComparison.Ordinal));
[Then("{string} is charged {decimal} with a {int}% discount")]
public void ThenTheLineIsCharged(string title, decimal lineTotal, int discountPercent)
{
InstitutionalOrderResponse order = orders.AcceptedOrder();
OrderLineResponse line = Assert.Single(order.Lines, l => l.Title == title);
Assert.Equal(discountPercent / 100m, line.AppliedDiscountRate);
Assert.Equal(lineTotal, line.LineTotal);
}
[Then("the order total is {decimal}")]
public void ThenTheOrderTotalIs(decimal total) =>
Assert.Equal(total, orders.AcceptedOrder().Total);
[Then("the whole order waits until every title is in stock")]
public void ThenTheWholeOrderWaitsForStock() =>
Assert.Equal("WaitingForStock", orders.AcceptedOrder().Status);
}
Attribute'lardaki {int}, {string} ve {decimal} birer Cucumber expression parametresi. Regular expression'a göre çok daha okunur ve Reqnroll eşleşen metni method'un parametre tipine kendisi çeviriyor. Scenario Outline'daki the order is <outcome> adımı için ayrı bir binding yok: Reqnroll yer tutucuyu doldurduktan sonra adım "the order is accepted" ya da "the order is rejected" olur ve mevcut iki binding'den biriyle eşleşir.
Bu class'ta neyin olmadığına da dikkat et: indirim hesabı, yuvarlama, minimum adet kontrolü. Step'ler yalnızca senaryodaki cümleyi bir isteğe ya da bir karşılaştırmaya çeviriyor. Bir adımın binding'i yoksa Reqnroll senaryoyu başarısız sayar ve eksik binding'in iskeletini önerir. 07. yazının başındaki imperative senaryoyu bu projede çalıştırınca çıktının başı şöyle:
$ dotnet test
failed Discount on a school order (273ms)
Reqnroll.xUnit3.ReqnrollPlugin.XUnitPendingException : Test pending: No matching step definition found for one or more steps.
using System;
using Reqnroll;
namespace MyNamespace
{
[Binding]
public class StepDefinitions
{
private readonly IReqnrollOutputHelper _outputHelper;
public StepDefinitions(IReqnrollOutputHelper outputHelper)
{
_outputHelper = outputHelper;
}
[Given("I open {string}")]
public void GivenIOpen(string p0)
{
throw new PendingStepException();
}
...
Önerilen iskelet bir başlangıç, kopyalanacak son hâl değil. p0 gibi parametre adlarını ve GivenIOpen gibi method adlarını iş diline çevirmek ve aynı kalıbın ikinci kez önerildiği yerleri tek binding'de birleştirmek senin işin. Bu örnekteki doğru tepki ise binding yazmak değil, senaryoyu 07. yazıdaki gibi declarative hâle getirmek.
Context Injection: Senaryo Başına Bir API
Step class'ları constructor'larında başka class'lar istiyor: InstitutionalOrderSteps bir InstitutionalOrderDriver, o da bir BookstoreApi. Reqnroll'un context injection'ı bu zinciri senaryo başına bir kez kurar: aynı senaryodaki bütün step class'ları aynı driver'ı ve aynı API instance'ını alır, senaryo bitince IDisposable olan nesneler dispose edilir. Adımlar arasında veri, statik bir sözlükle değil bu paylaşılan nesnelerle taşınır.
using Bookstore.Api.Catalog;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace Bookstore.Specs.Support;
// Senaryo başına bir instance: Reqnroll onu context injection ile oluşturur
// ve senaryo bitince dispose eder.
public sealed class BookstoreApi : IDisposable
{
private readonly InMemoryCatalog _catalog = new();
private readonly Dictionary<string, Book> _booksByTitle = [];
private readonly WebApplicationFactory<Program> _factory;
public BookstoreApi()
{
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
builder.UseSetting("Logging:LogLevel:Default", "Warning");
builder.ConfigureTestServices(services => services.AddSingleton<ICatalog>(_catalog));
});
Client = _factory.CreateClient();
}
public HttpClient Client { get; }
public void AddBook(Book book)
{
_catalog.Add(book);
_booksByTitle[book.Title] = book;
}
public Book BookTitled(string title) => _booksByTitle[title];
public void Dispose()
{
Client.Dispose();
_factory.Dispose();
}
}
BookstoreApi, API'yi WebApplicationFactory ile bellekte ayağa kaldırıyor ve ConfigureTestServices ile uygulamanın ICatalog kaydını senaryonun kendi kataloğuyla değiştiriyor. Böylece Background'daki tablo, API'nin gördüğü katalog oluyor. .NET 10'da minimal API projelerinin Program class'ı varsayılan olarak public üretildiği için WebApplicationFactory<Program> ek bir tanım gerekmeden derleniyor. Custom factory, test authentication ve configuration gibi ayrıntılar, uçtan uca (E2E) testleri de kapsayan Integration ve E2E Test serisinin WebApplicationFactory yazısının konusu.
Her senaryo için yeni bir factory kurmak pahalı görünebilir; bu projede on senaryonun tamamı bir saniye civarında bitiyor. Karşılığında her senaryo kendi kataloğuyla, diğerlerinden tamamen izole çalışıyor. Senaryo sayısı yüzleri bulduğunda factory'yi feature başına paylaşmak ve durumu senaryo başında sıfırlamak düşünülebilir; ama o zaman paralel çalışan senaryoların aynı durumu paylaşmadığından emin olmak gerekir.
Step'ler ile HTTP arasında bir katman daha var: driver.
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Bookstore.Api.InstitutionalOrders;
using Microsoft.AspNetCore.Mvc;
namespace Bookstore.Specs.Support;
// HTTP'yi bu class konuşur, step definition'lar iş dilini konuşsun diye.
public sealed class InstitutionalOrderDriver(BookstoreApi api)
{
public HttpResponseMessage? LastResponse { get; private set; }
public string LastResponseBody { get; private set; } = "";
public Task PlaceByTitleAsync(IEnumerable<(string Title, int Copies)> lines) =>
PlaceAsync(lines.Select(l => new OrderLineRequest(api.BookTitled(l.Title).Isbn, l.Copies)));
public async Task PlaceAsync(IEnumerable<OrderLineRequest> lines)
{
PlaceInstitutionalOrderRequest request = new("PO-2026-0412", lines.ToList());
LastResponse = await api.Client.PostAsJsonAsync("/api/institutional-orders", request);
LastResponseBody = await LastResponse.Content.ReadAsStringAsync();
}
public InstitutionalOrderResponse AcceptedOrder()
{
Assert.Equal(HttpStatusCode.Created, LastResponse?.StatusCode);
return JsonSerializer.Deserialize<InstitutionalOrderResponse>(LastResponseBody, JsonSerializerOptions.Web)!;
}
public IReadOnlyList<string> RejectionReasons()
{
Assert.Equal(HttpStatusCode.UnprocessableEntity, LastResponse?.StatusCode);
ValidationProblemDetails problem =
JsonSerializer.Deserialize<ValidationProblemDetails>(LastResponseBody, JsonSerializerOptions.Web)!;
return problem.Errors.SelectMany(e => e.Value).ToList();
}
}
Driver, istek gövdesinin biçimini, endpoint adresini, HTTP durum kodlarını ve problem details yanıtını bilen tek yer. Step definition'lar "sipariş kabul edildi" ya da "şu sebeple reddedildi" diye konuşuyor; endpoint'in adresi değişirse tek bir satır değişiyor, step'ler ve feature dosyası değişmiyor. Reqnroll'un kendi container'ı yerine Microsoft'un DI (Dependency Injection; nesnelerin bağımlılıklarını bir container'ın vermesi) container'ını kullanmak isteyen projeler için Reqnroll.Microsoft.Extensions.DependencyInjection plugin'i de var; bu küçük projede yerleşik context injection yetiyor.
Hook'lar ve Hata Anında Teşhis
Hook'lar test yaşam döngüsünün belirli anlarında çalışan method'lardır: test koşusunun, feature'ın, senaryonun ya da adımın öncesi ve sonrası. Bookstore ekibinin tek hook'u, senaryo başarısız olduğunda API'nin son yanıtını çıktıya yazıyor:
using Reqnroll;
namespace Bookstore.Specs.Support;
[Binding]
public sealed class DiagnosticsHooks(
ScenarioContext scenario,
InstitutionalOrderDriver orders,
IReqnrollOutputHelper output)
{
[AfterScenario]
public void WriteLastResponseWhenScenarioFails()
{
if (scenario.TestError is not null && orders.LastResponse is not null)
{
output.WriteLine($"Last response: {(int)orders.LastResponse.StatusCode} {orders.LastResponseBody}");
}
}
}
IReqnrollOutputHelper, Reqnroll'un test framework'ünden bağımsız çıktı arayüzü; xUnit'in kendi output helper'ına bağlanmadan senaryonun çıktısına yazmayı sağlıyor. Hook'un değerini görmek için API'deki yuvarlamayı bilerek bozalım ve MidpointRounding.AwayFromZero yerine .NET'in varsayılanı olan MidpointRounding.ToEven'ı yazalım. 05. yazıda muhasebenin cevabı gelmeseydi yazılacak kod tam olarak buydu:
$ dotnet test
failed Each line total is rounded to the cent, half away from zero (38ms)
Assert.Equal() Failure: Values differ
Expected: 229.37
Actual: 229.36
Standard output
Given the catalog contains these books:
--- table step argument ---
| ISBN | Title | List price | Stock | Campaign discount |
| 9786050000016 | Grade 5 Mathematics | 250.00 | 500 | 0% |
| 9786050000023 | Grade 5 Science | 84.95 | 500 | 0% |
| 9786050000030 | Short Stories Reader | 120.00 | 15 | 25% |
-> done: CatalogSteps.GivenTheCatalogContainsTheseBooks(<table>) (0,0s)
When the institution orders:
--- table step argument ---
| Title | Copies |
| Grade 5 Mathematics | 20 |
| Grade 5 Science | 3 |
-> done: InstitutionalOrderSteps.WhenTheInstitutionOrders(<table>) (0,0s)
Then "Grade 5 Science" is charged 229.37 with a 10% discount
-> error: Assert.Equal() Failure: Values differ
Expected: 229.37
Actual: 229.36 (0,0s)
And the order total is 4729.37
-> skipped because of previous errors
Last response: 201 {"orderId":"3a17164c-ac7a-48ad-8f8a-25243cc98090","status":"ReadyToShip","totalQuantity":23,"listTotal":5254.85,"total":4729.36,"lines":[{"isbn":"9786050000016","title":"Grade 5 Mathematics","quantity":20,"appliedDiscountRate":0.10,"lineTotal":4500.00},{"isbn":"9786050000023","title":"Grade 5 Science","quantity":3,"appliedDiscountRate":0.10,"lineTotal":229.36}]}
Çıktı, kırmızı testin neden kırmızı olduğunu iş diliyle anlatıyor: hangi senaryo, hangi adım, beklenen ve gerçekleşen tutar, sonraki adımın neden atlandığı ve API'nin döndürdüğü yanıtın tamamı. 229,37 ile 229,36 arasındaki tek kuruşluk farkın arkasındaki kural, senaryonun adında yazıyor.
Living Documentation: Formatter'lar ve Tag'ler
Feature dosyaları, düzenli çalıştırıldıkları sürece sistemin bugünkü davranışının güncel bir dokümanıdır; buna living documentation denir. Reqnroll 3 bu dokümanı iki yerleşik formatter ile üretiyor: html, feature'ları, kuralları, senaryoları ve sonuçlarını tek bir HTML dosyasında gösteriyor; message ise aynı bilgiyi Cucumber Messages biçiminde, başka araçların okuyabileceği NDJSON (her satırı ayrı bir JSON nesnesi olan metin dosyası) olarak yazıyor. Yukarıdaki reqnroll.json ile her dotnet test sonrasında test projesinin çıktı klasöründe reports/institutional-orders.html oluşuyor; bu projede dosya yaklaşık 1 MB ve harici bir script ya da stylesheet referansı içermiyor, tek başına açılıyor. Reqnroll dokümanı bu formatter'ı SpecFlow+ LivingDoc'un varsayılan yerine geçen araç olarak tanımlıyor. Ayar dosyasına dokunmadan, örneğin yalnız CI'da rapor üretmek için REQNROLL_FORMATTERS_HTML ortam değişkeni de kullanılabilir.
Raporu CI'da her build'in artifact'ı olarak sakla ve Selin'in bulabileceği bir yere bağla. Product Owner'ın bir kuralın bugün nasıl çalıştığını sormak için bir geliştiriciye yazması gerekmiyorsa, living documentation işini yapıyor demektir.
07. yazıda kurala koyduğumuz @REQ-BULK-05 tag'i burada işe yarıyor. Reqnroll tag'leri xUnit'e Category trait'i olarak aktarıyor; böylece yalnızca indirim kuralının senaryoları çalıştırılabiliyor:
$ dotnet test --filter-trait "Category=REQ-BULK-05"
Test run summary: Passed!
total: 3
failed: 0
succeeded: 3
skipped: 0
duration: 663ms
Kuralın üç senaryosu çalıştı, diğer yedisi çalışmadı. İlk yazıda requirement'a verdiğimiz kimlik, artık o requirement'ın testlerini seçen bir filtre. Traceability için ağır bir matris yerine bu kadarı çoğu ekipte yeter: requirement kimliği story'de, tag'de ve commit mesajında aynı.
TypeScript Tarafı ve BDD'nin Değmediği Yer
Aynı feature dosyaları TypeScript tarafında da çalıştırılabilir. Cucumber'ın JavaScript uygulaması @cucumber/cucumber (yazının yazıldığı gün 13.3.0) Gherkin'i okur, step definition'lar TypeScript'te yazılır ve tarayıcıyı Playwright yönetir. Playwright Test'in kendi runner'ını kullanmak isteyenler için feature dosyalarını Playwright testlerine çeviren playwright-bdd paketi de var. Bu yol, senaryoları arayüz üzerinden doğrulamak gerektiğinde anlamlı; ama 07. yazıda gördüğümüz gibi iş kurallarının çoğunu API seviyesinde doğrulamak hem daha hızlı hem daha az kırılgan. Playwright'ın kendisi Integration ve E2E Test serisinin konusu.
BDD aracının değmediği durumlar da var ve bunları söylemek dürüstlük gereği. Tek kişilik bir ekipte konuşulacak başka biri yoksa, Gherkin katmanı yalnızca bir çeviri adımı ekler. Stakeholder'lar senaryoları hiç okumuyorsa ve okumayacaksa, feature dosyaları sıradan testlerden daha pahalı bir test biçimine dönüşür; o durumda example map'teki kuralları ve örnekleri doğrudan xUnit testlerine çevirmek daha ucuzdur. Example Mapping ve example'larla konuşma alışkanlığı ise bu durumlarda da değerini korur: BDD'nin en değerli parçası araç değil, discovery.
Üretimde Dikkat
Paralel çalışma: xUnit farklı test class'larını, yani farklı feature dosyalarını paralel çalıştırır. Context injection ile kurulan nesneler senaryoya özel olduğu için güvende; statik alanlarda tutulan durum ise değil. Paylaşılması gereken pahalı bir kaynak varsa (bir veritabanı container'ı gibi) onu test koşusu seviyesindeki bir hook'ta kur ve senaryolar arasında durumunu nasıl izole edeceğine baştan karar ver. Reqnroll'un generator.addNonParallelizableMarkerForTags ayarı, belirli tag'leri taşıyan feature'ları paralel çalışmanın dışında tutmaya yarıyor.
Türkçe kültür: Bu yazının kodu, işletim sistemi kültürü tr-TR olan bir makinede derlendi ve iki şey gözlendi. Birincisi beklenen ve iyi olan: {decimal} parametresi "229.37" metnini feature dilinin kültürüyle, yani en-US ile okudu ve testler geçti. İkincisi sürpriz: Reqnroll'un senaryo adlarından ürettiği test method adlarında büyük "I" harfi Türkçe kurala göre "İ" oldu; örneğin TheİnstitutionalDiscountİsAppliedToEveryLine. Aynı proje en-US kültürüyle derlendiğinde method adı TheInstitutionalDiscountIsAppliedToEveryLine oluyor. Testlerin görünen adı senaryo başlığından geldiği için çıktı aynı kalıyor, ama tam method adına göre filtreleme yapan ya da CI'da test geçmişini method adıyla izleyen araçlar, Türkçe kültürlü bir geliştirici makinesi ile en-US kültürlü bir build agent'ını iki farklı test olarak görebilir. Filtrelemeyi method adı yerine tag'lerle yapmak bu sorundan kaçınmanın en kolay yolu.
Süre: Senaryolar her CI build'inde çalışmalı, bu yüzden hızlı kalmaları gerekiyor. Bu projede on senaryo bir saniye civarında bitiyor çünkü API bellekte, tarayıcı yok ve dış bağımlılık yok. Senaryo sayısı arttıkça süreyi izle; yavaşlayan bir senaryo seti, ekibin onu yerelde çalıştırmayı bırakmasının ilk adımıdır.
Sık Düşülen Tuzaklar
İlk tuzak step definition'larda iş mantığı biriktirmek. "Beklenen toplamı step içinde hesaplayıp API'nin sonucuyla karşılaştırmak" cazip görünür, ama o zaman test, kuralın ikinci bir uygulamasını test eder ve iki uygulama aynı hatayı yapabilir. Kural tek yerde, uygulamada yaşar:
public static class InstitutionalOrderRules
{
public const int MinimumTotalQuantity = 20;
public const decimal InstitutionalDiscountRate = 0.10m;
// Kampanya ve kurumsal indirim üst üste binmez; büyüğü uygulanır.
public static decimal DiscountRateFor(Book book) =>
Math.Max(InstitutionalDiscountRate, book.CampaignDiscountRate);
// Muhasebe her satır toplamını kuruşa yuvarlar, yarım kuruş yukarı.
public static decimal LineTotal(Book book, int quantity) =>
Math.Round(book.ListPrice * quantity * (1 - DiscountRateFor(book)), 2, MidpointRounding.AwayFromZero);
}
Step'ler ise yalnızca senaryodaki beklenen sayıyı API'nin sonucuyla karşılaştırır. Beklenen sayı feature dosyasında, Example Mapping'de konuşulmuş bir örnek olarak duruyor; hesaplanmıyor.
İkinci tuzak aynı adımın onlarca varyasyonu. "the institution orders 19 copies of X", "the school orders 19 books of X", "19 copies of X are ordered by the institution" gibi her senaryoda biraz farklı yazılmış cümleler, her biri için ayrı bir binding üretir ve step kütüphanesini kimsenin hatırlayamayacağı bir yığına çevirir. Bu projede sipariş için üç binding var: tek kitap, kitap adlarıyla tablo ve ISBN'lerle (kitabın uluslararası standart numarası) tablo. Yeni bir senaryo yazarken önce mevcut adımlara bak ve aynı şeyi söyleyen bir adım varsa onu kullan.
Üçüncüsü eski SpecFlow blog örneklerini kopyalamak. Önce Tahmin Et'teki yazı tek örnek değil; internetteki .NET BDD örneklerinin önemli bir kısmı SpecFlow dönemine ait. Paket adları, namespace'ler, ScenarioContext.Current ve SpecFlow+ LivingDoc gibi artık olmayan araçlar bu örneklerde sık geçer. Örnek kodun tarihine ve hangi paketi kullandığına bak; şüphedeysen Reqnroll'un kendi dokümanındaki örnekten başla.
Dördüncüsü feature dosyasını step'lere göre yazmak. Mevcut binding'lere uysun diye senaryonun cümlesini bozmak, okuyucuyu koda feda etmektir. Cümle Selin için doğru değilse binding'i değiştir, senaryoyu değil.
Küçük Bir Deneme
★☆☆ Bu yazıdaki projeyi kur ve dotnet test ile on senaryonun geçtiğini gör. Sonra InstitutionalOrderRules içindeki InstitutionalDiscountRate değerini 0.12 yap ve hangi senaryoların, hangi mesajla kırmızıya döndüğünü incele.
★★☆ 05. yazıda ikinci release'e kalan "eksikleri sonra gönder" dilimi için bir Rule ve iki senaryo yaz. Önce binding'siz çalıştırıp Reqnroll'un önerdiği iskeleti incele; sonra binding'leri mevcut driver'ı genişleterek, step'lere iş mantığı koymadan yaz.
★★★ HTML formatter'ın ürettiği raporu bir stakeholder'a göster ve rapordan bir kuralın bugünkü davranışını kendi başına bulup bulamadığını izle. Bulamadığı yerde sorunun raporda mı, Feature ve Rule başlıklarında mı olduğuna karar ver ve feature dosyasını buna göre düzelt.
Bu yazıyla Requirements Analysis serisi tamamlanıyor. Hakan'ın "Excel'den yükleme ekranı" e-postasından yola çıktık; elicitation ile gerçek ihtiyacı bulduk, use case'ten story'lere, story map'ten ilk release'e geçtik, kuralları Example Mapping ile örneklerle netleştirdik, kalite beklentilerini ölçülebilir yazdık ve sonunda bu örnekleri her build'de çalışan, Selin'in okuyabildiği bir spesifikasyona çevirdik. Buradan sonrası için iki doğal yol var: senaryoların altındaki birim testlerini önce test yazarak geliştirmek için TDD (Test-Driven Development; testi koddan önce yazma pratiği) serisi, aynı API'yi gerçek bir veritabanı ve tarayıcıyla uçtan uca doğrulamak için Integration ve E2E Test serisi.