Bir Web API’nin Test Serüvenleri

Yazılım süreçleri gün geçtikçe karmaşıklaşıyor. Yeni çıkan teknolojiler, geliştirilmesi gereken özellikler, arttırılması gereken performanslar ve tüm bunlara uyum sağlayabilen geliştirici bir takım oluşturmak zor mesele…

İşte bu problemlerin çözümünün bir parçası olarak Test Driven Development (TDD) çıkıyor karşımıza. Basitçe; test edilebilir kod yazmamızı salık veren bu anlayış, en azından geliştiricileri olası tüm ihtimalleri değerlendirme konusunda disipline ediyor.

Bu yazımda, Web API uygulaması geliştirirken karşımıza çıkabilecek test senaryolarını ele almaya çalışacağım. Baştan belirtmeliyim; amacım TDD yaklaşımını anlatmak değil. Sadece bir ASP.NET Core ile geliştirilmiş bir RestFul  Web API’ nin fonksiyonel testlerinin nasıl yapılabileceğine birlikte bir göz atalım istiyorum.

Dayanamayacağım ve bu yazının en eğlenceli kısmını şimdi söyleyeceğim. Eğer bir Web API geliştirmişseniz, çok büyük bir ihtimalle Postman (ki ben kendisine doğrudan Postacı diyorum) uygulamasını kullanmışsınızdır. Bu uygulama sayesinde; bir istemci inşa etmeden, API’nizin  request ve response süreçlerini test edebilyorsunuz. Fakat bu testleri hem fonksiyonel anlamda kullanmak hem de belgelemek istediğinizde, Postacı size pek de yardımcı olmuyor. İşte bu ihtiyacı karşılamak için çok tatlı bir kütüphanemiz var .NET Core içinde (şimdiden söylemeyeyim de okumaya devam edin 😊)

Evet bu kadar girizgah yeterli. Hadi bakalım. Demo’ya başlıyoruz.

Demo: ASP.NET Core Web API Uygulaması

Gerçekçi olmak adına, demo içerisinde Entity Framework Core çatısını ve buna bağlı olarak da Dependency Injection gibi mimari gereksinimleri kullanacağımı söylemek istiyorum. Madem ki bir serüvenden bahsediyoruz, o zaman olabildiğince detaylı olarak  projemi inşa edeceğim. Projemin ismi: Products.API

Tabii detaylı olması, karmaşık olması anlamına gelmiyor. Demo’yu basit tutmak adına yalnızca bir modelim yeterli olacak. Tamam belki çok klişe ama ne yapalım gayet de işe yarıyor. Gelsin Product modeli

Products.API/Models/Product.cs


namespace Products.API.Models
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        public int Stock { get; set; }

    }
}

Dediğim gibi EF Core çatısını projeme dahil edeceğim bu da Nuget Package dostumuzdan faydalanmak demek. Gelsin aşağıdaki paketler o zaman:

  • Microsoft.EntityFrameworkCore
  • Microsoft.EntityFrameworkCore.SqlServer
  • Microsoft.EntityFrameworkCore.Tools

Şimdi de sıra, DbContext sınıfımı oluşturmaya geldi. Eh elimizde zaten bir tanecik modelimiz var. DbContext de minnacık bir şey olacak işte:

Products.API/Data/ProductDbContext.cs

using Microsoft.EntityFrameworkCore;
using Products.API.Models;

namespace Products.API.Data
{
    public class ProductDbContext : DbContext
    {
        public ProductDbContext(DbContextOptions<ProductDbContext> options) : base(options)
        {

        }
        public DbSet<Product> Products { get; set; }

    }
}

Eveet, dediğim gibi bu projeyi biraz daha gerçekçi inşa etmeye çalışacağım ki özellikle Test süreçlerine daha anlamlı bir yolculuk yapabilelim. Niye sözü uzatıyorum? Bir servis modülü inşa edeceğim de ondan!

Products.API/Services/IProductService.cs

using System.Collections.Generic;
using Products.API.Models;

namespace Products.API.Services
{
    public interface IProductService
    {
        IEnumerable<Product> GetProducts();
        Product GetProduct(int id);
        Product Add(Product product);
        Product Edit(Product product);
        Product Delete(int id);

    }
}

Eh elbette bu interface’i implemente eden bir de sınfım olmalı

Products.API/Services/ProductService.cs

using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using System.Linq;
using Products.API.Data;
using Products.API.Models;

namespace Products.API.Services
{
    public class ProductService : IProductService
    {
        private ProductDbContext dbContext;

        public ProductService(ProductDbContext dbContext)
        {
            this.dbContext = dbContext;
        }
        public Product Add(Product product)
        {
            var entity = dbContext.Products.Add(product).Entity;
            dbContext.SaveChanges();
            return entity;
        }

        public Product Delete(int id)
        {
            var product = dbContext.Products.Find(id);
            var deletedProduct = dbContext.Products.Remove(product).Entity;
            return deletedProduct;
        }

        public Product Edit(Product product)
        {
            dbContext.Entry(product).State = EntityState.Modified;
            dbContext.SaveChanges();
            return product;
        }

        public Product GetProduct(int id)
        {
            return dbContext.Products.Find(id);
        }

        public IEnumerable<Product> GetProducts()
        {
            return dbContext.Products.ToList();
        }
    }
}

Aman dikkat! ProductService sınıfının constructor’unda Dependency Injection mekanizması oluşturduğuma dikkat edin! Yani constructor’da ProductDbContext instance’ini aldığıma dikkat edin.

Eh burada bir Dependency Injection yapısı oluştuğuna göre; Startup.cs sınıfına gidip IServiceCollection interface’ine gerekli eklemeleri yapmam gerek:

Products.API/Startup.cs

public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ProductDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("db")))
                    .AddControllers();

            services.AddTransient<IProductService, ProductService>();
        }
/* devam ediyor… */
     }

İşin bu noktasında, veritabanımızı oluşturabiliriz bence. Elbette migration komutlarını kullanarak 😊

  • Add-Migration initialize
  • Update-Database

Pekala. Tam gaz devam ediyoruz ve son olarak ProductsController sınıfımızı oluşturuyoruz:

Products.API/Controllers/ProductsController.cs

using Microsoft.AspNetCore.Mvc;
using Products.API.Models;
using Products.API.Services;

namespace Products.API.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ProductsController : ControllerBase
    {
        private IProductService productService;

        public ProductsController(IProductService productService)
        {
            this.productService = productService;
        }
        [HttpGet]
        public IActionResult GetProducts()
        {
            var products = productService.GetProducts();
            return Ok(products);
        }
        [HttpGet("{id}")]
        public IActionResult GetProductById(int id)
        {
            return Ok(productService.GetProduct(id));
        }
        [HttpPut("{id}")]
        public IActionResult UpdateProduct(int id, Product product)
        {
            var existingProduct = productService.GetProduct(id);
            if (existingProduct == null)
            {
                return BadRequest($"{id} id'li eleman yok.");
            }
            product.Id = id;
            return Ok(productService.Edit(product));

        }
        [HttpPost]
        public IActionResult Add(Product product)
        {
            var newProduct = productService.Add(product);
            return CreatedAtAction(nameof(GetProductById), new { id = newProduct.Id }, null);

        }

    }
}

Yine uyarayım! ProductsController sınıfını da dependency injection’a uygun bir hale getirdim. Constructor’a dikkat!!

İşte sevgili dostlar; buraya kadar yaptıklarınızı Postman ile test edebilecek durumdasınız. Ancak dediğim gibi, bu aracı kullanarak fonksiyonel testlerinizi bir arada ve sürekli erişilebilir tutmak pek mümkün değil. İşte o nedenle; şimdi solution’a bir test projesi ekleyecek ve serüvenimize öyle devam edeceğiz.

xUnit ile Serüvenimiz Başlıyor!

 Evet, .NET Core ile uyumlu ve oldukça yetenekli bir Test Framework’ü olan xUnit’i barındıran Test Project template’ini solution’a ekliyor ve adına da Products.API.Tests diyoruz. Ardından da bu projeye, Products.API projesini referans olarak eklemeyi unutmuyoruz.

ASP.NET Core MVC Test Ortamı

Şimdi, test projemizin altyapısını oluşturarak başlayalım önce. Bir kere öncelikli amacımızı bir kere daha hatırlayalım. İstemci uygulaması GELİŞTİRMEDEN, Web API’nin request – response süreçlerini test etmek ve yazdığımız fonksiyonel testleri erişilebilir halde bulundurmak istiyoruz.

Yani aslında, Web API uygulamasını ayağa kaldırmamız ve bu uygulamaya uygun sanal bir istemci oluşturmamız gerekiyor. Bu noktada önemli bir detay var. Web API uygulaması ayağa kalkarken, Startup.cs’de belirtilen Dependency Injection taşıyıcılarının da ayağa kaldırılması gerekiyor.

İşte bu amaç için geliştirilmiş bir kütüphane var. Şimdi xUnit Test uygulamasına bu paketi ekleyelim:

  • Microsoft.AspNetCore.Mvc.Testing

Tamamdır. Bu kritik paketi projemize ekledik. Artık başka bir sorun üzerinde düşünmeye başlayabiliriz. Test sürecinde gerçek veritabanını kullanacak mıyız?

EF Core ve Test Ortamı

Sorumuzun cevabı elbette kocaman bir HAYIR! Sorgu çekme işlemlerinde çok sorun olmayabilir ama tablodaki kaydı değiştirmeye yönelik işlemlerde; veritabanında değişiklik yapmayı hiç istemeyiz.

İşte o zaman, gerçek veritabanının bir kopyasını bellekte oluşturmalı ve tüm operasyonları bu kopya üzerinde test etmeliyiz. İşte bu ihtiyacımızı da yine bir paket ile karşılıyoruz. Yine aşağıdaki paketi, Nuget aracılığıyla Test projemize ekleyelim:

  • Microsoft.EntityFrameworkCore.InMemory

Bu noktaya kadar ihtiyacımız olan paketleri Test projemize ekledik. Veritabanının kopyasını bellekte oluşturacağımıza da karar verdik. Peki söz konusu bu kopya içine veriyi nasıl entegre edeceğiz? Unutmayın! Gerçek veritabanına değil, bellektekine geçici ve test amaçlı bir veri eklemek durumundayız.

Bir düşünelim; fonksiyonel testleri erişime açık halde tutmak istediğimiz düşünülürse, veritabanının bellekteki kopyasında tutmak istediğimiz verileri de fiziksel olarak saklamak isteyebiliriz. O zaman biz de test amaçlı verilerimizi JSON formatında tutalım. Bu amaçla, test projeme data isminde bir klasör açıyorum ve içine products.json dosyasını ekliyorum:

Products.API.Tests/data/products.json

[
  {
    "Id": 1,
    "Name": "Test Ürünü A",
    "Price": 12.0,
    "Stock": 1000
  },
  {
    "Id": 2,
    "Name": "Test Ürünü B",
    "Price": 10.0,
    "Stock": 1500
  },
  {
    "Id": 3,
    "Name": "Test Ürünü C",
    "Price": 8.0,
    "Stock": 750

  }
]

Şimdi yapmamız gereken; bu dosyayı run-time’da bellekteki kopya veritabanımıza ekleyecek kodu yazmak. Yani, in-memory veritabanımıza “seeding” (tohumlama) işlemi yapmamız gerekiyor. Eğer daha önce EF Core yapısında Code-First yaklaşımını kullanmışsanız, bu işlemi yapmış olabilirsiniz. Ancak hiç duymamış olma ihtimaline karşı birkaç cümle sarf etmek isterim (biliyorsanız aşağıdaki paragrafı atlayabilirsiniz).  

EF Code-First ile oluşturduğunuz bir veritabanını “içi bomboş” bir biçimde kullanmak yerine, birkaç temel veriyi (hepsini değil) eklemek isteyebilirsiniz. İşte, veritabanını oluştururken bu data ekleme işine “Seeding” adını veriyoruz.

Seeding işlemi, DbContext sınıfı aracılığıyla yapılıyor. Madem öyle, Test projemize yeni bir sınıf ekleyerek bu işlem için hazırlıklarımıza başlayalım:

Products.API.Tests/ProductTestContext.cs

using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using System.IO;
using Products.API.Data;
using Products.API.Models;

namespace Products.API.Tests
{
    public class ProductTestContext : ProductDbContext
    {
        public ProductTestContext(DbContextOptions<ProductDbContext> options) : base(options)
        {

        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            
        }        
    }
}

İşte bu sınıfta yer alan OnModelCreating metodu içerisinde Seeding işlemini yapacağız. Hatta tam olarak kullanmamız gereken kod parçası şöyle olmalı:

modelBuilder.Entity<Product>().HasData(products);

Burada HasData();  metodunun parametresi olarak kullandığım products nesnesi bir Product array’i olmalı tahmin edebileceğiniz gibi.

Ben örneğimde sadece bir model kullandım fakat elbette çok model varmış gibi kodlayacağım. Bu da demektir ki burada seed için gerekli olan işlemi bir metoda yaptırmalıyım. Gelsin bakalım o metot:

Products.API.Tests/ProductTestContext.cs – Seed metodu:

private void seedData<T>(ModelBuilder modelBuilder, string file) where T : class
        {
            using (StreamReader reader = new StreamReader(file))
            {
                var json = reader.ReadToEnd();
                var data = JsonConvert.DeserializeObject<T[]>(json);
                modelBuilder.Entity<T>().HasData(data);
            }

        }

Dikkat edeceğiniz gibi, verilen file parametresi içindeki dosyayı deserialize ederek, oluşan koleksiyonu, HasData metoduna parametre olarak ekliyorum ve amacıma ulaşıyorum.

Son durumda ProductTestContext sınıfının tamamı aşağıdaki gibi yani:

Products.API.Tests/ProductTestContext.cs

using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Products.API.Data;
using Products.API.Models;
using System.IO;

namespace Products.API.Tests
{
    public class ProductTestContext : ProductDbContext
    {
        public ProductTestContext(DbContextOptions<ProductDbContext> options) : base(options)
        {

        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            seedData<Product>(modelBuilder, "../../../data/products.json");
        }

        private void seedData<T>(ModelBuilder modelBuilder, string file) where T : class
        {
            using (StreamReader reader = new StreamReader(file))
            {
                var json = reader.ReadToEnd();
                var data = JsonConvert.DeserializeObject<T[]>(json);
                modelBuilder.Entity<T>().HasData(data);
            }

        }
    }
}

Bu arada bir uyarı daha… Dosyanın adresini “../../../data/products.json” şeklinde verdiğime dikkat! Bunun sebebi; Test projesinde kök dizinin projenin altındaki /bin/debug/netcoreapp3.1 içinde olması. Bu nedenle üç klasör üste çıkması gerektiğini söylüyorum!

Test Serüvenimizin Kalbi Burada!

Tamamdır! Şimdi, Test uygulamamızın kalbinde yer alan sınıfımıza geldi sıra… Hadi önce kodu paylaşayım size sonra da adım adım açıklayayım:

Products.API.Tests/InMemoryWebApplicationFactory.cs

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Products.API.Data;

namespace Products.API.Tests
{
    public class InMemoryWebApplicationFactory<T> : WebApplicationFactory<T> where T : class
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.UseEnvironment("Testing")
                   .ConfigureTestServices(services =>
                   {
                       var options = new DbContextOptionsBuilder<ProductDbContext>()
                                                              .UseInMemoryDatabase("testMemory").Options;
                       services.AddScoped<ProductDbContext>(provider => new ProductTestContext(options));

                       var serviceProvider = services.BuildServiceProvider();
                       using var scope = serviceProvider.CreateScope();
                       var scopedService = scope.ServiceProvider;
                       var db = scopedService.GetRequiredService<ProductDbContext>();
                       db.Database.EnsureCreated();

                   });
        }
    }
}

İlk olarak sınıfımızın, WebApplicationFactory<T> sınıfından türediğine odaklanalım. Bu sınıf, bizim ana web uygulamasını ayağa kaldırmakla görevli olan sınıf. “Ayağa kalkma” deyimiyle neyi kastediyoruz peki? Uygulamamızın Startup.cs sınıfında eklediğimiz tüm middleware ve builder’lar yani uygulamanın çalışması için gereken tüm kaynakların adım adım çalıştırılmasından bahsediyoruz. Haliyle buradaki <T> tipi de tam olarak Startup.cs oluyor.

Bizim bu test ortamında bir amacımız daha var unutmayın! EF Core ile geliştirdiğimiz altyapıyı InMemory olarak oluşturmak ve içine test edilebilir verileri eklemek. Yani test için ortam hazırlamamız gerekiyor.

ConfigureWebHost metodunu ezmemizin tek sebebi budur a dostlar. Bakınız ilk olarak ne yapıyorum: builder.UseEnvironment(“Testing”)  metodu ile, “her ihtiyaç duyduğunda ‘Testing’ ortamını oluşturmasını” söylüyorum. Asıl çılgınlık bu ortamın ConfigureTestServices () metodunda kopuyor.

Bu metot, uygulama “ayağa kalkarken” konfigüre edeceğim servisleri soruyor bana. Ben de ilk olarak diyorum ki, ProductDbContext sınıfında belirlenen veritabanı kurallarını, bellekte oluştur!

var options = new DbContextOptionsBuilder<ProductDbContext>()                                                             .UseInMemoryDatabase(“testMemory”).Options;

Fakat sonrasında diyorum ki; bu veritabanını ProductTestContext sınıfında belirlediğim kurallara göre oluştur:

services.AddScoped<ProductDbContext>(provider => new ProductTestContext(options));

İşte size çiçek gibi kod! Ancak, mademki IServiceCollection içine yeni bir servis eklediniz (AddScoped), o zaman database nesnenizi yine aynı servisten kullanmak zorundasınız!  Son satırlarımın tek amacı, bu database nesnesinin bellekte oluştuğuna emin olmak:

var serviceProvider = services.BuildServiceProvider();

using var scope = serviceProvider.CreateScope();

var scopedService = scope.ServiceProvider;

var db = scopedService.GetRequiredService<ProductDbContext>(); db.Database.EnsureCreated();

İşte beklenen final

Sonunda, ProductsController API’sini test edebilirim. Aşağıdaki sınıfı Test projesine dahil ediyorum ve önce sınıfı dependency injection’a uygun hale getiriyorum:

Products.API.Tests/Products.ControllerTest.cs

public class ProductsControllerTest : IClassFixture<InMemoryWebApplicationFactory<Startup>>
    {
        private InMemoryWebApplicationFactory<Startup> factory;

        public ProductsControllerTest(InMemoryWebApplicationFactory<Startup> factory)
        {
            this.factory = factory;
        }
     }

Açıklayarak gidelim… Bu sınıfta, fonksiyonel testlerimizi yazacağız. Yani, her test metodu -doğası gereği- bağımsız bir alanda işlem yapacak. Fakat biz testlerimizin, InMemoryWebApplicationFactory sınıfında belirlediğimiz standart test ortamlarını kullanmalarını istiyoruz.

Bu nedenle, factory değişkeninin değeri constructor’dan enjekte ediliyor. İyi ama her test birbirlerinden bağımsız olduğuna göre, bu constructor’u nasıl çağıracağız?

İşte burada XUnit Test Framework’ünde bulunan IClassFixture<T> interface’i devreye giriyor. Diyor ki “eğer, bir test sınıfının constructor’u bir paramatre alacaksa; onu burada tanımlayacaksın” e biz de dediğini yapıyoruz:

ProductsControllerTest : IClassFixture<InMemoryWebApplicationFactory<Startup>>

Artık bu uzun serüvenimizin son kısmına geçebiliriz!

Testler başlasın!

İlk testimiz, HttpGet metodunu test etsin bakalım:

[Fact]
  public async Task web_api_basari_testi()
  {
     var client = factory.CreateClient();
     var response = await client.GetAsync("/api/products");

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

Ya valla çok heyecanlı yahu. Şu minik “client” nesnesini oluşturduğum koda bir bakar mısınız? factory nesnesinin CreateClient() metodunu çağırdığım anda, Products.API projesi tüm bağımlılıkları (dependency) ve servisleri (IServiceCollection) ile ayağa kalktı ve üstelik, DbContext nesnesini InMemory ortamda oluşturup products.json dosyası içindeki verileri bu ortamdaki Products tablosuna ekledi.

Tüm bunlar için ne dedim? factory.CreateClient(); sonra gel de sevme böyle işlemleri. Peki sonra ne yaptım? Bu client’ın Get url’ine (“/api/products”) bir request gönderdim ve dönen yanıtın StatusCode özelliğini, HttpStatusCode.OK ile karşılaştırdım.

Hadi gelin bu testin sonucuna bir bakalım:

Tabii bu kadarla kalmayıp, Post ve Put HttpRequest’leri için de test yazdım:

[Fact]
public async Task post_request_test()
        {
            var product = new Product
            {
                Name = "TestName",
                Price = 5,
                Stock = 1500
            };

            var client = factory.CreateClient();
            var httpContent = new StringContent(JsonConvert.SerializeObject(product), Encoding.UTF8, "application/json");
            var response = await client.PostAsync("/api/products", httpContent);

            Assert.Equal(HttpStatusCode.Created, response.StatusCode);
            Assert.NotNull(response.Headers.Location);                     

        }
[Fact]
        public async Task put_request_test()
        {
            var client = factory.CreateClient();
            var request = new Product { Name = "X", Price = 100, Stock = 10 };
            var httpContent = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
         
            var response = await client.PutAsync("api/products/3", httpContent);

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

Son sözler:

Yani finalde ne ettik sevgili dostlarım? Bir web API uygulamasını Postman gibi uygulamalara ihtiyaç duymadan; kendi fonksiyonel testlerimizi yazarak test ettik. Böylece tüm ekip tarafından erişilebilecek ve istenildiğinde tekrarlanabilecek – değiştirilebilecek bir yapı oluşturmuş olduk.

Vallahi fıstık gibi bir çözüm.

Aha bu da çözümün kaynak kodları:

https://github.com/turkayurkmez/testingASPNETMVCWebAPI

Umarım faydasını görürsünüz sevgili dostlar. Esen kalın! Kendinize iyi bakın!

5 thoughts on “Bir Web API’nin Test Serüvenleri

    1. Yasin bey selam. Kodu okuduğunuz, yakaladığınız ve dönüş yaptığınız için teşekkür ederim.

      Hemen düzeltiyorum.

  1. Selamlar hocam, bizim DB’de tablolar hem çok büyük hem de tablolar arası çok fazla relation var. Test verisi oluşturmak bile başlı başına bir sorun. Ve assert kısmı da karmaşık olacak sanırım çünkü örnek veriyorum, satış işlemi yapan bir sınıfımızda satış işlemiyle beraber gerçekleşen olaylar olduğunu düşünelim. Satış yaptık, satış gerçekleşti, stok düştü, müşterinin bakiyesi güncellendi, satış personeli prim kazandı vs işlemleri…

    Böyle bir DB’de ve örnek senaryoda anlattığınız gibi bir yaklaşımı veya daha uygun bir yaklaşım varsa onu nasıl uygulayabiliriz?

    Teşekkürler.

Leave a Reply