W ramach testów jednostkowych może być potrzebne porównywanie wartości obiektów zamiast ich referencji. W dzisiejszym artykule poruszę temat jak porównywać obiekty w testach jednostkowych, jeżeli nie została przesłonięta metoda Equals. Proponowane rozwiązania nie będą wiązały się z dodaniem logiki wymaganej przez testy jednostkowe do klas domenowych. Na początek w ramach wprowadzenia do tematu kilka słów o typie object.
System.Object
Typ danych System.Object jest klasą bazową dla wszystkich klas języka C#. Klasa nie wymaga deklarowania dziedziczenia po klasie Object, ponieważ dziedziczenie jest wykonywane niejawnie. Oznacza to, że dowolna instancja klasy zawsze udostępnia metody zdefiniowane w klasie Object: ToString, Equals, GetHashCode i GetType. Zadaniem metody Equals jest porównanie danego obiektu z dowolnym innym obiektem. Domyślna implementacja metody Equals porównuje referencje. Gdy obiekt zostanie porównany z samym sobą otrzymamy wartość true. Przykładowo typ string przesłania metodę Equals i porównuje obiekty na podstawie wartości. Sprawdźmy to w praktyce, zaimplementujmy klasę Driver.
public class Driver
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
public Driver(string firstName, string lastName, int age)
{
FirstName = firstName;
LastName = lastName;
Age = age;
}
}
Na podstawie wcześniejszej omawianej teorii wiemy, że domyślna metoda Equals nie zwróci dla klasy Driver wartości true, mimo że obiekty posiadają pola o tej samej wartości. Do implementacji testów jednostkowych został wykorzystany framework NUnit.
[Test]
public void Equals_NotImplementIEquatable_DriversAreNotEqual()
{
var actualDriver = new Driver("Adam", "Nowak", 15);
var expectedDriver = new Driver("Adam", "Nowak", 15);
Assert.AreNotEqual(expectedDriver, actualDriver);
}
Oczywiście możemy napisać test, w którym zostanie sprawdzona każda wartość pola obiektu poprzez wykonanie asercji dla każdej właściwości. W celu uruchomienia wszystkich asercji w teście, asercje zostały wywołane w Assert.Multiple.
[Test]
public void Equals_NotImplementIEquatable_DriversAreEqual()
{
var actualDriver = new Driver("Adam", "Nowak", 15);
var expectedDriver = new Driver("Adam", "Nowak", 15);
Assert.Multiple(() =>
{
Assert.AreEqual(expectedDriver.FirstName, actualDriver.FirstName);
Assert.AreEqual(expectedDriver.LastName, actualDriver.LastName);
Assert.AreEqual(expectedDriver.Age, actualDriver.Age);
});
}
Im więcej obiekt ma właściwości tym więcej instrukcji Assert musimy zdefiniować w teście, co pogarsza czytelność testu. Powyższy test możemy poprawić wydzielając asercje do niestandardowej metody asercji, którą umieścimy w osobnej klasie. Idealnie było by stworzyć obiekt do porównania z ustawionymi właściwościami, a następnie porównać obiekt wynikowy z obiektem oczekiwanym w jednej asercji. Powyższą sytuacje mielibyśmy w przypadku przesłonięcia domyślnej metody Equals w obiekcie. Metodę Equals nadpisujemy implementując interfejs IEquatable
public class Driver : IEquatable{ public string FirstName { get; set; } public string LastName { get; set; } public int Age { get; set; } public Driver(string firstName, string lastName, int age) { FirstName = firstName; LastName = lastName; Age = age; } public bool Equals(Driver other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; return Age == other.Age && string.Equals(FirstName, other.FirstName) && string.Equals(LastName, other.LastName); } public override bool Equals(object obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != this.GetType()) return false; return Equals((Driver) obj); } public override int GetHashCode() { unchecked { var hashCode = Age; hashCode = (hashCode * 397) ^ (FirstName != null ? FirstName.GetHashCode() : 0); hashCode = (hashCode * 397) ^ (LastName != null ? LastName.GetHashCode() : 0); return hashCode; } } }
Powyższa sytuacja rozwiązałaby problem, ale nie taki jest cel powyższego wpisu. Zapominamy o przykładowej implementacji interfejsu IEquatable
Sposób nr. 1 – Implementacja IEqualityComparer
Interfejs IEqualityComparer
public class DriverComparer : IEqualityComparer{ public bool Equals(Driver x, Driver y) { if (ReferenceEquals(x, y)) return true; if (ReferenceEquals(x, null)) return false; if (ReferenceEquals(y, null)) return false; if (x.GetType() != y.GetType()) return false; return x.Age == y.Age && string.Equals(x.FirstName, y.FirstName) && string.Equals(x.LastName, y.LastName); } public int GetHashCode(Driver obj) { unchecked { var hashCode = obj.Age; hashCode = (hashCode * 397) ^ (obj.FirstName != null ? obj.FirstName.GetHashCode() : 0); hashCode = (hashCode * 397) ^ (obj.LastName != null ? obj.LastName.GetHashCode() : 0); return hashCode; } } }
Wykorzystamy teraz DriverComparer w celu porównania dwóch obiektów o tych samych wartościach.
[Test]
public void Equals_WhenDriverComparer_DriversAreEqual()
{
var actualDriver = new Driver("Adam", "Nowak", 15);
var expectedDriver = new Driver("Adam", "Nowak", 15);
var driverComparer = new DriverComparer();
Assert.IsTrue(driverComparer.Equals(actualDriver, expectedDriver));
}
Powyższej test możemy lekko zmodyfikować, stosując inną metodę asercji.
[Test]
public void Equals_WhenDriverComparer_DriversAreEqual_V2()
{
var actualDriver = new Driver("Adam", "Nowak", 15);
var expectedDriver = new Driver("Adam", "Nowak", 15);
var driverComparer = new DriverComparer();
Assert.That(actualDriver, Is.EqualTo(expectedDriver).Using(driverComparer));
}
Nic nie stoi nam na przeszkodzie by przetestować także kolekcje obiektów Driver wykorzystując instancje DriverComparer.
[Test]
public void Equals_WhenDriverComparer_DriversCollectionsAreEqual()
{
var actualDrivers = new List
{
new Driver("Adam", "Nowak", 15),
new Driver("Kamil", "Misiek", 16)
};
var expectedDrivers = new List
{
new Driver("Adam", "Nowak", 15),
new Driver("Kamil", "Misiek", 16)
};
var driverComparer = new DriverComparer();
Assert.That(actualDrivers, Is.EqualTo(expectedDrivers).Using(driverComparer));
}
Sposób nr. 2 – BeEquivalentTo z Fluent Assertions
Implementując testy jednostkowe w C# ciągle słyszymy o Xunit, MsTest i NUnit, ale czy istnieją inne alternatywy do pisania asercji. W moim przypadku do implementacji testów wykorzystam bibliotekę Fluent Assertions, która umożliwia pisanie testów w sposób „płynny”. Asercje z Fluent Assertions są zapisane w sposób bardziej naturalny. Sprawia to, że testy jednostkowe mogą być bardziej czytelne dla programisty/testera. Do projektów bibliotekę Fluent Assertions możemy pobrać wykorzystując system dystrybucji bibliotek NuGet. Osoby zainteresowane możliwościami Fluent Assertions zapraszam do lektury dokumentacji. W ramach aktualnych testów interesować będą nas dwie metody Be i BeEquivalentTo. Metoda Be zapewnia, że obiekt jest równy innemu obiektowi przy użyciu implementacji metody System.Object.Equals. W przypadku metody BeEquivalentTo obiekt jest równoważny innemu obiektowi, gdy oba obiekty mają jednakowe właściwości o tych samych wartościach nie zależnie od rodzaju tych obiektów. Załóżmy, że mamy obiekt typu Driver i obiekt typu Client. W przypadku, gdy oba obiekty posiadają właściwości o tych samych nazwach i wartościach, wywołanie metody BeEquivalentTo zakończy się sukcesem. Była teoria teraz czas na testy dla powyższych przypadków.
[Test]
public void Equals_FluentAssertionsUsed_DriversAreNotEqual()
{
var actualDriver = new Driver("Adam", "Nowak", 15);
var expectedDriver = new Driver("Adam", "Nowak", 15);
actualDriver.Should().NotBe(expectedDriver);
}
[Test]
public void Equals_FluentAssertionsUsed_DriversAreEqual()
{
var actualDriver = new Driver("Adam", "Nowak", 15);
var expectedDriver = new Driver("Adam", "Nowak", 15);
actualDriver.Should().BeEquivalentTo(expectedDriver);
}
[Test]
public void Equals_FluentAssertionsUsed_ObjectsAreEqual()
{
var driver = new Driver { FirstName = "Adam", LastName = "Kowalski", Age = 21};
var client = new Client { FirstName = "Adam", LastName = "Kowalski", Age = 21 };
driver.Should().BeEquivalentTo(client);
}
[Test]
public void Equals_FluentAssertionsUsed_DriverAndCustomerAreEqual()
{
var driver = new Driver { FirstName = "Adam", LastName = "Kowalski", Age = 21 };
var customer = new Customer { FirstName = "Adam", Nick = "Kowalski", Age = 21 };
driver.Should().BeEquivalentTo(customer);
}
W ramach implementacji testów jednostkowych utworzyłem klasę Client posiadającą takie same właściwości jak Driver, oraz klasę Customer z zmienioną nazwę właściwości z LastName na Nick w porównaniu do klasy Driver. Na potrzeby ostatnich dwóch testów w celu wychwycenia różnic użyłem inicjalizatora obiektu. Ostatni test uzyskał wynik negatywny mimo takich samych wartości ze względu na różnice w nazwie właściwości. Zobaczmy wyniki wszystkich testów w formie graficznej.
Osoby nieczujące potrzeby wykorzystania Fluent Assertions mogą zaimplementować swoją metodę generyczną, która pobierze właściwości z dwóch obiektów o tych samych nazwach i porówna je. Do implementacji będzie wymagane zastosowanie metod z przestrzeni nazw System.Reflection.
Sposób nr. 3 – JSON Serializer
Kolejną alternatywą jest serializacja obiektów do formatu JSON i porównanie ciągów znaków w metodzie Assert.AreEqual.
[Test]
public void Equals_JsonConverterUsed_DriversAreEqual()
{
var actualDriver = new Driver("Adam", "Nowak", 15);
var expectedDriver = new Driver("Adam", "Nowak", 15);
string actualDriverJson = JsonConvert.SerializeObject(actualDriver);
string expectedDriverJson = JsonConvert.SerializeObject(expectedDriver);
Assert.AreEqual(actualDriverJson, expectedDriverJson);
}
Sprawdźmy jak zachowa się test, gdy zmienimy wartość właściwości LastName w jednym obiekcie z Nowak na Kowalski.
Oczywiście test uzyskał negatywny wynik. W przypadku porównywania JSON-ów szukamy błędu przechodząc pod wskazany indeks w ciągu znaków.
Podsumowanie
W sytuacji, gdy nie chcemy lub nie możemy przesłonić metody Object.Equals w celach testowych, istnieje możliwość zastosowania implementacji IEqualityComparer

