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<T>.
public class Driver : IEquatable<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; } 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<T>. Załóżmy, że klasa Driver jest z zewnętrznej biblioteki, której nie możemy zmodyfikować. Co możemy w takim przypadku zrobić?
Sposób nr. 1 – Implementacja IEqualityComparer<T>
Interfejs IEqualityComparer<T> definiuje metody służące do porównania obiektów. Stwórzmy klasę DriverComparer implementującą interfejs IEqualityComparer<Driver>.
public class DriverComparer : IEqualityComparer<Driver> { 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<Driver> { new Driver("Adam", "Nowak", 15), new Driver("Kamil", "Misiek", 16) }; var expectedDrivers = new List<Driver> { 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<T>, wykorzystania metody BeEquivalentTo z Fluent Assertions, lub porównania serializowanych obiektów w formacie JSON. Wybór sposobu w celu poradzenia z powyższym problemem zostawiam wam. Znacie może jakieś inne rozwiązania?