Tests unitaires : Comment comparer deux collections qui ne sont pas dans le même ordre ?

écrit par Damien publié le jeudi 12 janvier 2017
 
La rédaction de tests unitaires est un élément important (voir indispensable) dans le développement d’une application. Pour l’aider à écrire ses tests unitaires, le développeur utilise généralement un framework de test (MS Test, NFluent, NUnit…). Récemment j’ai cherché à comparer deux collections n’étant pas dans le même ordre. Ces collections étaient composées d’objets de type non primitifs. Pour réaliser mes tests il me fallait une méthode de test capable de :
  • Comparer 2 collections qui ne sont pas dans le même ordre
  • Comparer 2 objets non primitifs à l’aide d’IEqualityComparer
  • Garder le côté « fluent » de mes tests

Mes recherches :

J’ai donc effectué un comparatif de différentes solutions pouvant répondre à ma problématique :   CollectionAssert.AreEquivalent
  • Framework MsTest
  • Permet de comparer deux collections qui ne sont pas forcément dans le même ordre
  • Nécessite un override d’Equals et GetHashCode pour les types non primitifs
Enumerable.SequenceEqual
  • Méthode d’extension d’IEnumerable (Linq)
  • Permet de comparer deux collections dans le même ordre uniquement
  • Nécessite l’utilisation d’un IEqualityComparer ou l’override d’Equals et GetHashCode pour les types non primitifs
Le problème lorsqu’on utilise SequenceEqual dans un test unitaire est la perte de l’aspect « Fluent » et donc de la lisibilité. Check.That(collectionToTest).Contains(ExpectedCollection)
  • Framework NFluent
  • Permet de comparer deux collections qui ne sont pas forcément dans le même ordre
  • Nécessite un override d’Equals et GetHashCode pour les types non primitifs
  • Ne permet pas l’utilisation d’un IEqualityComparer
CollectionAssert.AreEquivalent et Check.That(collectionToTest).Contains(ExpectedCollection) répondent en grande partie à la problématique. En revanche je me suis aperçu qu’aucun framework de test ne permettait l’utilisation d’un IEqualityComparer. Dans le cadre de mes tests je ne souhaitais pas faire l’override de Equals et de GetHashCode car :
  • je ne voulais pas ajouter du comportement qui ne serait utilisé que dans des tests unitaires dans un code utilisé en production
  • en mode test unitaire j’ai besoin dans certains cas d’exclure certaines propriétés de l’Equals (par exemple l’Id de type Guid généré à l’intérieur de la méthode testée).

Ma Solution :

J’ai donc décidé d’écrire ma propre méthode de comparaison de collection et je l’ai intégrée dans NFluent : Check.That(collectionToTest).IsEqualNotOrdered(expectedCollection)
  • Méthode d’extension pour NFluent
  • Permet de comparer deux collections qui ne sont pas forcément dans le même ordre
  • Nécessite soit un override d’Equals et GetHashCode soit l’utilisation d’un IEqualityComparer pour les types non primitifs

Explication du code :

public static class EnumerableExtensions
{
	/// <summary>
	/// Vérifie que deux listes sont égales sans ordre avec un IEqualityComparer particulier.
	/// </summary>
	/// <typeparam name="T">Type des éléments des collections.</typeparam>
	/// <param name="collectionToTest">Collection source.</param>
	/// <param name="expectedCollection">Collection cible.</param>
	/// <param name="comparer">IEqualityComparer à utiliser.</param>
	/// <returns>Vrai si les deux collections sont égales et ordonnées différements.</returns>
	public static bool IsEqualNotOrdered<T>(this IEnumerable<T> collectionToTest, IEnumerable<T> expectedCollection, IEqualityComparer<T> comparer = null)
	{
		var currentCollectionToTest = collectionToTest.ToList();
		var currentExpectedCollection = expectedCollection.ToList();

		if (currentCollectionToTest.Count != currentExpectedCollection.Count)
		{
			return false;
		}

		foreach (var itemToFind in currentCollectionToTest)
		{
			var isFind = false;

			for (var i = 0; i < currentExpectedCollection.Count; i++)
			{
				var itemToTest = currentExpectedCollection[i];
				var isEquals = comparer?.Equals(itemToFind, itemToTest) ?? itemToFind.Equals(itemToTest);

				if (isEquals)
				{
					currentExpectedCollection.RemoveA(i);
					isFind = true;

					break;
				}
			}
			if (!isFind)
			{
				return false;
			}
		}
		return true;
	}
}
Dans un premier temps on vérifie que les collections ont le même nombre d’items. Puis on parcourt la première collection et pour chaque item on va parcourir la deuxième collection à la recherche d’une correspondance (en utilisant l’EqualityComparer ou object.Equal dans le cas où le comparer est null). Si aucune correspondance n’est trouvée, on s’arrête là. Sinon on exclut l’item correspondant de la deuxième collection. On fait cette exclusion d’une part pour ne pas comparer à nouveau un item ayant déjà une correspondance mais aussi pour gérer le cas des doublons : Par exemple les listes suivantes :
var list1 = new List<int>(){1, 2, 2};
var list2 = new List<int>(){1, 2, 3};
ressortiraient égales sans cette gestion des doublons. En l’état actuel, l’utilisation de cette méthode dans un test unitaire fonctionnerait très bien de la manière suivante (avec NFluent par exemple) :
Check.That(collectionToTest.IsEqualNotOrdered(expectedCollection, myCustomComparer)).IsTrue();
Mais nous perdrions le côté « Fluent » du test et donc de la lisibilité. C’est pourquoi je l’ai intégrée en tant que méthode d’extension à NFluent :
public static class NFluentExtension
{
	public static ICheckLink<ICheck<ICollection<T>>> IsEqualNotOrdered<T>(this ICheck<ICollection<T>> check, ICollection<T> target, IEqualityComparer<T> equalityComparer = null)
	{
		var runnableCheck = ExtensibilityHelper.ExtractChecker(check);
		var value = runnableCheck.Value;

		return runnableCheck.ExecuteCheck(() =>
		{
			if (!value.IsEqualNotOrdered(target, equalityComparer))
			{
				throw new FluentCheckException(String.Format("{0} n'est pas égale à {1}.", value, target));
			}
		}, String.Format("{0} est pas égale à {1}.", value, target));
	}
}
 

Bonus :

J’ai réalisé le même principe mais pour comparer deux dictionnaires : Check.That(dictionnaryToTest).IsEqualNotOrdered(expectedDictionnary)
  • Méthode d’extension pour NFluent
  • Permet de comparer deux dictionnaires qui ne sont pas forcément dans le même ordre
  • Si la clé du dictionnaire n’est pas un type primitif, il faut faire un override d’Equals et GetHashCode
  • Si les valeurs du dictionnaire sont des objets non primitifs, on peut soit faire un override d’Equals et GetHashCode ou utiliser un lEqualityComparer
Le code du comparer :
public static class DictionnaryExtension
{
	public static bool IsEqualNotOrdered<TKey, TValue>(this IDictionary<TKey, TValue> dictionnaryToTest, IDictionary<TKey, TValue> expectedDictionnary, IEqualityComparer<TValue> valueComparer = null)
	{
		if (expectedDictionnary.Count != dictionnaryToTest.Count)
			return false;

		foreach (var expectedItem in expectedDictionnary)
		{
			TValue valueToTest;

			if (dictionnaryToTest.TryGetValue(expectedItem.Key, out valueToTest))
			{
				if (valueComparer == null)
				{
					if (!valueToTest.Equals(expectedItem.Value))
						return false;
				}
				else
				{
					if (!valueComparer.Equals(valueToTest, expectedItem.Value))
						return false;
				}
			}
			else
				return false;
		}
		return true;
	}
}
Et le code NFluent :
public static class NFluentExtension
{
	public static ICheckLink<ICheck<IDictionary<TKey, TValue>>> IsEqualNotOrdered<TKey, TValue>(this ICheck<IDictionary<TKey, TValue>> check, IDictionary<TKey, TValue> target, IEqualityComparer<TValue> equalityComparer = null)
	{
		var runnableCheck = ExtensibilityHelper.ExtractChecker(check);
		var value = runnableCheck.Value;

		return runnableCheck.ExecuteCheck(() =>
		{
			if (!value.IsEqualNotOrdered(target, equalityComparer))
			{
				throw new FluentCheckException(String.Format("{0} n'est pas égale à {1}.", value, target));
			}
		}, String.Format("{0} est pas égale à {1}.", value, target));
	}
}