blexin

Sviluppo
Consulenza e
Formazione IT


}

Blog

Evolvi la tua azienda

Scopriamo come sfruttare la metodologia TDD per migliorare l'approccio allo sviluppo delle nostre applicazioni

TDD: tutto il codice è colpevole fino a prova contraria!

Mercoledì 15 Maggio 2019

Lavorando con un nostro partner su un progetto di un cliente, ho avuto l'opportunità di approcciare lo sviluppo di alcuni task con una pratica di cui avevo sempre sentito parlare ma che non avevo mai utilizzato prima: Test Driven Development (TDD). Questa pratica consiste nello scrivere i test del codice che sviluppiamo prima ancora di scrivere il codice stesso!
Considerando che ad oggi è difficile trovare progetti su cui si riesca ad avere delle batterie di test, questa esperienza mi ha davvero colpito soprattuto per il valore aggiunto che un approccio del genere può dare allo sviluppo software.

I principi di questa disciplina sono stati definiti in maniera molto dettagliata da Uncle Bob e sono i seguenti:

  • You are not allowed to write any production code unless it is to make a failing unit test pass.
  • You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
  • You are not allowed to write any more production code than is sufficient to pass the one failing unit test.

Sinteticamente, la cosa da tenere presente quando si utilizza questa metodologia è:

"Only ever write code to fix a failing test"

ovvero scrivere codice solo per evitare che un test fallisca.


Le tre fasi di TDD: Red, Green, Refactor

Test Driven Development si fonda su 3 fasi: Red, Green, Refactor.

""

Fase Rossa

In questa fase si deve scrivere un comportamento che si vuole testare senza pensare al modo in cui il codice reale deve essere implementato. Occorre identificare solo come deve comportarsi con determinati input.
Se stai pensando a come implementarlo, stai sbagliando!
In questa fase vanno prese solo decisioni su come il tuo codice verrà utilizzato.

Fase Verde

Questa è la fase che piace a noi programmatori, scrivere codice.
L'errore più comune che si può fare in questa fase è quello di pensare ad implementare il comportamento atteso dal test nel miglior modo possibile magari andando a complicarlo per sviluppi futuri.
Anche in questo caso stai sbagliando!
Va scritto il codice sufficiente per far passare il test da rosso a verde, senza stare a pensare se si sta duplicando codice oppure non si stanno seguendo le Best Practices, non è in questa fase che vanno fatte queste considerazioni.
Il Test Driven Development mette a disposizione una to-do list in cui annotare tutto quello che occorre per il completamento della funzionalità che stiamo implementando. La stessa lista contiene inoltre i dubbi o i problemi che si scoprono man mano che si scrive codice. Al termine dell'intero processo tale lista dovrà risultare necessariamente vuota.

Fase di Refactoring

L'ultima fase del processo ha uno scopo ben preciso, migliorare il codice senza cambiarne il comportamento.
Abbiamo il nostro test verde che ci garantisce che la funzionalità che abbiamo implementato faccia ciò che deve e non ci resta quindi che rendere il codice migliore.
E' il momento di togliere tutto il codice superfluo, guardare ciò che si è scritto con un occhio critico e applicare le best practices.
Terminata questa fase siamo pronti per ricominciare con la fase rossa.

La parte divertente

Una volta compreso la modalità operativa voglio provare ad applicare questa metodologia con un esempio molto semplice: il focus deve essere sull'approccio e non sulla complessità dell'esempio! Creiamo un'applicazione Web API in .NET Core che abbia come unica funzionalità quella di accogliere una richiesta GET con un custom header, e restituirmi un 202 (Accepted) nel caso in cui il custom header sia corretto, oppure un 401 (Unauthorized) nel caso non sia corretto o assente.
Utilizzo i template di VisualStudio per essere più rapido possibile nella creazione della nuova soluzione.

"step1"

"step2"

Aggiungo un ulteriore progetto alla solution, per la gestione dei test. "step3"

In questo modo ho la mia Solution con al suo interno due progetti, uno per il test e l'altro per l'API che vogliamo creare.

"step4"

Facciamo un po' di pulizia delle classi create dal template, andando ad eliminare ValueController.cs e UnitTest1.cs

Creiamo le classi DemoControllerTest nel progetto dei test e DemoController nel progetto delle API.

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace TddStepToStep.Tests
{
    [TestClass]
    public class DemoControllerTest
    {
    }
}
using Microsoft.AspNetCore.Mvc;

namespace TddStepToStep.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class DemoController : ControllerBase
    {   
    }
}

specifichiamo il target del test, ovvero la classe DemoController:

private DemoController _target;

Aggiungiamo un metodo per inizializzare la classe di test in cui creiamo l'istanza di DemoController passandogli una classe di configurazione. Nella classe di configurazione è presente la proprietà ApiKey che verrà utilizzata per determinare se il valore passato dalla richiesta è corretto.
Ovviamente il compilatore ci dirà che non esiste nessuna classe DemoControllerConfig, che DemoController non ha nessun costruttore che accetta un parametro di quel tipo e che non ha la minima idea di chi sia CORRECT_API_KEY.

Partiamo dalla cosa più semplice, creare la costante CORRECT_API_KEY

public const string CORRECT_API_KEY = "1234";

Poi creiamo la classe DemoControllerConfig nel progetto API, inizialmente vuota ma facendoci aiutare da VS tramite lo shortcut CTRL + '.' che ci suggerirà di aggiungere una proprieta ApiKey alla classe. Stessa identica cosa la facciamo per creare il costruttore e relativa proprietà privata per DemoController:

using TddStepToStep.Controllers;

namespace TddStepToStep.Tests
{
    [TestClass]
    public class DemoControllerTest
    {
        public const string CORRECT_API_KEY = "1234";
        private DemoController _target;

        [TestInitialize]
        public void Init()
        {
            _target = new DemoController(new DemoControllerConfig() { ApiKey = CORRECT_API_KEY });
        }
    }
}
using Microsoft.AspNetCore.Mvc;

namespace TddStepToStep.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class DemoController : ControllerBase
    {
        private DemoControllerConfig demoControllerConfig;

        public DemoController(DemoControllerConfig demoControllerConfig)
        {
            this.demoControllerConfig = demoControllerConfig;
        }
    }
}

L'idea di passare una classe che contiene l'ApiKey al Controller invece di recuperarne il valore da un file di configurazione viene naturale quando si utilizza l'approccio test first: in questo modo stiamo separando la nostra logica da fattori esterni.
Lo step successivo è quello di creare il test vero e proprio.
Voglio testare che quando arriva una richiesta HTTP in cui è presente un determinato header con valore uguale a quello fornito, lo Status Code della risposta sia 202 accepted.
La scelta del nome del test deve rispecchiare esattamente quello che vogliamo testare, e un buon approccio è quello di scrivere il nome del test seguendo questa convenzione Should_ExpectedBehavior_When_StateUnderTest.
Quindi il nome del mio test sarà ShouldReturnAcceptedResultWhenCorrectApiKeyIsPassed.
Per poter passare un Header ad un Controller, sono necessarie un paio di righe di codice

_target.ControllerContext = new ControllerContext();
_target.ControllerContext.HttpContext = new DefaultHttpContext();
_target.ControllerContext.HttpContext.Request.Headers.Add("X-API-KEY", CORRECT_API_KEY);

Per aggiungere un header creiamo un nuovo ControllerContext per il Controller che stiamo testando, a cui impostiamo un nuovo HttpContext dal quale possiamo accedere alla Request in questa troviamo gli Headers. Aggiungiamo quindi l'header X-API-KEY con il valore corretto. A questo punto possiamo simulare la chiamata all'API semplicemente invocando il metodo che risponde alla chiamata.

var resp = _target.GetValues();

Prensiamo la risposta ottenuta e la diamo in pasto all'Assert.

Assert.IsInstanceOfType(resp, typeof(AcceptedResult));

Il compilatore avrà da obiettare sull'inesistenza di GetValues(), ma utilizzando il nostro fidato CTRL + '.' possiamo creare il metodo mancante che andremo poi ad implementare.

[HttpGet]
public ActionResult GetValues()
{
    throw new NotImplementedException();
}

Abbiamo finalmente il nostro metodo di test, che possiamo eseguire e che ci aspettiamo fallisca (fase rossa):

[TestMethod]
public void ShouldReturnAcceptedResultWhenCorrectApiKeyIsPassed()
{
    _target.ControllerContext = new ControllerContext();
    _target.ControllerContext.HttpContext = new DefaultHttpContext();
    _target.ControllerContext.HttpContext.Request.Headers.Add("X-API-KEY", CORRECT_API_KEY);
    var resp = _target.GetValues();
    Assert.IsInstanceOfType(resp, typeof(AcceptedResult));
}

"Failed Test"

Il test fallisce perchè non abbiamo ancora implementato il metodo GetValues nel controller.
Quindi implementiamo il metodo in modo tale che il test risulti verde.

[HttpGet]
public ActionResult GetValues()
{
    if (Request.Headers.ContainsKey("X-API-KEY") 
            && Request.Headers["X-API-KEY"].Equals(demoControllerConfig.ApiKey))
        return Accepted();

    return Ok();
}

Rieseguiamo il test: "Passed Test"

Il primo requisito è rispettato, ci manca di ottenere un 401 nel caso in cui la chiave non sia corretta.
Aggiungo il test e lo vado ad eseguire aspettandomi anche questa volta che fallisca.

public const string WRONG_API_KEY = "1235";
[TestMethod]
public void ShouldReturnUnauthorizedResultWhenWrongApiKeyIsPassed()
{
    _target.ControllerContext = new ControllerContext();
    _target.ControllerContext.HttpContext = new DefaultHttpContext();
    _target.ControllerContext.HttpContext.Request.Headers.Add("X-API-KEY", WRONG_API_KEY);
    var resp = _target.GetValues();
    Assert.IsInstanceOfType(resp, typeof(UnauthorizedResult));
}

"Second Test Failed"

Il messaggio di errore che riceviamo dipende dal fatto che il test si aspetta un Unauthorized ma ha invece restituito un OK.
Modifichiamo quindi il comportamento del metodo GetValues:

[HttpGet]
public ActionResult GetValues()
{
    if (!Request.Headers.ContainsKey("X-API-KEY") 
            || !Request.Headers["X-API-KEY"].Equals(demoControllerConfig.ApiKey))
        return Unauthorized();

    return Accepted();
}

Rieseguiamo i test: "All Tests Passed"

Ottimo, obiettivo raggiunto.

In base a quanto detto prima però, manca la fase di refactoring, che vi lascio come esercizio! Potete per esempio mettere parte del codice in un Action Filter...

Spero di avervi incuriosito.
Alla prossima.

Autore

Servizi

Evolvi la tua azienda