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.
