Rozpoznawanie i Zastosowanie Obiektów Testowych w .NET: Dummy, Fake, Stubs, Mocks i Spies

Rozpoznawanie i Zastosowanie Obiektów Testowych w .NET: Dummy, Fake, Stubs, Mocks i Spies
1 maja 2024 Brak komentarzy Testowanie Tajko

Testowanie jednostkowe to fundament jakości oprogramowania w metodologiach agile i beyond. W języku C# przy użyciu frameworka xUnit, testy jednostkowe często korzystają z różnych typów obiektów testowych, aby izolować komponenty systemu i zapewnić, że każdy test jest niezależny i skupiony na jednym elemencie logiki. W tym wpisie omówimy pięć głównych rodzajów obiektów używanych w testach jednostkowych: Dummy, Fake, Stubs, Mocks i Spies. Każdy z tych typów ma swoje specyficzne zastosowania i cechy, które pomagają w różnych scenariuszach testowych. Przeanalizujemy ich charakterystykę, wady i zalety, a także pokażemy, jak efektywnie ich używać w praktyce przykładowymi kodami.

Dummy Objects

Dummy objects to najprostszy typ obiektów testowych. Są używane głównie jako wypełniacze miejsc w sygnaturach metod, które wymagają parametrów, ale dla których konkretne wartości nie są istotne dla testu.

Zalety:

  • Prostota i szybkość implementacji.
  • Redukcja skomplikowania testów poprzez eliminację niepotrzebnych zależności.

Wady:

  • Brak możliwości weryfikacji interakcji z dummy object.
  • Ograniczona użyteczność poza wypełnianiem parametrów.

Przykład

/// <summary>
/// Testowanie metody, która wymaga obiektu Customer jako parametru.
/// </summary>
[Fact]
public void ProcessOrder_ShouldNotThrow_WithDummyCustomer()
{
    // Arrange
    // Utworzenie dummy object, który nie będzie używany.
    var dummyCustomer = new Customer();
    var orderProcessor = new OrderProcessor();

    // Act
    // Próba przetwarzania zamówienia z dummy customer.
    var exception = Record.Exception(() => orderProcessor.ProcessOrder(dummyCustomer));

    // Assert
    // Sprawdzenie, czy nie wystąpił wyjątek - cel testu.
    Assert.Null(exception);
}

Fake Objects

Fake objects to bardziej rozbudowane niż dummy, implementują rzeczywiste logiki, ale uproszczone, aby zapewnić kontrolowane środowisko testowe bez zależności zewnętrznych, jak bazy danych czy serwisy zewnętrzne.

Zalety:

  • Lepsza kontrola nad wynikami testów.
  • Możliwość symulacji różnych scenariuszy i stanów aplikacji.

Wady:

  • Większy nakład pracy na implementację niż w przypadku dummy objects.
  • Potencjalne ryzyko ukrycia błędów przez niepełną implementację.

Przykład

/// <summary>
/// Testuje, czy metoda GetAllCustomers zwraca wszystkich klientów, gdy jest wywołana.
/// </summary>
[Fact]
public void GetAll_ShouldReturnAllCustomers_WhenCalled()
{
    // Arrange - przygotowanie środowiska testowego
    // Utworzenie instancji fałszywego repozytorium
    var fakeRepository = new FakeCustomerRepository();

    // Utworzenie serwisu z fałszywym repozytorium
    var customerService = new CustomerService(fakeRepository);

    // Tworzenie przykładowych klientów
    var customer1 = new Customer { Id = 1, Name = "Naruto Uzumaki" }; 
    var customer2 = new Customer { Id = 2, Name = "Hinata Hyūga" };

    // Dodanie klientów do fałszywego repozytorium
    fakeRepository.Add(customer1);
    fakeRepository.Add(customer2);

    // Act - wykonanie metody testowanej
    var customers = customerService.GetAllCustomers();

    // Assert - weryfikacja wyników
    // Sprawdzenie, czy wynik nie jest null
    Assert.NotNull(customers);

    // Sprawdzenie, czy liczba zwróconych klientów jest równa 2
    Assert.Equal(2, customers.Count());
    
    // Sprawdzenie, czy pierwszy klient jest obecny w wyniku
    Assert.Contains(customer1, customers);

    // Sprawdzenie, czy drugi klient jest obecny w wyniku
    Assert.Contains(customer2, customers);
}

Stubs

Stubs to obiekty, które zawierają z góry zdefiniowane odpowiedzi na wywołania metod. Są używane do testowania zachowań systemu w odpowiedzi na konkretne dane wejściowe.

Zalety:

  • Precyzyjna kontrola nad wynikami zwracanymi przez metody.
  • Możliwość łatwego testowania zachowań systemu przy różnych warunkach.

Wady:

  • Nie nadają się do testowania interakcji pomiędzy obiektami.
  • Możliwość przeoczenia nieprzewidzianych interakcji lub błędów.

Przykład

/// <summary>
/// Testowanie metody obliczającej podatek na podstawie zmockowanego repozytorium stawek podatkowych.
/// </summary>
[Fact]
public void CalculateTax_ShouldReturnCorrectAmount_UsingStubbedTaxRate()
{
    // Arrange
    // Utworzenie stuba dla repozytorium podatkowego.
    var stubTaxRepository = new Mock<ITaxRepository>();

    // Ustawienie stałej stawki podatkowej.
    stubTaxRepository.Setup(repo => repo.GetTaxRate(It.IsAny<string>())).Returns(0.2m);

    var taxCalculator = new TaxCalculator(stubTaxRepository.Object);

    // Act
    var tax = taxCalculator.CalculateTax("PL", 100);

    // Assert
    // Weryfikacja, czy obliczona wartość podatku jest poprawna.
    Assert.Equal(20, tax);
}

Mocks

Mocks to obiekty, które symulują zachowania zależności w testowanych klasach. Mogą być używane do weryfikacji, czy określone metody były wywoływane z odpowiednimi argumentami.

Zalety:

  • Możliwość weryfikacji, czy odpowiednie metody zostały wywołane.
  • Kontrola nad zachowaniem mockowanych metod bez wpływu na resztę systemu.

Wady:

  • Wyższa złożoność w implementacji i konfiguracji.
  • Ryzyko nadmiernego skupienia się na detalach implementacji zależności.

Przykład

/// <summary>
/// Testowanie, czy metoda aktualizacji użytkownika wywołuje odpowiednie metody w repozytorium.
/// </summary>
[Fact]
public void UpdateUser_ShouldCallSaveChanges_WhenUserIsValid()
{
    // Arrange
    // Utworzenie mocka dla repozytorium użytkowników.
    var mockUserRepository = new Mock<IUserRepository>();

    // Konfiguracja weryfikowalnej metody.
    mockUserRepository.Setup(repo => repo.Update(It.IsAny<User>())).Verifiable();

    var userService = new UserService(mockUserRepository.Object);
    var validUser = new User { Id = 1, Name = "Naruto Uzumaki", IsActive = true };

    // Act
    userService.UpdateUser(validUser);

    // Assert
    // Weryfikacja, czy metoda Update została wywołana.
    mockUserRepository.Verify(repo => repo.Update(It.IsAny<User>()), Times.Once());

    // Weryfikacja, czy metoda SaveChanges została wywołana.
    mockUserRepository.Verify(repo => repo.SaveChanges(), Times.Once());
}

Spies

Spies to specjalne obiekty, które pozwalają na monitorowanie interakcji z rzeczywistymi obiektami w testach, jednocześnie zachowując ich naturalne zachowanie.

Zalety:

  • Możliwość obserwacji wywołań metod na rzeczywistych obiektach bez ich modyfikacji.
  • Umożliwiają testowanie w bardziej naturalnych warunkach.

Wady:

  • W C# i .NET, spies nie są natywnie wspierane przez popularne biblioteki mockowania jak Moq; mogą wymagać dodatkowych narzędzi lub niestandardowych rozwiązań.

Przykład

W C# nie ma bezpośredniego wsparcia dla spies w popularnych bibliotekach takich jak Moq, ale możemy osiągnąć podobne zachowanie poprzez użycie kombinacji technik mockowania i delegowania do rzeczywistych instancji. Oto przykład użycia “manualnego” spy w testach jednostkowych:

/// <summary>
/// Testuje, czy metoda Add jest wywoływana z oczekiwanymi parametrami, używając metody spy.
/// Test sprawdza, czy weryfikacja poprawności parametrów metody Add działa prawidłowo
/// przy założeniu, że rzeczywista implementacja metody jest wywoływana.
/// </summary>
[Fact]
public void Add_ShouldBeCalled_WithCorrectParameters()
{
    // Arrange
    var realCalculator = new RealCalculator();

    // Ustawienie, że mock powinien delegować wywołania do rzeczywistej klasy
    var spyCalculator = new Mock<RealCalculator>
    {
        CallBase = true 
    };

    // Act
    var result = spyCalculator.Object.Add(2, 3);

    // Assert
    // Weryfikacja, czy metoda Add została wywołana z oczekiwanymi argumentami.
    spyCalculator.Verify(x => x.Add(2, 3), Times.Once());

    // Dodatkowa weryfikacja wyniku, aby upewnić się, że metoda działa jak oczekiwano.
    Assert.Equal(5, result);
}

Definicje klas itp. użytych w przykładach

#region Dummy Objects

/// <summary>
/// Reprezentuje klienta.
/// </summary>
public class Customer
{
    /// <summary>
    /// Identyfikator klienta.
    /// </summary>
    public int Id { get; set; }

    /// <summary>
    /// Imię klienta.
    /// </summary>
    public string Name { get; set; }
}

/// <summary>
/// Procesor zamówień obsługujący logikę przetwarzania zamówień klientów.
/// </summary>
public class OrderProcessor
{
    /// <summary>
    /// Przetwarza zamówienie dla klienta.
    /// </summary>
    /// <param name="customer">Klient, dla którego przetwarzane jest zamówienie.</param>
    public void ProcessOrder(Customer customer)
    {
        // Logika przetwarzania zamówienia dla klienta
        Console.WriteLine("Processing order for customer: " + customer.Name);
    }
}

#endregion Dummy Objects

#region Fake Objects

/// <summary>
/// Definiuje kontrakt dla repozytorium klientów, umożliwiający operacje CRUD na danych klientów.
/// </summary>
public interface ICustomerRepository
{
    /// <summary>
    /// Dodaje nowego klienta do repozytorium.
    /// </summary>
    /// <param name="customer">Klient do dodania.</param>
    void Add(Customer customer);

    /// <summary>
    /// Pobiera wszystkich klientów z repozytorium.
    /// </summary>
    /// <returns>Kolekcja klientów.</returns>
    IEnumerable<Customer> GetAll();
}

/// <summary>
/// Sztuczna implementacja repozytorium klientów, używana do celów testowych.
/// </summary>
public class FakeCustomerRepository : ICustomerRepository
{
    /// <summary>
    /// Lista przechowująca klientów
    /// </summary>
    private readonly List<Customer> _customers = [];

    /// <summary>
    /// Dodaje klienta do wewnętrznej listy.
    /// </summary>
    /// <param name="customer">Klient do dodania.</param>
    public void Add(Customer customer)
    {
        // Dodanie klienta do listy
        _customers.Add(customer);
    }

    /// <summary>
    /// Zwraca wszystkich klientów.
    /// </summary>
    /// <returns>Kolekcja klientów.</returns>
    public IEnumerable<Customer> GetAll()
    {
        // Zwrócenie wszystkich klientów z listy
        return _customers;
    }
}

/// <summary>
/// Serwis obsługujący operacje na klientach.
/// </summary>
/// <remarks>
/// Inicjalizuje nową instancję klasy CustomerService z podanym repozytorium klientów.
/// </remarks>
/// <param name="repository">Repozytorium klientów używane do operacji na danych.</param>
public class CustomerService(ICustomerRepository repository)
{
    /// <summary>
    /// Pole do przechowywania referencji do repozytorium klientów
    /// </summary>
    private readonly ICustomerRepository _repository = repository;

    /// <summary>
    /// Dodaje klienta do repozytorium.
    /// </summary>
    /// <param name="customer">Klient do dodania.</param>
    public void AddCustomer(Customer customer)
    {
        // Wywołanie metody Add z repozytorium
        _repository.Add(customer);
    }

    /// <summary>
    /// Pobiera wszystkich klientów z repozytorium.
    /// </summary>
    /// <returns>Kolekcja klientów.</returns>
    public IEnumerable<Customer> GetAllCustomers()
    {
        // Pobranie i zwrócenie wszystkich klientów
        return _repository.GetAll();
    }
}

#endregion Fake Objects

#region Stubs

/// <summary>
/// Definiuje kontrakt dla repozytorium zarządzającego stawkami podatkowymi.
/// </summary>
public interface ITaxRepository
{
    /// <summary>
    /// Pobiera stawkę podatkową dla podanego regionu.
    /// </summary>
    /// <param name="region">Region, dla którego pobierana jest stawka podatkowa.</param>
    /// <returns>Stawka podatkowa.</returns>
    decimal GetTaxRate(string region);
}

/// <summary>
/// Kalkulator podatków, który oblicza podatek na podstawie stawek z repozytorium.
/// </summary>
/// <remarks>
/// Inicjalizuje nową instancję klasy TaxCalculator z podanym repozytorium podatkowym.
/// </remarks>
/// <param name="taxRepository">Repozytorium podatków używane do obliczeń.</param>
public class TaxCalculator(ITaxRepository taxRepository)
{
    /// <summary>
    /// Repozytorium dostarczające stawki podatkowe
    /// </summary>
    private readonly ITaxRepository _taxRepository = taxRepository;

    /// <summary>
    /// Oblicza podatek dla danego regionu i kwoty.
    /// </summary>
    /// <param name="region">Region, dla którego obliczany jest podatek.</param>
    /// <param name="amount">Kwota, dla której obliczany jest podatek.</param>
    /// <returns>Obliczona kwota podatku.</returns>
    public decimal CalculateTax(string region, decimal amount)
    {
        // Pobranie stawki podatkowej dla regionu
        var taxRate = _taxRepository.GetTaxRate(region);

        // Obliczenie i zwrócenie kwoty podatku
        return amount * taxRate;
    }
}

#endregion Stubs

#region Mocks

/// <summary>
/// Definiuje kontrakt dla repozytorium użytkowników, umożliwiający operacje CRUD na danych użytkowników.
/// </summary>
public interface IUserRepository
{
    /// <summary>
    /// Aktualizuje dane użytkownika w repozytorium.
    /// </summary>
    /// <param name="user">Użytkownik do aktualizacji.</param>
    void Update(User user);

    /// <summary>
    /// Zapisuje wszystkie zmiany dokonane w repozytorium.
    /// </summary>
    void SaveChanges();
}

/// <summary>
/// Serwis obsługujący operacje na użytkownikach.
/// </summary>
/// <remarks>
/// Inicjalizuje nową instancję klasy UserService z podanym repozytorium użytkowników.
/// </remarks>
/// <param name="userRepository">Repozytorium użytkowników do operacji na danych.</param>
public class UserService(IUserRepository userRepository)
{
    // Repozytorium użytkowników używane do operacji na danych
    private readonly IUserRepository _userRepository = userRepository; 

    /// <summary>
    /// Aktualizuje dane użytkownika w repozytorium i zapisuje zmiany.
    /// </summary>
    /// <param name="user">Użytkownik do aktualizacji.</param>
    public void UpdateUser(User user)
    {
        // Aktualizacja danych użytkownika
        _userRepository.Update(user);

        // Zapisanie zmian w repozytorium
        _userRepository.SaveChanges();
    }
}

/// <summary>
/// Reprezentuje użytkownika w systemie.
/// </summary>
public class User
{
    /// <summary>
    /// Identyfikator użytkownika.
    /// </summary>
    public int Id { get; set; }

    /// <summary>
    /// Imię i nazwisko użytkownika.
    /// </summary>
    public string NameAndLastname { get; set; }

    /// <summary>
    /// Określa, czy użytkownik jest aktywny.
    /// </summary>
    public bool IsActive { get; set; }
}

#endregion Mocks

#region Spies

/// <summary>
/// Prosta klasa kalkulatora z funkcją dodawania.
/// </summary>
public class RealCalculator
{
    /// <summary>
    /// Dodaje dwie liczby całkowite.
    /// </summary>
    /// <param name="x">Pierwsza liczba do dodania.</param>
    /// <param name="y">Druga liczba do dodania.</param>
    /// <returns>Suma dwóch liczb całkowitych.</returns>
    public virtual int Add(int x, int y)
    {
        // Zwraca sumę dwóch liczb
        return x + y;
    }
}

#endregion Spies

Podsumowanie

W tym wpisie omówiłem pięć głównych typów obiektów testowych używanych w testach jednostkowych w C# i .NET przy użyciu xUnit: Dummy, Fake, Stubs, Mocks i Spies. Każdy z nich ma swoje miejsce i zastosowanie w różnych scenariuszach testowych, a ich odpowiednie wykorzystanie może znacznie przyspieszyć i ułatwić proces testowania oprogramowania. Rozumienie różnic między tymi obiektami oraz kiedy i jak ich używać, jest kluczowe dla skutecznych testów jednostkowych, które zapewniają wysoką jakość i niezawodność kodu.

Warto też tutaj Argument Matching w Moq (np. It.Is, It.IsAny, Verify) rzucić okiem by przeczytać sobie o metodkach używanych w Setupie takich jak It.Is, It.IsAny itp. albo różne metody Verify.

Tagi
O Autorze
Tajko
Tajko Tajko z tej strony:) Obecnie pracuję we Wrocławiu przy projektach desktopowych. Skoro sam się czegoś nauczyłem to i inni mogliby nauczyć się tego co ja w łopatologiczny sposób:)

ZOSTAW ODPOWIEDŹ