Home > Articoli > Unit testing: Come scrivere la tua prima unit test!

Unit testing: Come scrivere la tua prima unit test!

Nell’articolo precedente, il secondo di questa serie, abbiamo descritto come installare il framework tSQLt, il tool SQL Test di Red-Gate e come avviene l’esecuzione di una unit di test. Ora è giunto il momento di scrivere il nostro primo test! Useremo gli strumenti che abbiamo descritto in precedenza!

Specifiche e requisiti

Scriveremo la nostra prima unit di test per verificare i requisiti di un trigger che ci è stato commissionato dall’Azienda inventata Adventure Works LTD il cui database è disponibile per il download su questo repository di GitHub. Il database AdventureWorks2017 contiene la tabella Product riferita allo schema Production. La tabella Product rappresenta l’anagrafica dei prodotti gestiti e commercializzati dall’Azienda che vi ha commissionato un trigger per impedire l’inserimento di nuovi prodotti aventi come “scorta di sicurezza” valori minori di 10. L’Azienda desidera quindi avere sempre una scorta di magazzino pari a 10 unità. La scorta di sicurezza è un dato molto importante per le procedure automatiche di riordino dei materiali, che ne tengono conto per l’emissione degli ordini a fornitore o degli ordini di produzione. Per semplificare l’esempio, il trigger risponderà soltanto all’evento OnInsert, per i comandi INSERT.

Questo è il codice del trigger che abbiamo realizzato, la stored procedure usp_Raiserror_SafetyStockLevel gestisce l’errore in modo centralizzato.

USE [AdventureWorks2017];
GO

CREATE PROCEDURE Production.usp_Raiserror_SafetyStockLevel
(
  @Message NVARCHAR(256)
)
AS
BEGIN
  ROLLBACK;
  RAISERROR(@Message, 16, 1);
END;

CREATE TRIGGER Production.TR_Product_SafetyStockLevel ON Production.Product
AFTER INSERT AS
BEGIN
  /* 
     Avoid to insert products safety stock level lower than 10
  */
  DECLARE @SafetyStockLevel SMALLINT;

  SELECT
    @SafetyStockLevel = SafetyStockLevel
  FROM
    inserted;

  IF (@SafetyStockLevel < 10)
    -- Error!!
    EXEC Production.usp_Raiserror_SafetyStockLevel
      @Message = 'Safety stock level cannot be lower than 10!';
END;
GO

Definizione del System Under Test

La prima cosa da fare quando si inizia a scrivere una unit di test è definire il System Under Test (SUT) per isolarlo in modo che non venga influenzato dal comportamento di altre parti di codice, procedure o funzioni. In questo esempio il System Under Test è il trigger TR_Product_SafetyStockLevel.

Definizione dei test case

Una volta definito il System Under Test, prima di iniziare a scrivere le unit di test è necessario individuare i test case pensando ai requisiti che il trigger deve soddisfare: Il trigger deve impedire vengano inseriti nuovi prodotti con valori di scorta di sicurezza inferiori a 10. I test case da trasformare in unit test sono quindi:

  • Inserimento di un prodotto con scorta di sicurezza maggiore o uguale a 10
  • Inserimento di un prodotto con scorta di sicurezza minore di 10
  • Inserimento multiplo di prodotti, il primo con scorta di sicurezza maggiore di 10
  • Inserimento multiplo di prodotti, il primo con scorta di sicurezza minore di 10

State pensando ad altri test case? Quali? Pensate che il quarto test sia superfluo perché già contemplato nel terzo? Lo vedremo tra poco! Ogni singolo test deve verificare una sola condizione, dovremo scrivere quindi quattro unit test, in pratica quattro stored procedure.

La nostra prima unit test

Tutte le unit test dell’oggetto che si desidera testare devono essere contenute all’interno di una “classe di test” del framework tSQLt. Per creare una classe di test è sufficiente invocare la stored procedure tSQLt.NewTestClass specificando il nome della classe di test che abbiamo chiamato UnitTestTRProductSafetyStockLevel. Il seguente codice T-SQL esegue la creazione della nuova classe di test. La stessa operazione può essere eseguita anche con l’interfaccia grafica del tool SQL Test di Red-Gate.

USE [AdventureWorks2017];
GO

-- Create new test class
-- The test class collects test cases for this class
EXEC tSQLt.NewTestClass 'UnitTestTRProductSafetyStockLevel';
GO

E’ giunto il momento di scrivere la nostra prima unit test.. eccola qui:

USE [AdventureWorks2017];
GO

CREATE PROCEDURE UnitTestTRProductSafetyStockLevel.[test try to insert one wrong row]
AS
BEGIN
  /*
    Arrange:
    Spy the procedure Production.usp_Raiserror_SafetyStockLevel
  */
  EXEC tSQLt.SpyProcedure 'Production.usp_Raiserror_SafetyStockLevel';

  /*
    Act:
    Try to insert one wrong rows with SafetyStockLevel lower than 10
  */
  INSERT INTO Production.Product
  (
    [Name]
    ,ProductNumber
    ,MakeFlag
    ,FinishedGoodsFlag
    ,SafetyStockLevel
    ,ReorderPoint
    ,StandardCost
    ,ListPrice
    ,DaysToManufacture
    ,SellStartDate
    ,rowguid
    ,ModifiedDate
  )
  VALUES
  (
    N'Carbon Bar 1'
    ,N'CB-0001'
    ,0
    ,0
    ,9 /* SafetyStockLevel */
    ,750
    ,0.0000
    ,78.0000
    ,0
    ,GETDATE()
    ,NEWID()
    ,GETDATE()
  );

  /*
    Assert
  */
  IF NOT EXISTS (SELECT _id_ FROM Production.usp_Raiserror_SafetyStockLevel_SpyProcedureLog)
    EXEC tSQLt.Fail
      @Message0 = 'Production.usp_Raiserror_SafetyStockLevel_SpyProcedureLog is empty! usp_raiserror has not been called!';
END;

La stored procedure UnitTestTRProductSafetyStockLevel.[test try to insert one wrong row] implementa le sezioni note con il nome di Arrange, Act e Assert.

Nella sezione Arrange troviamo la chiamata a tSQLt.SpyProcedure con la quale si vuole isolare la stored procedure Production.usp_Raiserror_SafetyStockLevel che restituirebbe errore all’interno del trigger facendo fallire il test in modo sistematico. Per creare test indipendenti, possiamo sostituire le azioni eseguite da una stored procedure con una “spia”. tSQLt.SpyProcedure consente di scrivere test per una procedura in isolamento delle altre. tSQLt.SpyProcedure effettuerà la creazione di una tabella il cui nome viene composto concatenando il nome della stored procedure che si desidera isolare (@ProcedureName) e “SpyProcedureLog”. Questa tabella conterrà una colonna Identity “_id_” e una colonna per ogni parametro della procedura. SpyProcedure può sostituire le azioni eseguite dalla stored procedure indicata nel parametro @ProcedureName con il comando fornito nel parametro @CommandToExecute. Ogni volta che @ProcedureName verrà invocata durante il test, invece di eseguire effettivamente la procedura, verrà creata una nuova riga di log nella tabella @ProcedureName_SpyProcedureLog e se specificato verrà invocato il comando contenuto nel parametro @CommandToExecute.

Nella sezione Act troviamo il comando INSERT (rivolto alla tabella Production.Product) che scatenerà il trigger oggetto del test. Il valore specificato per la colonna SafetyStockLevel è minore di 10, il trigger dovrà impedirne l’inserimento.

Nella sezione Assert troviamo la chiamata a tSQLt.Fail, verrà eseguita qualora la tabella di log Production.usp_Raiserror_SafetyStockLevel_SpyProcedureLog fosse vuota.

Per eseguire il nostro primo test sarà sufficiente invocare tSQLt.Run specificando il nome della unit test da eseguire… l’output che otterremo sarà simile a quello riportato di seguito:

+----------------------+
|Test Execution Summary|
+----------------------+
 
|No|Test Case Name                                                        |Dur(ms)|Result |
+--+----------------------------------------------------------------------+-------+-------+
|1 |[UnitTestTRProductSafetyStockLevel].[test try to insert one wrong row]|     14|Success|
-----------------------------------------------------------------------------
Test Case Summary: 1 test case(s) executed, 1 succeeded, 0 failed, 0 errored.
-----------------------------------------------------------------------------

Il trigger ha impedito l’inserimento di un prodotto con scorta di sicurezza minore di zero, il test ha restituito esito positivo. Procediamo con la scrittura delle altre unit test..

USE [AdventureWorks2017];
GO

CREATE PROCEDURE UnitTestTRProductSafetyStockLevel.[test try to insert one right row]
AS
BEGIN
  /*
    Arrange:
    Spy the procedure Production.usp_Raiserror_SafetyStockLevel
  */
  EXEC tSQLt.SpyProcedure 'Production.usp_Raiserror_SafetyStockLevel';

  /*
    Act:
    Try to insert one right row with SafetyStockLevel lower than 10
  */
  INSERT INTO Production.Product
  (
    [Name]
    ,ProductNumber
    ,MakeFlag
    ,FinishedGoodsFlag
    ,SafetyStockLevel
    ,ReorderPoint
    ,StandardCost
    ,ListPrice
    ,DaysToManufacture
    ,SellStartDate
    ,rowguid
    ,ModifiedDate
  )
  VALUES
  (
    N'Carbon Bar 1'
    ,N'CB-0001'
    ,0
    ,0
    ,20 /* SafetyStockLevel */
    ,750
    ,0.0000
    ,78.0000
    ,0
    ,GETDATE()
    ,NEWID()
    ,GETDATE()
  );

  /*
    Assert
  */
  IF EXISTS (SELECT _id_ FROM Production.usp_Raiserror_SafetyStockLevel_SpyProcedureLog)
    EXEC tSQLt.Fail
      @Message0 = 'Production.usp_Raiserror_SafetyStockLevel_SpyProcedureLog is not empty! The value assigned to Safety Stock Level is right, it is greater than 10!';
END;

CREATE PROCEDURE UnitTestTRProductSafetyStockLevel.[test try to insert multiple rows]
AS
BEGIN
  /*
    Arrange:
    Spy the procedure Production.usp_raiserror_safety_stock_level
  */
  EXEC tSQLt.SpyProcedure 'Production.usp_Raiserror_SafetyStockLevel';

  /*
    Act:
    Try to insert multiple rows
    The first product has a wrong value for SafetyStockLevel column,
    whereas the value in second one is right
  */
  INSERT INTO Production.Product
  (
    [Name]
    ,ProductNumber
    ,MakeFlag
    ,FinishedGoodsFlag
    ,SafetyStockLevel
    ,ReorderPoint
    ,StandardCost
    ,ListPrice
    ,DaysToManufacture
    ,SellStartDate
    ,rowguid
    ,ModifiedDate
  )
  VALUES
  (
    N'Carbon Bar 1'
    ,N'CB-0001'
    ,0
    ,0
    ,9 /* SafetyStockLevel */
    ,750
    ,0.0000
    ,78.0000
    ,0
    ,GETDATE()
    ,NEWID()
    ,GETDATE()
  ),
  (
    N'Carbon Bar 3'
    ,N'CB-0003'
    ,0
    ,0
    ,15 /* SafetyStockLevel */
    ,750
    ,0.0000
    ,78.0000
    ,0
    ,GETDATE()
    ,NEWID()
    ,GETDATE()
  );

  /*
    Assert
  */
  IF NOT EXISTS (SELECT _id_ FROM Production.usp_Raiserror_SafetyStockLevel_SpyProcedureLog)
    EXEC tSQLt.Fail
      @Message0 = 'Production.usp_Raiserror_SafetyStockLevel_SpyProcedureLog is empty! usp_Raiserror_SafetyStockLevel has not been called!';
END;

CREATE PROCEDURE UnitTestTRProductSafetyStockLevel.[test try to insert multiple rows ordered]
AS
BEGIN
  /*
    Arrange:
    Spy the procedure Production.usp_Raiserror_SafetyStockLevel
  */
  EXEC tSQLt.SpyProcedure 'Production.usp_Raiserror_SafetyStockLevel';

  /*
    Act:
    Try to insert multiple rows
    The first product has a right value for SafetyStockLevel column,
    whereas the value in second one is wrong
  */
  INSERT INTO Production.Product
  (
    [Name]
    ,ProductNumber
    ,MakeFlag
    ,FinishedGoodsFlag
    ,SafetyStockLevel
    ,ReorderPoint
    ,StandardCost
    ,ListPrice
    ,DaysToManufacture
    ,SellStartDate
    ,rowguid
    ,ModifiedDate
  )
  VALUES
  (
    N'Carbon Bar 1'
    ,N'CB-0001'
    ,0
    ,0
    ,15 /* SafetyStockLevel */
    ,750
    ,0.0000
    ,78.0000
    ,0
    ,GETDATE()
    ,NEWID()
    ,GETDATE()
  ),
  (
    N'Carbon Bar 3'
    ,N'CB-0003'
    ,0
    ,0
    ,3 /* SafetyStockLevel */
    ,750
    ,0.0000
    ,78.0000
    ,0
    ,GETDATE()
    ,NEWID()
    ,GETDATE()
  );

  /*
    Assert
  */
  IF NOT EXISTS (SELECT _id_ FROM Production.usp_Raiserror_SafetyStockLevel_SpyProcedureLog)
    EXEC tSQLt.Fail
      @Message0 = 'Production.usp_Raiserror_SafetyStockLevel_SpyProcedureLog is empty! usp_Raiserror_SafetyStockLevel has not been called!';
END;
GO

Tutte le unit test sono state create, invochiamo tSQLt.RunTestClass con il nome della classe di test UnitTestTRProductSafetyStockLevel per eseguire tutti i test in essa contenuti.

USE [AdventureWorks2017];
GO

-- Run all tests in the class
EXEC tSQLt.RunTestClass 'UnitTestTRProductSafetyStockLevel';
GO

L’output sarà simile a quello riportato di seguito.

+----------------------+
|Test Execution Summary|
+----------------------+
 
|No|Test Case Name                                                                |Dur(ms)|Result |
+--+------------------------------------------------------------------------------+-------+-------+
|1 |[UnitTestTRProductSafetyStockLevel].[test try to insert multiple rows]        |     33|Success|
|2 |[UnitTestTRProductSafetyStockLevel].[test try to insert one right row]        |     17|Success|
|3 |[UnitTestTRProductSafetyStockLevel].[test try to insert one wrong row]        |     13|Success|
|4 |[UnitTestTRProductSafetyStockLevel].[test try to insert multiple rows ordered]|     30|Failure|
----------------------------------------------------------------------------------------
Msg 50000, Level 16, State 10, Line 13
Test Case Summary: 4 test case(s) executed, 3 succeeded, 0 skipped, 1 failed, 0 errored.
----------------------------------------------------------------------------------------

Il quarto test è fallito, il trigger impedisce l’inserimento dei prodotti soltanto se ordinati in modo che i valori minori di dieci per la scorta di sicurezza si trovino nella prima riga, sul primo prodotto che viene inserito. Lo avreste mai detto? L’ordinamento dei prodotti può eludere il controllo effettuato dal trigger con conseguente inserimento di dati errati!

Quando la tabella virtuale “Inserted” conterrà una sola riga, il trigger lavorerà correttamente, negli altri casi non avremo garanzie!

Osservando il valore assunto dalle variabili si intuisce facilmente l’anomalia presente nel codice. La variabile @SafetyStockLevel potrà verificare soltanto una delle righe interessate dal comando di INSERT (la prima), assumendo valide o non valide tutte le altre in funzione del risultato ottenuto per la prima riga. Grazie all’esecuzione dei test abbiamo trovato un bug prima del rilascio!

Modifichiamo il trigger come descritto di seguito per correggere l’anomalia emersa durante l’esecuzione delle unit test.

ALTER TRIGGER Production.TR_Product_SafetyStockLevel ON Production.Product
AFTER INSERT AS
BEGIN
  /* 
     Avoid to insert products safety stock level lower than 10
  */

  -- Testing all rows in the Inserted virtual table
  IF EXISTS (
             SELECT ProductID
             FROM inserted
             WHERE (SafetyStockLevel < 10)
            )
    -- Error!!
    EXEC Production.usp_Raiserror_SafetyStockLevel
      @Message = 'Safety stock level cannot be lower than 10!';
END;
GO

Il vantaggio di aver previsto le unit di test per il trigger che abbiamo realizzato lo si percepisce in questo momento: Le unit test non solo ci hanno permesso di rilevare un bug, ora ci danno la serenità e la sicurezza di poter riprovare i casi di test per verificare che le modifiche eseguite per la correzione del bug non abbiamo introdotto anomalie. Rieseguiamo quindi tutti i test case invocando tSQLt.RunTestClass in questo modo:

USE [AdventureWorks2017];
GO

-- Run all tests in the class
EXEC tSQLt.RunTestClass 'UnitTestTRProductSafetyStockLevel';
GO

L’output sarà simile a quello riportato di seguito.

+----------------------+
|Test Execution Summary|
+----------------------+
 
|No|Test Case Name                                                                |Dur(ms)|Result |
+--+------------------------------------------------------------------------------+-------+-------+
|1 |[UnitTestTRProductSafetyStockLevel].[test try to insert multiple rows ordered]|     30|Success|
|2 |[UnitTestTRProductSafetyStockLevel].[test try to insert multiple rows]        |     17|Success|
|3 |[UnitTestTRProductSafetyStockLevel].[test try to insert one right row]        |     47|Success|
|4 |[UnitTestTRProductSafetyStockLevel].[test try to insert one wrong row]        |     47|Success|
----------------------------------------------------------------------------------------
Test Case Summary: 4 test case(s) executed, 4 succeeded, 0 skipped, 0 failed, 0 errored.
----------------------------------------------------------------------------------------

Conclusioni

In questo articolo abbiamo descritto come scrivere la nostra prima unit test. Il trigger di esempio che abbiamo scelto è intenzionalmente semplice. Pensate invece ai trigger, alle stored procedure e funzioni complesse che avete scritto (quelle con più di 200 righe di codice per intenderci 😊), vi sentireste sicuri nel modificarle? Se la risposta è “No” la prima cosa da fare è scrivere le unit di test, ora sapete come fare!

Nel prossimo articolo approfondiremo il concetto di “test in isolamento” e i possibili Assert che il framework tSQLt fornisce.

Chi è Sergio Govoni

Sergio Govoni è laureato in Scienze e Tecnologie Informatiche. Da oltre 16 anni lavora presso una software house che produce un noto sistema ERP, distribuito a livello nazionale ed internazionale, multi azienda client/server su piattaforma Win32. Attualmente si occupa di progettazione e analisi funzionale, coordina un team di sviluppo ed è responsabile tecnico di prodotto. Lavora con SQL Server dalla versione 7.0 e si è occupato d'implementazione e manutenzione di database relazionali in ambito gestionale, ottimizzazione delle prestazioni e problem solving. Nello staff di UGISS si dedica alla formazione e alla divulgazione in ambito SQL Server e tecnologie a esso collegate, scrivendo articoli e partecipando come speaker ai workshop e alle iniziative del primo e più importante User Group Italiano sulla tecnologia SQL Server. Ha conseguito la certificazione MCP, MCTS SQL Server. Per il suo contributo nelle comunità tecniche e per la condivisione della propria esperienza con altri, dal 2010 riceve il riconoscimento SQL Server MVP (Microsoft Most Valuable Professional). Nel corso dell'anno 2011 ha contribuito alla scrittura del libro SQL Server MVP Deep Dives Volume 2 (http://www.manning.com/delaney/).

Leggi Anche

UGISS ospite di AWS Online Tech Talks

Questa sessione è la prima di una serie di appuntamenti insieme a Gianluca Hotz, Microsoft …