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
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.
