Birim Testleri

Unit Test

1. Amaç

Bu belgenin amacı .NET uygulamalarında geliştirilen birim testlerine ait pratikleri tanıtmaktır.

Kaynaklar

2. Giriş

Bir birim testi, bir kod biriminin (genellikle bir nesnenin veya modülün belli bir metodunun) davranışlarını test eden ve doğrulayan özel bir metottur. Bu kod biriminin olası tüm davranışları için testler oluşturulduğunda, birimin sistemin diğer bölümlerinden bağımsız bir biçimde doğru çıktıları sağladığı doğrulanmış olur.

Birim testleri, uygulama değiştirildiğinde, test ettikleri birimlerin işlevlerini doğru yerine getirmeye devam edip etmediğini kontrol etmeyi sağlarlar. Ayrıca test edilen metotların çalışma mantıklarını da belgelemeye yararlar.

Birim testleri olan uygulamalarda Refactoring - Yeniden Düzenleme teknikleri güvenli bir şekilde uygulanabilir. İşlevlerin doğru çalıştığından emin olunarak yazılımın yapısı istendiği gibi yeniden düzenlenerek kod kalitesi ve organizasyonu iyileştirilebilir.

2.1 Test Türleri

Birim testleri bir sistemin test edilme yöntemlerinden sadece birisidir.

Unit Testing

Birim Testleri

Whitebox Testing

Integration Testing

Entegrasyon Testleri

System Testing

Sistem Testleri

Acceptance / Functional Testing

Kabul Testleri

Blackbox Testing

2.2 Yaklaşımlar

Name

Value

Birim testleri

ne yapılacağını belirler.

Test Driven Development (Test Güdümlü Programlama)

→ testin ne zaman yazılacağını belirler.

Behavior Driven Development (Davranış Güdümlü Programlama)

→ testin nasıl yazılacağını belirler.

Her biri ayrı ayrı da kullanılabilse de, en iyi sonuçlar için bu yöntemler bir arada kullanılmalıdır. Zaten beraber uygulanmaya da çok uygundurlar.

TDD (Test Driven Development) Test Güdümlü Programlama: İşlevin geliştirilmesinin önce testlerinin yazılarak sağlandığı yöntem. Bkz. Test Driven Development (TDD)

BDD (Behaviour Driven Development) Sistemin davranışlarına odaklanan bir geliştirme yöntemidir. Bu yaklaşımda testler implementasyon yerine beklenen davranışı (fonksiyonalite) test etmeye yoğunlaşır. Bkz. Behaviour Driven Development (BDD)

Kullanılan terminoloji iş beklentilerini karşılamaya yönelik oluşturulur ve iş uzmanları ile geliştiriciler arasında oluşan iletişim boşluğunu da gidermeyi hedefler.

Tasarımı, beklenen davranış üzerinden giderek düşünürsek ve organize edersek, iş ihtiyaçlarının mantıksal yerleşimi testlere yansır, test sınıfları fonksiyonaliteyi test eder hale gelir.

3. Bir Testin Anatomisi

3.1 Örnek Birim Testi

[Test]
public void UpdateMember_ValidMember_ReturnSuccess()
{
    //Arrange
    Member member = new Member();
    Member existingMember = null;
    bool isUpdated = true;

    accountRepository.Setup(x => x.GetMemberByEmailAddress(member.EmailAddress)).Returns(existingMember);
    accountRepository.Setup(x => x.UpdateMemberAccountInfo(member)).Returns(isUpdated);

    //Act
    var result = accountService.UpdateMemberAccountInfo(member);

    //Assert
    result.ShouldEqual(true);
}

3.2 Bir Birim Testinin Organizasyonu Hakkında Öneri: A/A/A

Bir birim testi 3 temel bölümden oluşur. Bu bölümler yorum ifadeleriyle birbirinden ayrılabilir.

Name

Value

Arrange

Test edilecek işlevin çalıştırılabilmesi için gerekli kurulumlar.

Act

İşlevin çalıştırıldığı bölüm.

Assert

Sonucun kontrolü.

Arrange Bu bölümde test edilecek birime, koşullara ve sonuçlara ait kurulumlar yapılır.

//Arrange
Member member = new Member();
Member existingMember = null;
bool isUpdated = true;

Setup

Bir metodun belli parametrelerle çalıştırılması sonucunda döndürdüğü sonucu belirlemek için kullanılır.

accountRepository.Setup(x => x.GetMemberByEmailAddress(member.EmailAddress)).Returns(existingMember);
accountRepository.Setup(x => x.UpdateMemberAccountInfo(member)).Returns(isUpdated);

Act Bu bölümde test edilecek birim istenen koşullarla çalıştırılır ve sonuç elde edilir.

//Act
var result = accountService.UpdateMemberAccountInfo(member);

Assert Bu bölümde çalıştırılan birimin ürettiği sonuç kontrol edilerek beklentileri karşılaması sağlanır. Testin geçip geçmediği bu bölümde belli olur. Bu nedenle beklenen sonuçlar için kontrol ifadeleri kapsamlı bir şekilde tanımlanmalıdır.

//Assert
result.ShouldEqual(true);

ASP.NET MVC yapısı kullanılıyorsa ve test edilecek birim Controller katmanında ise işlem sonucunda arayüzle ilgili sonuçlar kontrol edilir.

Verify Bir metodun çalışıp çalışmadığını kontrol etmek için Verify metodu kullanılır.

logger.Verify(x => x.LogException(It.IsAny<string>()), Times.Once);
accountRepository.Verify(x => x.GetMemberAccountInfo(memberId), Times.Never);

Bir sınıfa ait özelliğe atama yapılıp yapılmadığı SetValue metodu ile kontrol edilir.

session.Verify(x => x.SetValue("CheckoutInfo", It.IsAny<CheckoutInfo>()), Times.Once);

Mock kurulumu yapılan bir metot sonuç döndürüp akışın sürmesini sağlıyorsa, kurulum yapılan metodun çalıştığını Verify etmeye gerek kalmaz.

3.3 SetUp / TearDown metotları

SetUp Bir test sınıfının içerdiği tüm testlerde kullanılacak kurulumlar için SetUp metodu kullanılır. SetUp metodu her bir test çalışmaya başlamadan önce çalışır.

#region setup

Mock<IAccountRepository> accountRepository;
Mock<IErpService> erpService;
Mock<ILogger> logger;
Mock<ISettings> settings;

AccountService accountService;

[SetUp]
public void Initialize()
{
    accountRepository = new Mock<IAccountRepository>();
    erpService = new Mock<IErpService>();
    logger = new Mock<ILogger>();
    settings = new Mock<ISettings>();

    accountService = new AccountService(       
        accountRepository.Object,
        erpService.Object,
        logger.Object,
        settings.Object);
}

#endregion

Bu metotta genelde test edilecek sınıfa kullanacağı arayüzlerin Mock versiyonları enjekte edilir. Böylece bu arayüzlerin istenen metotlarının mock kurulumları ile istendiği gibi sonuç üretmesi sağlanabilir.

TearDown Bir test sınıfının içerdiği tüm testlerde kullanılacak sonlandırma işlemleri için TearDown metodu kullanılır. TearDown metodu her bir testin çalışması tamamlandıktan sonra çalışır.

[TearDown]
public void TearDown()
{
    accountRepository.Dispose();
}

4. Birim Testlerinin İsimlendirilmesi

bkz. Birim Testi İsimlendirme Kılavuzu

5. Test Geliştirme Pratikleri

5.1 Prensipler

bkz. Birim Testi Geliştirme Prensipleri

5.2 Testleri Paralel Çalıştırmak

Testlerin hızlı çalışması önemlidir, çünkü testler hızlı çalışmıyorsa, çalıştırılmamaya başlanır. Testler sık çalıştırılmazsa refactoring yapma eğilimi de azalır (çünkü programcının kendine olan güveni azalır).

Birim testlerini paralel çalıştırmak zamandan kazandırır. Testleri paralel çalıştırmak için NUnit 3.0 ve üstü versiyonlarda test sınıflarına (TestFixture) [Parallelizable(ParallelScope.Fixtures)] etiketi eklenir.

Vaka: Foo projesinde test sayısı 800’ü geçince testlerin çalışma süresi 10 saniyeye çıktı. Paralel çalıştırma sonucunda 2.600 civarında test ortalama 6,50 saniye gibi bir sürede çalışabiliyor.

6. Birim Testlerinde Sorunlar ve Çözümler

6.1 Test Yazmanın Uzun Sürmesinden Yakınan Geliştiriciler İçin Açıklamalar

1. Arrange bölümü çok uzun

  • Önce Act kısmını yaz, ihtiyacın kadar Setup ekle. (Test edilen metodu olabildiğince Black Box olarak düşün. Bu şekilde düşünmek metodun soyutlama seviyesini belirlemekte de yararlıdır.)

  • İhtiyaç olmadığı sürece, Mock sınıfların Setup tanımlarına verilen parametre ve dönüş tipi sınıflarının özelliklerine atama yapma ya da sadece yapılması gerektiği kadar yap. (Kurulumların çalışması için bu sınıfları sadece new ile oluşturmak ve nesnenin referansını kullanmak yeterlidir.)

  • Birden fazla testte kullanılan setup bölümleri ayrı bir metoda alınıp ortak olarak kullanılabilir. (Parametreler için factory metotlar oluşturulabilir.)

  • Yine de çok fazla metodu setup yapmak gerekiyorsa bu durum, test edilecek metodun birden fazla sorumluluğu yerine getirmeye çalıştığını gösterir. Bu sorumlulukların soyutlama seviyeleri büyük olasılıkla farklıdır. Alt seviye sorumlulukları yerine getiren adımlar gruplanıp başka bir sınıfa sorumluluk olarak taşınabilir. Bu durumda taşınan metodun setup'ını yapmak yeterli olur. İşler birden fazla sınıfa dağılacak şekilde anlamsal bir bütünlüğe sahipse böyle yap.

2. Assert satırları fazla

  • FluentAssertions veya benzeri, BDD (Behaviour-Driven Development) odaklı, zincir metotlar ile çalışabilen bir Assert kütüphanesi kullan.

    • Assert bölümü testin amacıdır, en önemli kısmıdır.

    • Testin neden yazıldığı, hangi sonuçların beklendiği açık, anlaşılır ve kısa olmalı.

    • FluentAssertions ile cümle okur gibi okunabilen, BDD odaklı, daha az satırdan oluşan Assert ifadeleri yazılabiliyor.

3. Çok fazla test oluşturmam gerekiyor

  • Sadece belirli koşulları ya da sonuçları değişen testleri yeniden yazma. Parametrik testler oluşturarak, değişen bölümleri parametre olarak tanımla. (TestCase kullan.)

6.2 Sık Sorulan Sorular

1. Setup çalışmıyor

  • Setup'a sağlanan parametreyi kontrol et. Parametrenin test edilen birimde (metotta)

    • Değer tipi parametre ise aynı değerle

    • Referans tipi ise aynı referansla çalıştırıldığından emin ol.

    • Aynı değilse setup başarısız olur, test de (çoğu zaman) geçmez.

  • Setup’a sağlanan referans tipi parametre, test edilen metodun içinde farklı bir referansla oluşturuluyorsa (örneğin new anahtar kelimesi ile) setup başarısız olur. Bu durumda yaşanan genel yanılgı şudur: “Parametre sınıfı için tüm özellikleri beklenen değerlere sahip bir nesne oluşturmak bu işi çözebilir.” Hayır, çözmez. Sınıfın özelliklerine ait değerler aynı bile olsa referansı farklı olacaktır. Bu durumun olası çözümleri:

    • Test edilen metodun oluşturduğu ve kullandığı nesne için bir factory metot oluşturulur. Metot nesneyi factory aracılığıyla oluşturur ve kullanır. Testte factory metodunun kurulumu yapılır ve kullanılacak parametre bu metodun dönüşünde sağlanır. İmplementasyon ile test aynı yöntemi kullanarak parametreyi oluşturacağından setup çalışır.

    • Setup metoduna bir nesne vermek yerine It.IsAny<ParameterType>() ifadesi ile parametre sağlamak. Bu kuruluma göre aynı tipte herhangi bir nesne parametre olarak kullanılırsa, setup başarılı olur. (Bu kullanım önerilmez. Bkz. Not - It.IsAny Kullanımı)

Not - It.IsAny Kullanımı Setup yapılan metotlarda parametreler için It.IsAny tipini kullanmak sakıncalıdır.

  • SORUN 1: Metot içindeki çalışma akışında parametre için doğru nesne kullanıldığından emin olunamaz. Test fonksiyonaliteyi tam anlamıyla test ediyor olmaz.

  • SORUN 2: It.IsAny kullanmak gerekmesi bir soruna işarettir. İmplementasyon içinde new anahtar kelimesi ile sınıf oluşturulduğunu gösterir. new ile sınıf ancak Factory metotlarda oluşturulmalı. Bu durumlarda Factory metot kullanılmalıdır. Yeni oluşturulan sınıfın istenen değerlerle oluşturulup oluşturulmadığı (verilen değerler ve beklenen varsayılan kurulumlarıyla) Factory metodun testinde kontrol edilir.

2. .NET Framework veya 3. parti bir bileşene ait metot beklendiği gibi çalışmıyor

  • Url, ControllerContext, HttpContext, File, Session, vb. Bileşenler birim testi ortamında beklendiği gibi çalışmaz. (Özellikle bir HttpContext nesnesine ihtiyaç duyan Web metotları)

  • Bu durumlarda gevşek-eşleştirme (loose-coupling) sağlanmalı. Wrapper sınıflar oluşturulmalı ve istemci sınıf bu framework bileşenlerine wrapper sınıfların arayüzleri aracılığıyla ulaşmalıdır.

  • Wrapper metotlar istemcinin çağıracağı hedef metot ile aynı imzaya sahip vekil metotlardır.

  • Wrapper → Kullanılan bileşenin metotları ile aynı imzaya sahip arayüz oluşturulur, bu arayüzü uygulayan wrapper sınıfı, metotların implementasyonlarında hedef bileşenin metotlarını çalıştırır ve sonucu döndürür.)

  • Örnek: ISession (arayüz)

    • SessionWrapper (implementasyon)

    • RedisSessionWrapper (implementasyon)

  • Sıkı sıkıya eşleştirmekten kurtulmanın faydası: Örneğin Session işlemleri için kullanılan ISession arayüzünün kullanılan implementasyonu istendiği zaman kolayca değiştirilebilir. ASP.NET Session nesnesini saran SessionWrapper yerine Redis kullanan RedisSessionWrapper kullanımına geçilebilir ve istemci kodu ve testleri bu durumda değişmek zorunda kalmaz.

6.3 Birim Testleri ve Teknik Olmayan Proje Paydaşları

Geliştirme sürecinde birim testi yazıldığını müşteriye, proje yöneticisine bildirmeyin. Testleri geliştirmenin doğal bir parçası olarak düşünün. Yeterli bilgiye sahip olmayan kişiler, test yazmanın geliştirmeyi uzatacağı, gereksiz olduğu gibi önyargılara sahip olabilir (genelde böyle olur). Dikkate almayın. Testlerin geliştirme sürecinin doğal ve gerekli bir aşaması olduğunu içselleştirememiş organizasyonlarda, bunu açıkça belirtmeden testlerinizi yazmanız daha iyi olabilir.

Öneri: Test Driven Development (TDD) yöntemi ile geliştirme yaparsanız, işlevin kodlamasını bitirmiş ama testlerini henüz yazmamış bir durumda kalmazsınız. Önce testi yazarsınız ve uygulama kodu da doğal olarak oluşturulmuş olur. Geliştirme bittiğinde testler de, uygulama kodu da tamamlanmış olur.

7. Test Assert Örnekleri

Bu bölümde gösterilen örneklerde FluentAssertions kütüphanesi kullanılmıştır.

7.1 Web Testleri

Yönlendirme Testi

//Assert
result.Should().BeRedirectToRouteResult().WithController(ControllerNames.Friend).WithAction(FriendControllerActionNames.Index);

ViewResult ve Model Testi

//Assert
result.Should().BeViewResult().WithViewName(ViewNames.Index).ModelAs<FriendListViewModel>).Friends.Should().BeEquivalentTo(friends.Items);
result.As<ViewResult>().Model.As<FriendListViewModel>().PagingInfo.Should().BeEquivalentTo(pagingInfo);

JsonResult ve Data Testi

//Assert
var resultModel = result.Should().BeOfType<JsonResult>().Which.Data.As<ProfileActionResult>();
resultModel.IsSuccess.Should().Be(expectedModel.IsSuccess);
resultModel.ProfileId.Should().Be(expectedModel.ProfileId);
resultModel.Message.Should().Be(expectedModel.Message);
resultModel.UrlToRedirect.Should().Be(expectedModel.UrlToRedirect);

TempData ve ViewData Testi

//Assert
result.Should().BeRedirectToRouteResult().WithController(ControllerNames.Friend).WithAction(FriendControllerActionNames.Index);
var resultMessage = controller.TempData[nameof(DictionaryKey.ResultMessage)].As<ResultMessage>();
resultMessage.MessageType.Should().Be(MessageType.Success);
resultMessage.Message.Should().NotBeNullOrEmpty();
controller.ViewData["PageSize"].ShouldEqual(5);

ASP.NET MVC ModelState Kontrolü Controller testlerinde ModelState nesnesinin geçersiz olduğu, hata içerdiği durumları kontrol etmek için aşağıdaki Assert ifadesi kullanılabilir.

//Assert
accountController.ModelState.GetErrorCount("").Should().NotBe(0);

GetErrorCount metodu ModelState sınıfı için oluşturulan bir extension metottur:

public static int GetErrorCount(this ModelStateDictionary dictionary, string key)
{
    if (dictionary.Keys.Count == 0)
    {
        return 0;
    }

    return dictionary[key].Errors.Count;
}

Model validasyonu testler çalışırken devreye girmediğinden ModelState nesnesini hatalı duruma getirmek ve bu durumu test etmek gerekebilir. Bu durumda ModelState hatasını elle oluşturmak amacıyla Act bölümünden önce, test edilecek Controller için AddModelError metodu kullanılabilir.

accountController.ModelState.AddModelError("", "");

7.2 Validasyon Testleri

FluentValidation Testleri FluentValidation testleri genelde sadece Assert bölümlerinden oluşan kısa testlerdir. Bu testlerde birden fazla durum aynı koşul için aynı metotta test edilebilir.

Validasyon testlerinin aşağıdaki gibi iki bölüme ayırılması testlerin organizasyonu açısından yararlıdır.

  • Invalid conditions

  • Valid conditions

[Test]
public void Validate_ValueEmpty_HaveError()
{
    validator.ShouldHaveValidationErrorFor(model => model.CardHolderName, string.Empty);
    validator.ShouldHaveValidationErrorFor(model => model.CardHolderName, null as string);
    validator.ShouldHaveValidationErrorFor(model => model.CardNumber, string.Empty);
    validator.ShouldHaveValidationErrorFor(model => model.CardNumber, null as string);
    validator.ShouldHaveValidationErrorFor(model => model.ExpireDateMonth, 0);
    validator.ShouldHaveValidationErrorFor(model => model.ExpireDateYear, 0);
}

Validasyon testlerinde validasyonu ihlal eden koşullar kontrol edildiği gibi, kabul edilebilir değerlerin de bir ihlale yol açmadığı da kontrol edilmelidir.

[Test]
public void Validate_ValueNotEmpty_DoNotHaveError()
{
    validator.ShouldNotHaveValidationErrorFor(model => model.CardHolderName, "test kişisi");
    validator.ShouldNotHaveValidationErrorFor(model => model.CardNumber, "0000111122223333");
    validator.ShouldNotHaveValidationErrorFor(model => model.Csc, "000");
    validator.ShouldNotHaveValidationErrorFor(model => model.ExpireDateMonth, 1);
    validator.ShouldNotHaveValidationErrorFor(model => model.ExpireDateYear, DateTime.Now.Year);
}

Diğer durumlarda kullanılabilecek test isimleri:

  • Validate_ValueNotInRange_HaveError

  • Validate_ValueLengthNotInRange_HaveError

  • Validate_ValueFormatInvalid_HaveError

  • Validate_ValuesDoNotMatch_HaveError

Özel kontroller için

  • Validate_CityEmpty_HaveError

  • Validate_AddressValueMaxLength_HaveError

7.3 Hata (Exception) Testleri

Test edilecek işlev bir sonuç döndürmek yerine hata fırlatıyorsa Assert.That metodu ile beklenen hata tipi belirtilir ve bu hatanın fırlatılması durumunda test geçmiş olur. Metodun çağrısı ile sonucun kontrolü aynı satırda olduğu için Act ve Assert bölümleri birleşir.

[Test]
public void GetAppSettingValueAsString_KeyDoesNotExist_ThrowException()
{
    // Arrange
    var key = ConfigKey.DefaultMailSenderName;

    bool keyExists = false;

    configManager.Setup(x => x.AppSettingsKeyExists(key)).Returns(keyExists);

    // Act - Assert
    Assert.That(() => settingsReader.GetAppSettingValueAsString(key), Throws.Exception.TypeOf<Exception>());
    configManager.Verify(x => x.GetAppSettingsValue(VerifyAny<ConfigKey>()), Times.Never);
}

Akışın belli bir aşamasında bir metodun hata fırlatması istenirse, bu metodun Setup işlemi Returns yerine Throws metodu ile yapılır.

mailService.Setup(x => x.SendApplicationReceivedMail(memberApplicant)).Throws(new Exception());

8. Yardımcı Kütüphaneler

NUnit

.NET için açık kaynaklı bir birim testi kütüphanesi.

https://www.nuget.org/packages/NUnit

Moq (Mocking library)

Mocking, birim testlerinde gerçek uygulama sınıfları yerine bu sınıfların özelliklerine sahip (aynı arayüzleri uygulayan) geçici sınıflar tanımlama ve kullanma yöntemi.

Moq, bir arayüzden mock nesnesi yaratıldığında, çalışma zamanında arayüzü uygulayan bir sınıf oluşturarak bu sınıfın kullanılmasını, bu sınıfın özelliklerinin ve metotlarının beklenen sonuçları verecek şekilde ayarlanmasını sağlar.

https://www.nuget.org/packages/Moq/ http://code.google.com/p/moq/

FluentAssertions

Daha önce kullanılan basit UnitTestHelper sınıfındaki ShouldEqual, ShouldNotEqual metotları yerine çok daha kapsamlı olan ve BDD yönelimli FluentAssertions kütüphanesinin kullanımına geçildi.

FluentAssertions Yararları:

  1. Daha anlamlı, cümle yapısına sahip

  2. Daha kısa Assert ifadeleri yazılabiliyor

  3. Test satır sayısı da azalıyor

  4. Assert ifadelerindeki because parametresi aslında ne beklendiğini ve gerekçesini, geçmeyen testlerin çıktılarına daha anlaşılır bir şekilde yansıtmayı sağlıyor.

TestDriven.Net

VisualStudio ile NUnit’in entegrasyonunu sağlayan, testi debug ile çalıştırmaya ve NCover programı ile coverage gösterebilen eklenti.

http://www.testdriven.net/

Visual Studio entegrasyonu

https://www.nuget.org/packages/NUnitTDNet

NCrunch (Automated Concurrent Testing Tool)

https://www.ncrunch.net/

Ninject (Dependency injection library)

Ninject, sınıfların bağımlılıklarını ayarlamaya yarayan bir kütüphane. Arayüzlerle bağlanan sınıfların çalışma zamanında hangi somut sınıflarla çalışacağının belirlenmesini sağlar.

Hangi controller’ın hangi somut sınıfların nesnelerini kullanacağını belirlemek için Ninject kullanılan özel ControllerFactory sınıfı, uygulamanın kullandığı ControllerFactory olarak Global.asax içinde Application_Start olayında atanır.

Test için FluentAssertions.Ioc.Ninject paketi

http://ninject.org

Last updated