Home » Approfondimenti » Computer Science » Automated Testing in C#
  []

Cosa testare

  • Prima fare i test nominali
  • Testare i casi estremi (come le date limite di un periodo)
  • Testare le exception (preferire exception castomizzate)

Caratteristiche generarli

  • Vantaggi di progettare test automatici =>
    • Testare il codice in meno tempo e più facilmente.
    • Trovare eventuali bug e migliorare la qualità del codice.
    • Fare il deploy e refactoring più serenamente.
  • Tipi di test automatici =>
    1. Manual tests
    2. Unit Test => testano un’unità di un’applicazione (una classe o più classi) in modo isolato, senza cioè dipendenze esterne e automatico.
      • Economici da scrivere
      • Permettono di riscrivere il codice senza troppa paura
      • Veloci da eseguire
      • Non completamente affidabili
      • Da fare e lanciare ripetutamente prima di committare le proprie modifiche nel repository
      • Ogni dipendenza esterna deve essere un fake
      • Riducono del bug fixing
      • Possono fornire della documentazioni => se gli unit test seguono lo schema degli use-cases possono fornire un modo per documentare l’applicazione

    1. Integration Test (API test) => testano un’unità di un’applicazione (o componente) insieme alle sue dipendenze esterne.
      • Sono più lunghi da scrivere
      • Più affidabili
      • Da fare solo dopo aver committato le modifiche nel repository
    2. End-to-End  Test (GUI Test) => basati sull’interfaccia utente
      • Molto affidabili
      • Molto lenti (fare il login/ aprire la pagina da testare / verificare il risultato)
      • Sono molto fragili => un piccolo cambiamento nell’interfaccia potrebbero inficiare i test
    3. Acceptance tests =>
      • Testano il comportamento atteso
    4. Performance/Load/Volume tests
    5. Test a piramide => E’ un metodo che raggruppa i 3 metodi precedenti (vedi immagine)

 

 

 

 

 

 

      • Step 1 => Scrivere Unit test per testare il grosso dell’applicazione. Soprattutto per testare algoritmi e il grosso della logica
      • Step 2 => Se non si è abbastanza confidenti, scrivere dei test d’integrazione che coinvolgono anche le dipendenze esterne della nostra applicazione.
      • Step 3 => Infine scrivere alcuni test per verificare l’interfaccia sulle principali funzionalità dell’applicativo, con parsimonia.
  •  

TDD (Test-Driven Development) or Test-First  (Vs Code-First)

  • E’ un modo di programmare dove si scrivono prima i test e poi l’applicazione vera e propria.
  • Implementazione a tre fasi =>

    1. In primis si scrive un test che fallisce.
    2. Poi si scrive il codice più semplice affinché il test risulti positivo.
    3. Fare il refactoring del codice dove necessario per renderlo più affidabile e mantenibile
  • Pros =>
    • Test coverage => Tutta l’applicazione è pienamente testata.
    • Clean design =>
      • Spesso l’implementazione è più semplice.
      • Si è sicuri che il codice è testabile senza nessun cambiamento
    • Focus on requirement => Si punta l’attenzione sulle esigenze e non sull’implementezione

Testing Frameworks (Libraries + Test runner)

  • Principali framework per testare applicazione in c# =>
    1. NUnit (uno dei primi)
      • Attributo per classi => [TestFixture]
      • Attributo per metodi => [Test]
      • Assert =>
        • Assert.That(result, Is.True); or Assert.That(result == true);
          Assert.That(result, Is.False); or Assert.That(result == false);
          Assert.That(result.Count(), Is.EqualTo(..))

          //Deal with string
          Assert.That(result, Is.EqualTo("...").IgnoreCase)
          Assert.That(result, Does.StartWith("...").IgnoreCase)
          Assert.That(result, Does.EndWith("...").IgnoreCase)
          Assert.That(result, Does.Contain("..."))

          //Deal with array/collection
          Assert.That(result, Is.Not.Empty)
          Assert.That(result.Count(), Is.EquivalentTo(new [] {1,2,3}))
          Assert.That(result, Is.Ordered)
          Assert.That(result, Is.Unique)

          //Testing return type
          //Verifica se result sia esattamente un'istanza del tipo indicato
          Assert.That(result, Is.TypeOf<...>())
          //Verifica se result sia esattamente un'istanza del tipo indicato
          //o un'instanza di eventuali classi derivate da quest'ultimo
          Assert.That(result, Is.InstanceOf<...>())

          //Testing an exception
          Assert.That(() => [metodo_da_testare], Throws.ArgumentNullException);
          Assert.That(() => [metodo_da_testare], Throws.Exception.TypeOf<..>());

          //Testing method that raising event
          //Prima di eseguire il metodo è sufficente implementare l'evento
          //e dopo verificarne la corretta esecuzione

      • Nelle versioni precedenti a Visual Studio 2019 per aver NUnit bisogna installare i package:
        • Install-Package NUnit
        • Install-Package NUnit3TestAdapter [-Version 3.8.0]
    2. MS Test (built-in in Visual Studio)
      • Attributo per classi => [TestClass]
      • Attributo per metodi => [TestMethod]
      • Assert =>
        • Assert.IsTrue(result);
          Assert.IsFalse(result);
    3. xUnit (in voga negli ultimi anni) =>
      • Creare nuovo progetto con la shell =>
        • //posizionarsi con la linea di comando nella cartella che ospiterà l'applicazione di test
          dotnet new xunit -n <my-test-project-name>

          //aggiungo la referenze al progetto da testare
          dotnet add reference <projectfile-path>

          //aggiungo la referenze a mop
          dotnet add package moq
      • Attributo per metodi => [Fact]
      • Assert =>
        • var item = Assert.Single(<some_ienumerable>);
          Assert.NotNull(item);
          Assert.Equal("pippo", item);
          Assert.True(<my_value>);
          Assert.False(<my_value>);

          result.ShouldBeAssignableTo<ViewResult>();
  • Testing product (sembrano essere stati inglobati in VS 2022) => 
    1. ReSharper
    2. Rider
    3. ReSharper.DotCover

 

1. Unit-Tests

  • Good practices =>
    • I test sono importanti come i codice vero e proprio.
    • Un test deve avere una sola e unica responsabilità.
    • Non devono contenere della logica (es: if statement).
    • Né troppo generico, né troppo specifico.
    • Non fare copia incolla del codice da testare nei metodi del progetto di test
    • Piuttosto usare le funzionalità offerte dal framework scelto per i test come client per interagire con le funzionalità da testare 
  • Cosa testare =>
    • Se la routine ritorna un valore, testare che sia quello desiderato per ognuno dei possibili percorsi d’esecuzione all’interno della routine stessa.
    • In MVC se il controller ha una sola linea di codice (come in un’architettura DDD) non c’è bisogno di testarlo
    • Il business layer ha bisogno di unit test 
    • Testare solo il proprio codice => non testare codice esterno non scritto da te, as es: EF o ASP.NET
    • Testare l’iterazione tra oggetti
      • Validare se le chiamate ai componenti esterni sono state effettuate
      • Non è necessario validare se i componenti esterni hanno ben funzionato (saranno gli intergration test a farlo)
    • Testare il cambio di stato dei componenti
      • Significa testare ciò che ha cambiato di valore
  • Cosa non testare =>
    • Caratteristiche del linguaggio.
    • Codice di terzi.
    • Metodi private o protected.
  • Organizzazione del progetto di test =>
    • Ogni test avrà 3 fasi (Metodo della tripla A)
      • Arrange => istanziare il metodo da testare
      • Act => eseguire il metodo da testare
      • Assert => verificare l’esecuzione
    • Per ogni progetto della soluzione creare un progetto di test.
    • Separare il progetto secondo le componenti del progetto da testare

    • Per ogni classe di un progetto creare una classe di test.
    • Per ogni metodo di una classe creare un metodo nella corrispondente classe di test.
      1. Numero di tests >= numero di percorsi di esecuzione del metodo da testare
      2. Creare i metodi vuoti per ognuno dei percorsi da testare e poi iniziarne l’implementazione.
      3. Se un metodo è particolarmente complicato e con molti percorsi da testare è possibile creare una classe ad hoc.
      4. Ogni metodo deve essere isolato.
      5. Testare ogni return presente nella routine/funzione
      6. Validare ogni edge case
      7. Black Box => Non bisogna realizzare il metodo di test in funzione dell’implementazione del metodo da testare ma della funzione che deve realizzare. 
      8. Trustworthy  = > E’ possibile aumentare la confidenza verso il metodo di test che abbiamo scritto testandolo nel seguente modo:
        1. Creare un bug  nel metodo che si sta testando.
        2. Lanciare il metodo di test che abbiamo scritto.
        3. Verificare che il metodo di test fallisca. Se non fallisce, il metodo di test ha un bug.
      9. NUnit =>
        • [TestCases] => E’ possibile creare un metodo di test con parametri e eseguirlo più volte decorandolo con [TestCases]. Ciò permette di centralizzare l’esecuzione di test simili.
        • [SetUp] => E’ possibile creare un metodo decorato con [SetUp] che verrà eseguito prima di ogni test.
        • [TearDown] (spesso usato nei test di integrazione) => E’ possibile creare un metodo decorato con [TearDown] che verrà eseguito dopo di ogni test.
      10. NUnit e MSTest =>
        • [Ignore] => E’ possibile disabilitare temporaneamente un metodo e saltarne l’esecuzione aggiungendo al metodo l’attributo [Ignore(“descrizione del motivo per cui ho disabilitato il test”)]
  • Naming convention =>
    • Nome progetto per i test => [nome_progetto_da_testare].UnitTests
    • Nome classi => [nome_classe_da_testare]Tests
    • Nome metodi => (3 parti) [Component_to_test]_[Scenario]_[ExpectedBehavior or return value]_[possible Parameters]
      • Scenario =>
        • Scrivere un metodo di test per ognuno dei possibili percorsi di esecuzione
        • WhenCalled => usarlo quando si vuole testare il solo percorso possibile
      • ExpectedBehavior => 
        • ReturnsTrue
        • ReturnsFalse
        • ReturnsTheSumOfArguments
      • Esempi =>
        • //Homecontroller => componente
          //Index => action o scenario
          //ShouldReturnView => comportamento o valore atteso
          HomeController_Index_ShouldReturnView()

          //stesso caso di sopra ma con parametro in ingresso
          //WithNullId => attendo un parametro in ingresso
          HomeController_Index_ShouldReturnView_WithNullId() 

          GetItemAsync_WithUnexistingItem_ReturnNotFound

2. Integration Tests

  • Intera pipeline => Servono per testare l’intero ciclo della mia web app, dall’inizio delle http request alla fine della http response:
    • routing
    • controllers
    • actions
    • middlelayer
    • database
  • Ogni test deve essere eseguito in isolazione e indipendente dagli altri
  • Usare oggetti fake il meno possibile
    • fake le chiamate al db
    • fake le chiamate a provider esterni (es: provider di pagamento, provider di geolocalizzazione
    • non fake i componenti interni del nostro sistema
  • Possono avere delle parti fake (es: database) ma la maggior parte dei componenti esterni non devono essere fake
  • Rompere le dipendenze esterne  =>  Dependency Injection (DI)
    • Per poter unit-testare del codice che abbia delle interazioni con risorse esterne è necessario rompere queste dipendenze ottenendo così un ambiente debolmente accoppiato =>
      • Eseguire le seguenti 3 operazioni:
        1. Isolare le classi del nostro codice che dialogano con l’esterno =>
          • Cercare ad esempio i vari punti dove si istanziano delle nuove classi.
        2. Estrarre da queste classi delle interfacce.
        3. Modificare le altre classi del nostro codice affinché usino tali interfacce.
          • Sostituire cioè le istanziazioni con il passaggio di interfacce.
      • A questo punto il nostro codice non dipenderà più da una implementazione specifica ma da un’interfaccia =>
        • In un ambiente di test verranno passate delle implementazioni fake (mock o stub) di tali interfaccie.
    • Tre modi per implementare la Dependency Injection =>
      1. Instanziazione nel costruttore =>
        • utilizzo di una framework per DI
      2. Passaggio come parametro =>
        • necessita di riscrivere la signature del metodo ovunque questa è usata.
        • non tutti i framework che gestiscono la DI gestiscono questo tipo di ingezione.
      3.  Passaggio tramite poprietà
  • Dependency Injection Framework =>
    • Nella maggior parte dei DI framework esiste un container dove sono registrate tutte le interfacce e le loro implementazioni concrete. 
    • Quando l’applicazione il framework è responsabile di inizializzare le giuste dipendenze per ogni oggetto pescando le corrispondenze dal container.
    • I più diffusi =>
      • NInject
      • Autofac
  • Azure (CI/CD) =>
    • Testare web api che accede ad un db sql server tramite EF.
    • Progetto di test =>
      • Aggiungere la referenza al progetto web api che si vuole testare.
      • appsetting.json =>
        • Il progetto di test ha il proprio file appsetting.json in modo da poter configurarli con la massima libertà
        • Ad esempio è possibile modificare la stringa id conessione e decidere di eseguire i test un un’altro DB
      • DbContext factory =>
        • Il progetto di test per poter testare la connessione al DB deve istanziare il DbContext
        • Aggiungere il package Microsoft.Extensions.Configuration.Json
        • Aggiungere il package ‘EntityFrameworkCore.SqlServer’
        • using Microsoft.Extensions.Configuration;

          //creare il DbContext
          var factory = new CookBookContextFactory();
          using var CookBook = factory.CreateDbContext(args);

          //abbiamo deciso di lanciare il test su un nuovo database
          //(semplicemente customizzando l'appsettings.json del progetto di test)
          //quindi dobbiamo assicurarci di crearlo prima di usarlo.
          //per evitare una doppia creazione, prima lo elimino
          await context.Database.EnsureDeletedAsync();
          await context.Database.EnsureCreatedAsync();

          //Se decido di rimuovere e ricreare il Db ad ogni lancio di test
          //allora posso saltare il prossimo passaggio (eliminazione customer eventualmente già esistenti)
          //Delete all existing customer
          //context.Customers!.RemoveRange(await context.Customers.ToArrayAsync());
          //await context.SaveChangesAsync();

          //Factory class for retrieving DbContext
          class MyDbContextFactory: IDesignTimeDbContextFactory<MyDbContext>
          {
          public <MyDbContext> CreateDbContext(string[]? args = null)
          {
          var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build();

          var optionBuilder = new DbContextOtionsBuilder<MyDbContext>();
          optionBuilder
          .UseSqlServer(configuration["ConnectionStrings:DefaultConnection"]);

          return MyDbContext(optionBuilder.options);
          }
          }
      • Testare il controller => 
        • Aggiungere il package ‘AspNetCore.Mvc.Core’
        • //Create customer controller
          var controller = new CustomersController(context);

          //Add customer
          var customer = new Customer() { Name = "prova" };
          await controller.Add(customer);

          //Check does the GetAll return the added customers
          var result = (await controller.GetAll()).ToArray();

          //XUnit syntax
          Assert.Single(result);
          Assert.Equal("prova", result[0].Name);
  • Mocking Framework =>
    • Quando usare classi mock =>
      • Per testare dipendenze esterne
      • Per testare una specifica classe in scenari in cui più classi sono chiamate insieme.
    • Moq (instalare tramite NuGet) =>
      • Verify() method =>
        • Per fare dei test d’integrazione si usa spesso l’opzione Verify di Moq. Cioè verifico che la routine del componente esterno sia stata chiamata con i parametri d’ingresso desiderati. 
        • Oppure che non sia stata chiamata (usando Times.Never)
      • Object property => Una volta inizializzato l’ogetto Mock per recuperarne un’istanza è necessario usare la proprietà object
        • var fileReaderMoq = new Mock<IFileReader>();

          IFileReader myfileReaderObj = fileReaderMoq.Object;
      • Setup => E’ possibile inizializzare il comportamento di un dato metodo nella classe moq 
        • //la classe moq è inizializzata affinché il metodo Read() per ogni stringa in ingresso ritorni stringa vuota ("")
          fileReaderMoq.Setup(x => x.Read(It.IsAny<string>())).Returns("");
      • Stub vs Moq => Se non uso il metodo .Verify posso chiamare le mie variabili con il suffisso stub se no posso usare moq
        • - caso1 => è il caso non uso il metodo fileReaderStub.Verify(...)
          var fileReaderStub = new Mock<IFileReader>();

          - caso 2 => uso il metodo fileReaderStub.Verify(...)
          var fileReaderMoq = new Mock<IFileReader>()
      • Run test =>
        • Tramite console =>

        • .NET Core Test Explorer for VS Code => Direttamente dalla metodo di test

      • Esempio classe (IFileReader) =>
        • public class MyClass
          {
          string Name {get; set;}
          }

          public class MyTestWithMoq
          {

          MyClass myClass = new { Name = "prova" };

          -- step1 - creazione di un oggetto Mock per simulare l'interfaccia IFileReader
          var fileReaderMoq = new Mock<IFileReader>();

          -- step2 - inizializzazione dell'oggetto Mock
          //quando il metodo Read viene chiamato con la stinga "video.txt" come parametro deve ritornare ""
          fileReaderMoq.Setup(x => x.Read("video.txt")).Returns("");
          fileReaderMoq.Setup(x => x.Read(It.IsAny<string>())).Returns("");
          fileReaderMoq.Setup(x => x.Read(It.IsAny<myClass>())).Returns("prova");
          //caso asincrono
          fileReaderMoq.Setup(x => x.Read(It.IsAny<myClass>())).ReturnsAsync("prova");
          //faccio in modo che il metodo ReadAll lancia un'eccezione
          fileReaderMoq.Setup(x => x.ReadAll(It.IsAny<string>()).Throws<Exception>();

          -- step 3 - verifico che l'eccezione sia stata ben lanciata
          Assert.That(result, Throws.TypeOf<Exception>());
          Assert.IsType<NotFoundResult>(myResult);

          //utilizzo dell'oggetto Mock nel costruttore di VideoService
          var videoService = new VideoService(fileReaderMoq.Object);

          -- Verify
          //verifico che il metodo Store sia stato effettivamente chiamato
          fileReaderMoq.Verify(s => s.Store(video));
          //verifico che il metodo Store non venga mai chiamato
          fileReaderMoq.Verify(s => s.Store(video), Times.Never);
          //posso che il metodo Store sia chiamato una volta
          fileReaderMoq.Verify(s => s.Store(video), Times.Once);
          }
      • Esempio Controller – Test Get item  =>
        • Action da testare – GetItemAsync(Guid id):

        • Caso item is null – GetItemAsync_WithUnexisitingItem_ReturnsNotFound():

          • Usano Fluent Assertion library l’Assert:

        • Caso item is not null – GetItemAsync_WithExisitingItem_ReturnsExpectedItem():

          • Usano Fluent Assertion library l’Assert diventa: 

      • Esempio ControllerTest a collection =>
        • Action da testare – GetItemsAsync():

        • Caso return all items – GetItemsAsync_WithExisitingItem_ReturnsAllItems():

      • Esempio Controller – Test post =>
        • Action da testare – CreateItemAsync(CreateItemDto item):

        • Test creazione – CreateItemAsync_WithItemToCreate_ReturnCreatedItem():

      • Esempio Controller – Test update =>
        • Action da testare – UpdateItemAsync(Guid id, UpdateItemDto item):

        • Test update – UpdateItemAsync_WithExisitingItem_ReturnsNoContent():

      • Esempio Controller – Test Delete =>
        • Action da testare – DeleteItemAync(Guid id)

        • Test delete:

    • Altri Moq framework =>
      • NSubstitute
      • FateItEasy
      • Rhyno Mocks
  • Fluent Assertion library =>
    • Permette di fare Assert.Equal in mdo automatico per tutte le proprietà dell’oggetto da comparare 
    • Installare package:

  •  
  •