Commencer avec NUnit
Par Eric. Tests unitaires | Lien permanent.
NUnit est le principal framework de tests unitaires pour la plateforme .NET. Et il est gratuit.
Pré-requis
Il suffit de télécharger la dernière version sur le site officiel et de l'installer. Pour cela, suivez les instructions fournies par le site. Il est quelque peu basique mais tout y est dit.
Comment créer un test ?
Projet de test
Comme d'habitude, il y a plusieurs façons de faire. Je conseille de créer un projet différent pour les tests et d'ajouter le ou les projets testés dans les références du projet de test. Cela évite d'alourdir inutilement le code des projets testés. L'inconvénient est que les membres non publics ne seront pas accessibles. Il y a cependant des contournements possibles que j'aborderai dans d'autres billets. Donc pour commencer, on testera les méthodes publiques d'un projet.
Il faut donc :
- Créer un nouveau projet, qu'on nomme par convention [librairie]Test où [librairie] est le nom du projet testé.
- Ajouter la référence
NUnit.Frameworkainsi que, évidemment, la ou les références des projets testés.
Classe de test
En général, on crée une classe de test par classe à tester. Ce n'est bien sûr pas une obligation mais ça permet d'avoir une structure claire. Le nom de la classe correspond au nom de la classe testée avec le suffixe Test.
Dans le cadre de cet article, je vais tester un projet très simple mais tout à fait concret, qui consiste à représenter un numéro IMEI (vous savez, ce qui identifie nos chers modems GSM). Voici donc la classe Imei :
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace IMEI
{
/// <summary>
/// <para>Entités officielles responsables de l'allocation de numéros IMEI.</para>
/// <para>Le cast en <see cref="byte"/> peut être rapproché à un <see cref="Imei.ReportingBodyIdentifier"/>.</para>
/// <remarks>Cette liste n'est pas exhaustive.</remarks>
/// </summary>
public enum ReportingBodyIdentifier : byte
{
/// <summary>
/// <para>GSM Satellites Iridium</para>
/// <remarks>Cette entité n'alloue plus de nouveaux IMEI.</remarks>
/// </summary>
Iridium_GsmSatellitesBands = 30,
/// <summary>
/// <para>British Approvals Board for Telecommunications</para>
/// <para>Les numéros IMEI de modems GSM sont alloués par cette association.</para>
/// </summary>
BABT_AllBands = 35,
/// <summary>
/// <para>British Approvals Board for Telecommunications</para>
/// <remarks>Cette entité n'alloue plus de nouveaux IMEI.</remarks>
/// <seealso cref="BABT_AllBands"/>
/// </summary>
BABT_900_1800 = 44
}
/// <summary>
/// Classe pour l'exploitation d'un numéro IMEI
/// </summary>
public class Imei
{
#region Fields
private const int LENGTH_IMEI = 15;
private const int LENGTH_IMSI = 16;
/// <summary>
/// Numéro IMEI sous forme de chaîne (renseigné par <see cref="ParseIMEI"/>).
/// </summary>
private string _imei;
#region IMEI parsing:
/* IMEI exemple:
* 35-209900-176148-1
*
* IMEISV exemple:
* 35-209900-176148-23
* */
private byte _reportingBodyId; // 35
private UInt32 _tac; // 352099
private byte _fac; // 00
private UInt32 _snr; // 176148
private byte _cd; // 1 (IMEI)
private byte _svn; // 23 (IMEISV)
private const int OFFSET_RBI = 0; // 35
private const int LENGTH_RBI = 2;
private const int OFFSET_TAC = 0; // 352099
private const int LENGTH_TAC = 6;
private const int OFFSET_FAC = 6; // 00
private const int LENGTH_FAC = 2;
private const int OFFSET_SNR = 8; // 176148
private const int LENGTH_SNR = 6;
private const int OFFSET_CD = 14; // 1
private const int LENGTH_CD = 1;
private const int OFFSET_SVN = 14; // 23
private const int LENGTH_SVN = 2;
#endregion
#endregion
#region Properties
/// <summary>
/// Obtient le code (de 0 à 99) identifiant l'entité ayant alloué cet identifiant de modèle (<see cref="TypeAllocationCode"/>).
/// </summary>
/// <seealso cref="AllocationNumber"/>
public byte ReportingBodyIdentifier
{
get
{
return _reportingBodyId;
}
}
/// <summary>
/// <para>Obtient le code (TAC) identifiant le modèle (6 chiffres).</para>
/// <para>Il s'agit de la concaténation de <see cref="ReportingBodyIdentifier"/> et de
/// <see cref="AllocationNumber"/>.</para>
/// <remarks>Plusieurs TAC peuvent être alloués à un même type de modem. C'est à la discrétion de l'entité
/// chargée d'allouer les TAC (<see cref="ReportingBodyIdentifier"/>). L'inverse n'est pas vrai.</remarks>
/// </summary>
public UInt32 TypeAllocationCode
{
get
{
return _tac;
}
}
/// <summary>
/// <para>Obtient le numéro alloué par l'entité <see cref="ReportingBodyIdentifier"/> (4 chiffres).</para>
/// <remarks>Champ également connu sous le nom "ME Type Identifier".</remarks>
/// </summary>
/// <seealso cref="TypeAllocationCode"/>
public UInt16 AllocationNumber
{
get
{
// Correspond aux 4 derniers chiffres du _tac
return UInt16.Parse(_tac.ToString("D" + LENGTH_TAC).Substring(LENGTH_RBI));
}
}
/// <summary>
/// Obtient le numéro de série (6 chiffres) qui identifie de façon unique une unité de ce modèle (<see cref="TypeAllocationCode"/>).
/// </summary>
public UInt32 SerialNumber
{
get
{
return _snr;
}
}
/// <summary>
/// Obtient le numéro d'assemblage (2 chiffres).
/// </summary>
[Obsolete("Le champ FAC d'un IMEI vaut toujours 00 depuis le 01/04/2004.")]
public byte FinalAssemblyCode
{
get
{
return _fac;
}
}
/// <summary>
/// <para>Obtient le chiffre de Luhn ou 0 sur certains modèles. S'il s'agit d'un IMEISV,
/// ce champ est remplacé par <see cref="SoftwareVersionNumber"/>.</para>
/// <remarks>Ce chiffre de 0 à 9 n'est généralement pas transmis sur un réseau (ou alors, il a la valeur "0").</remarks>
/// </summary>
public byte LuhnCheckDigit
{
get
{
return _cd;
}
}
/// <summary>
/// <para>Obtient le numéro (2 chiffres) d'identification de la version du logiciel installé dans le modem (99 est une valeur réservée).</para>
/// <remarks>Ne concerne que les IMEISV. Remplacé par <see cref="LuhnCheckDigit"/> pour un IMEI.</remarks>
/// </summary>
public byte SoftwareVersionNumber
{
get
{
return _svn;
}
}
#endregion
#region Constructors
/// <summary>
/// Constructeur à partir du numéro.
/// </summary>
/// <param name="imei">Numéro sous forme de chaîne</param>
/// <exception cref="ArgumentException">Le nombre de chiffres ne correspond pas.</exception>
public Imei(string imei)
{
ParseIMEI(imei);
}
#endregion
#region Methods
/// <summary>
/// Retourne le numéro IMEI sous forme de chaîne.
/// </summary>
/// <returns></returns>
public override string ToString()
{
return _imei;
}
/// <summary>
/// Parse un numéro IMEI ou IMSI.
/// </summary>
/// <param name="number"></param>
/// <exception cref="ArgumentException">Le nombre de chiffres ne correspond pas.</exception>
private void ParseIMEI(string number)
{
#region Recupere le n° en supprimant tout caractere séparateur éventuel
StringBuilder i = new StringBuilder(15);
foreach ( char c in number )
{
if ( char.IsDigit(c) )
i.Append(c);
}
number = i.ToString();
#endregion
if (number.Length<LENGTH_IMEI)
throw new ArgumentException("Le numéro IMEI fourni (" + number + ") ne contient pas le nombre de chiffres attendu !");
_imei = number;
#region IMEI/IMSI
_reportingBodyId = byte.Parse(number.Substring(OFFSET_RBI, LENGTH_RBI));
_tac = UInt32.Parse(number.Substring(OFFSET_TAC, LENGTH_TAC));
_fac = byte.Parse(number.Substring(OFFSET_FAC, LENGTH_FAC));
_snr = UInt32.Parse(number.Substring(OFFSET_SNR, LENGTH_SNR));
#endregion
#region Spécifique IMEI ou IMSI
switch (number.Length)
{
case LENGTH_IMEI:
_cd = byte.Parse(number.Substring(OFFSET_CD, LENGTH_CD));
break;
case LENGTH_IMSI:
_svn = byte.Parse(number.Substring(OFFSET_SVN, LENGTH_SVN));
break;
default:
throw new ArgumentException("Le numéro IMEI fourni (" + number + ") ne contient pas le nombre de chiffres attendu !");
}
#endregion
}
#endregion
}
}
Le projet test est donc nommé ImeiTest, de même que la classe de test qui est la suivante :
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using NUnit.Framework;
using IMEI;
namespace ImeiTest
{
/// <summary>
/// Teste le parseur de numéro IMEI et l'identification entre un modem Iridium et un modem GSM.
/// </summary>
[TestFixture]
public class ImeiTest
{
[Test]
[ExpectedException(typeof(ArgumentException))]
public void CheckInvalidIMEI([Values("30002501003060")] string imeiNumber)
{
Imei imei = new Imei(imeiNumber);
}
[Test]
public void CheckIMEI_Iridium([Values("300025010003060")] string imeiNumber)
{
Imei imei = Imei.FromString(imeiNumber);
if ( imei == null )
throw new ArgumentException("imeiNumber is not a valid IMEI: " + imeiNumber);
Assert.IsTrue(( imei.ReportingBodyIdentifier == (byte)ReportingBodyIdentifier.Iridium_GsmSatellitesBands ), "Not an Iridium IMEI: " + imeiNumber + " !");
}
[Test]
public void CheckIMEI_Gsm(
[Values(
"353851010156735",
"353851010156641",
"353851010157160",
"353851010157527"
)] string imeiNumber)
{
Imei imei = Imei.FromString(imeiNumber);
if ( imei == null )
throw new ArgumentException("imeiNumber is not a valid IMEI: " + imeiNumber);
Assert.IsTrue(( imei.ReportingBodyIdentifier == (byte)ReportingBodyIdentifier.BABT_AllBands ), "Not a GSM IMEI: " + imeiNumber + " !");
}
}
}
Ce qu'il faut principalement remarquer, ce sont les attributs et les assertions (appels aux méthodes statiques de la classe Assert).
- La classe déclare un attribut
TestFixture(ligne 15). C'est ce qui définit une classe qui contient un lot de tests. - Chaque méthode qui déclare l'attribut
Testest un test unitaire (lignes 18, 25 et 35). - Le test réussit si aucune exception n'est levée.
- Une assertion qui se révèle être fausse lève une exception. Par exemple, la ligne 38 lève une exception si le numéro testé n'est pas un numéro de modem GSM (délivré par l'entité BABT_AllBands). Le message d'erreur de l'exception sera:
Not a GSM IMEI:
suivi du numéro IMEI testé.
Le message d'erreur de l'assertion n'est jamais obligatoire et je vous conseille de toujours en définir un clair.
Cette classe de test est extrêmement simple.
La méthode CheckInvalidIMEI s'assure que l'exception ArgumentException est levée avec un numéro IMEI qui contient davantage de chiffres qu'il ne devrait. La valeur testée (le numéro invalide) est déclarée par l'attribut Values (juste avant l'argument imeiNumber. L'exception est levée par la classe Imei si on tente de l'instancier avec un numéro invalide. par invalide, il faut entendre mauvais nombre de chiffres. Rien ne garantie qu'il s'agit d'un véritable numéro IMEI (à ce propos, les numéros inscrits dans la classe de test existent peut-être mais ils sont factices).
Les autres tests fonctionnent de façon similaire. Remarquez la méthode CheckIMEI_Gsm (ligne 36) qui teste 4 numéros. NUnit lancera ce test 4 fois de suite afin de tester toutes les valeurs indiquées.
Exécution des tests dans NUnit
Le plus simple est d'utiliser le GUI NUnit et d'ouvrir le projet compilé, dans mon cas le fichier ImeiTest.dll. On clique sur le bouton Run et c'est parti :

Simulons un cas de test qui doit échouer en remplaçant le premier chiffre du numéro de la ligne 38. Remarquez que NUnit recharge automatiquement la dll après que le projet ait été recompilé. Le test échoue, et on peut prendre connaissance de la raison:
Précisions utiles
- L'ordre d'exécution des tests n'est pas garanti.
Ces derniers doivent donc réussir quelque soit l'ordre d'exécution. Il est possible de déclarer une méthode d'initialisation à exécuter avant chaque test grâce à l'attribut SetUp.
- Le test de projets WPF nécessite certains pré-requis. Ce sera peut-être l'objet d'un prochain billet.
Pour la suite, je vous invite à essayer sur vos projets et à lire la documentation de référence de NUnit, très basique et relativement claire.
