Wprowadzenie do Mockowania w Testach Jednostkowych z użyciem xUnit
Wprowadzenie do Mockowania w Testach Jednostkowych z użyciem xUnit
W dzisiejszym wpisie przyjrzymy się jednemu z najważniejszych narzędzi w arsenale każdego testera oprogramowania: mockowaniu w ramach testów jednostkowych przy użyciu xUnit. Mockowanie to technika tworzenia imitacji (mocków) obiektów, które symulują zachowanie prawdziwych obiektów w kontrolowany sposób. Jest to niezwykle przydatne, gdy testowane komponenty zależą od innych obiektów, które mogą być trudne do skonfigurowania, kosztowne w utrzymaniu lub niestabilne.
Podstawy Mockowania
Zacznijmy od najprostszego przykładu, pokazującego, jak można zaimplementować mockowanie w testach jednostkowych przy użyciu biblioteki Moq, która jest popularnym narzędziem współpracującym z xUnit.
Prosty Przykład Mockowania Zależności
[Fact]
public void Test_GetData_ReturnsMockedData()
{
// Arrange
// Tworzenie mocka dla interfejsu IRepository
var mockRepository = new Mock<IRepository>();
// Konfiguracja mocka, aby metoda GetData zwracała "Mocked Data"
mockRepository
.Setup(repo => repo.GetData())
.Returns("Mocked Data");
// Inicjalizacja testowanego serwisu z zainjektowanym mockiem
var service = new DataService(mockRepository.Object);
// Act
// Wywołanie metody, która powinna zwrócić zmockowane dane
var result = service.GetData();
// Assert
// Sprawdzenie, czy wynik metody jest zgodny z oczekiwaniami
Assert.Equal("Mocked Data", result);
}
Zaawansowane Scenariusze Mockowania
Po opanowaniu podstaw, możemy przejść do bardziej skomplikowanych przypadków użycia, takich jak mockowanie wywołań asynchronicznych i obsługa wyjątków.
Mockowanie Metod Asynchronicznych
[Fact]
public async Task Test_GetDataAsync_ReturnsMockedData()
{
// Arrange
// Tworzenie mocka dla IRepository
var mockRepository = new Mock<IRepository>();
// Konfiguracja, aby metoda GetDataAsync zwracała "Mocked Async Data" asynchronicznie
mockRepository
.Setup(repo => repo.GetDataAsync())
.ReturnsAsync("Mocked Async Data");
// Utworzenie serwisu z mockiem
var service = new DataService(mockRepository.Object);
// Act
// Asynchroniczne wywołanie metody serwisu
var result = await service.GetDataAsync();
// Assert
// Sprawdzenie, czy wynik jest zgodny z oczekiwaniami
Assert.Equal("Mocked Async Data", result);
}
Obsługa Wyjątków przy Użyciu Mockowania
[Fact]
public void Test_GetData_ThrowsException()
{
// Arrange
// Mockowanie interfejsu IRepository
var mockRepository = new Mock<IRepository>();
// Konfiguracja mocka, aby rzucić wyjątek przy wywołaniu GetData
mockRepository
.Setup(repo => repo.GetData())
.Throws(new InvalidOperationException("Nie udało się uzyskać dostępu do danych"));
// Utworzenie serwisu z mockiem
var service = new DataService(mockRepository.Object);
// Act & Assert
// Testowanie, czy metoda rzuci wyjątek InvalidOperationException
Assert.Throws<InvalidOperationException>(() => service.GetData());
}
Kompleksowe Scenariusze Testowe
Na koniec zobaczmy, jak można zintegrować mockowanie z bardziej złożonymi scenariuszami testowymi, takimi jak testowanie interakcji między komponentami.
Testowanie Interakcji z Użyciem Callbacków
[Fact]
public void Test_UpdateData_CallsSaveChanges()
{
// Arrange - przygotowanie mocka repozytorium i serwisu danych
// Tworzenie mocka dla IRepository
var mockRepository = new Mock<IRepository>();
// Inicjalizacja DataService z mockowanym repozytorium
var service = new DataService(mockRepository.Object);
// Tworzenie obiektu Customer do aktualizacji
var customer = new Customer { Id = "1", Name = "Naruto Uzumaki" };
bool saveCalled = false; // Zmienna do monitorowania wywołania Save
bool saveChangesCalled = false; // Zmienna do monitorowania wywołania SaveChanges
// Konfiguracja mocka repozytorium, aby użyć callbacków do zmiany stanu zmiennych
mockRepository.Setup(repo => repo.Save(It.IsAny<Customer>()))
.Callback<Customer>((cust) => saveCalled = true);
mockRepository.Setup(repo => repo.SaveChanges())
.Callback(() => saveChangesCalled = true);
// Act
// Wywołanie metody UpdateData, która powinna wywołać Save i SaveChanges na repozytorium
service.UpdateData(customer);
// Assert
// Weryfikacja, czy metody Save i SaveChanges zostały wywołane
Assert.True(saveCalled, "Metoda Save powinna zostać wywołana.");
Assert.True(saveChangesCalled, "Metoda SaveChanges powinna zostać wywołana.");
}
Typy obiektów wykorzystywanych w mockowaniu
Poniżej przyjrzymy się różnym typom obiektów wykorzystywanych w mockowaniu, które pomagają w testach jednostkowych: dummy, fake, stub, mock i spy. Zrozumienie tych typów pozwoli na lepsze zrozumienie ich zastosowania i zastosowanie odpowiedniej metody w zależności od potrzeb testu. Bardziej szczegółowe omówienie będzie w osobnym wpisie. Więcej możecie przeczytać tutaj Rozpoznawanie i Zastosowanie Obiektów Testowych w .NET: Dummy, Fake, Stubs, Mocks i Spies
Dummy Objects
Dummy objects to najprostsze obiekty, które są używane, aby wypełnić parametry metod, które są niezbędne do wykonania, ale które nie są używane w żaden inny sposób.
Przykład użycia:
[Fact]
public void TestMethodDummyObjects()
{
// Arrange
// Przygotowanie obiektu dummy i mocka repozytorium
// Tworzenie mocka dla IRepository
var mockRepository = new Mock<IRepository>();
// Tworzenie obiektu dummy, który nie ma realnych danych
var dummyCustomer = new Customer();
// Inicjalizacja CustomerService z mockowanym repozytorium
var service = new CustomerService(mockRepository.Object);
// Act - wykonanie metody testowanej
service.AddCustomer(dummyCustomer);
// Assert
// weryfikacja, że metoda Save została wywołana na repozytorium z przekazanym obiektem dummy
mockRepository.Verify(repo => repo.Save(dummyCustomer),
Times.Once(), "Save powinno zostać wywołane z fikcyjnym klientem.");
// Opcjonalnie można sprawdzić, czy zostały zapisane zmiany
mockRepository.Verify(repo => repo.SaveChanges(),
Times.Once(), "SaveChanges powinno zostać wywołane raz po dodaniu klienta.");
}
Fake Objects
Fakes są bardziej złożone niż dummies. Te obiekty mają działające implementacje, ale zazwyczaj są uproszczone wersje prawdziwych implementacji.
Przykład użycia:
[Fact]
public void TestCustomerAdditionFakeObjects()
{
// Arrange
// Inicjalizacja fałszywego repozytorium
var fakeRepository = new FakeRepository();
// Utworzenie serwisu klienta z fałszywym repozytorium
var service = new CustomerService(fakeRepository);
// Tworzenie nowego obiektu klienta
var newCustomer = new Customer { Id = "123", Name = "Goku" };
// Act
// Dodanie klienta do serwisu
service.AddCustomer(newCustomer);
// Assert
// Sprawdzenie, czy klient został dodany poprawnie
Assert.True(fakeRepository.Exists("123"));
}
Stubs
Stubs dostarczają z góry określone odpowiedzi na wywołania, które są od nich oczekiwane w testach.
Przykład użycia:
[Fact]
public void TestMethodStubs()
{
// Arrange
// Utworzenie mocka repozytorium
var stubRepository = new Mock<IRepository>();
// Konfiguracja stuba, aby metoda GetData zawsze zwracała "Known Data"
stubRepository.Setup(repo => repo.GetData()).Returns("Znane dane");
// Utworzenie serwisu danych z podanym stubem jako zależność
var service = new DataService(stubRepository.Object);
// Act
// Wywołanie metody GetData na serwisie
var data = service.GetData();
// Assert
// Weryfikacja, czy zwrócone dane są zgodne z oczekiwaniami
Assert.Equal("Znane dane", data);
}
Mocks
Mocks są podobne do stubs, ale są używane do sprawdzenia, czy na obiektach wykonane zostały określone działania.
Przykład użycia:
[Fact]
public void TestMethodMock()
{
// Arrange
// Utworzenie mocka repozytorium
var mockRepository = new Mock<IRepository>();
// Konfiguracja mocka, aby weryfikować wywołanie metody Save
mockRepository
.Setup(repo => repo.Save(It.IsAny<Customer>()))
.Verifiable("Należy wywołać funkcję zapisywania klienta.");
// Utworzenie serwisu klienta z podanym mockiem jako zależność
var service = new CustomerService(mockRepository.Object);
// Act
// Wywołanie metody dodającej klienta
service.AddCustomer(new Customer());
// Assert
// Weryfikacja, czy metoda Save została wywołana
mockRepository.Verify();
}
Spies
Spy pozwala na śledzenie wywołań na obiektach, które mają być monitorowane w teście, przy jednoczesnym zachowaniu ich naturalnego zachowania.
Przykład użycia:
[Fact]
public void TestMethodSpies()
{
// Arrange
// Utworzenie mocka listy, która będzie monitorowana
var mockList = new Mock<IList<string>>();
// Act
// Dodanie elementu do zmockowanej listy
mockList.Object.Add("item");
// Assert
// Weryfikacja, czy metoda Add została wywołana dokładnie jeden raz z dowolnym stringiem
mockList.Verify(l => l.Add(It.IsAny<string>()), Times.Once());
}
Definicje klas itp. użytych w przykładach
/// <summary>
/// Definiuje operacje dostępu do danych.
/// </summary>
public interface IRepository
{
/// <summary>
/// Pobiera dane w formie ciągu znaków.
/// </summary>
/// <returns>Zwraca dane jako ciąg znaków.</returns>
string GetData();
/// <summary>
/// Zapisuje obiekt klienta.
/// </summary>
/// <param name="customer">Obiekt klienta do zapisania.</param>
void Save(Customer customer);
/// <summary>
/// Asynchronicznie pobiera dane w formie ciągu znaków.
/// </summary>
/// <returns>Zwraca dane jako ciąg znaków asynchronicznie.</returns>
Task<string> GetDataAsync();
/// <summary>
/// Zapisuje wszystkie zmiany dokonane w repozytorium.
/// </summary>
void SaveChanges();
}
/// <summary>
/// Reprezentuje klienta.
/// </summary>
public class Customer
{
/// <summary>
/// Pobiera lub ustawia identyfikator klienta.
/// </summary>
public string Id { get; set; }
/// <summary>
/// Pobiera lub ustawia nazwę klienta.
/// </summary>
public string Name { get; set; }
}
/// <summary>
/// Serwis obsługujący operacje na klientach.
/// </summary>
/// <remarks>
/// Inicjalizuje nową instancję klasy CustomerService z określonym repozytorium.
/// </remarks>
/// <param name="repository">Repozytorium do obsługi operacji na danych.</param>
public class CustomerService(IRepository repository)
{
private readonly IRepository _repository = repository;
/// <summary>
/// Dodaje klienta i zapisuje zmiany.
/// </summary>
/// <param name="customer">Klient do dodania.</param>
public void AddCustomer(Customer customer)
{
// Zapisuje klienta przy użyciu repozytorium
_repository.Save(customer);
// Zapisuje wszystkie zmiany dokonane w repozytorium
_repository.SaveChanges();
}
}
/// <summary>
/// Serwis do obsługi danych.
/// </summary>
/// <remarks>
/// Inicjalizuje nową instancję klasy DataService z określonym repozytorium.
/// </remarks>
/// <param name="repository">Repozytorium do zarządzania operacjami na danych.</param>
public class DataService(IRepository repository)
{
private readonly IRepository _repository = repository;
/// <summary>
/// Pobiera dane.
/// </summary>
/// <returns>Zwraca dane jako ciąg znaków.</returns>
public string GetData()
{
// Pobiera dane z repozytorium
return _repository.GetData();
}
/// <summary>
/// Asynchronicznie pobiera dane.
/// </summary>
/// <returns>Zwraca dane jako ciąg znaków asynchronicznie.</returns>
public async Task<string> GetDataAsync()
{
// Asynchroniczne pobieranie danych z repozytorium
return await _repository.GetDataAsync();
}
/// <summary>
/// Aktualizuje dane klienta w repozytorium.
/// </summary>
/// <param name="customer">Klient, którego dane mają zostać zaktualizowane.</param>
public void UpdateData(Customer customer)
{
// Zapisuje zmodyfikowanego klienta do repozytorium
_repository.Save(customer);
// Zapisuje wszystkie zmiany dokonane w repozytorium
_repository.SaveChanges();
}
}
/// <summary>
/// Sztuczna implementacja IRepository, używana do testów.
/// </summary>
public class FakeRepository : IRepository
{
private readonly List<Customer> customers = [];
/// <summary>
/// Dodaje obiekt klienta do wewnętrznej listy.
/// </summary>
/// <param name="customer">Obiekt klienta do dodania.</param>
public void Add(Customer customer)
{
// Dodaje klienta do wewnętrznej listy
customers.Add(customer);
}
/// <summary>
/// Sprawdza, czy klient o podanym identyfikatorze istnieje w liście.
/// </summary>
/// <param name="customerId">Identyfikator klienta do sprawdzenia.</param>
/// <returns>Zwraca true, jeśli klient istnieje, inaczej false.</returns>
public bool Exists(string customerId)
{
// Sprawdza, czy klient istnieje w liście
return customers.Any(c => c.Id == customerId);
}
/// <summary>
/// Symuluje pobieranie danych.
/// </summary>
/// <returns>Zwraca przykładowe dane jako ciąg znaków.</returns>
public string GetData()
{
// Zwraca dane jako ciąg znaków, przykładowo z listy klientów
return "Dummy Data";
}
/// <summary>
/// Asynchronicznie symuluje pobieranie danych.
/// </summary>
/// <returns>Zwraca przykładowe dane jako ciąg znaków asynchronicznie.</returns>
public Task<string> GetDataAsync()
{
// Asynchronicznie zwraca dane jako ciąg znaków
return Task.FromResult("Dummy Async Data");
}
/// <summary>
/// Zapisuje obiekt klienta w wewnętrznej liście.
/// </summary>
/// <param name="customer">Obiekt klienta do zapisania.</param>
public void Save(Customer customer)
{
// Zapisuje klienta do listy jeśli jeszcze nie istnieje
if (!customers.Any(c => c.Id == customer.Id))
{
customers.Add(customer);
}
}
/// <summary>
/// Symuluje zapisanie wszystkich zmian dokonanych w repozytorium.
/// </summary>
public void SaveChanges()
{
// Symuluje zapisanie zmian w repozytorium
// W rzeczywistej implementacji tutaj byłoby np. zapisanie do bazy danych
}
}
Podsumowanie
Podsumowując, mockowanie jest niezastąpionym narzędziem w arsenale każdego programisty i testera oprogramowania, które pozwala na efektywną izolację testowanego kodu od jego zależności. W tym wpisie przedstawiłem w ogólny sposób różne typy obiektów używanych w mockowaniu: dummy, fake, stub, mock, oraz spy. Każdy z tych typów ma swoje specyficzne zastosowania i jest kluczowy dla różnych scenariuszy testowych:
- Dummy objects są używane głównie do wypełniania parametrów i nie mają wpływu na wynik testu.
- Fake objects oferują prostsze, kontrolowane implementacje funkcjonalności, które zazwyczaj są bardziej złożone w realnych systemach.
- Stubs dostarczają z góry określone odpowiedzi na wywołania metody, umożliwiając testerom skupienie się na testowaniu określonych aspektów aplikacji.
- Mocks służą do weryfikacji interakcji między komponentami, sprawdzając czy określone metody zostały wywołane.
- Spies pozwalają na obserwację i weryfikację działania rzeczywistych obiektów, rejestrując ich interakcje bez zakłócania ich naturalnych operacji.
Rozumienie, kiedy i jak używać każdego z tych typów obiektów w testach jednostkowych, jest kluczowe dla efektywnego projektowania i wykonania testów. Poprawnie zaimplementowane testy jednostkowe, wykorzystujące techniki mockowania, zwiększają pewność, że system działa zgodnie z oczekiwaniami, jednocześnie zapewniając elastyczność w refaktoryzacji i rozbudowie kodu. Mockowanie nie tylko pomaga w izolacji błędów, ale także stanowi cenne narzędzie do dokumentowania zachowań systemu.
W przyszłych wpisach możemy dokładniej zbadać każdy z tych typów, dostarczając bardziej złożonych przykładów i najlepszych praktyk ich użycia, co pozwoli na jeszcze głębsze zrozumienie i lepsze wykorzystanie mockowania w różnorodnych scenariuszach testowych.
