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.

Equals Tests

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.

Equals json

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?